Новые роуты API для статистики и мониторинга

- /api/version
- /api/health
- /api/stats
This commit is contained in:
2025-11-01 00:58:25 +08:00
parent c75da39b87
commit b5d3b60356
4 changed files with 162 additions and 84 deletions

View File

@@ -9,6 +9,9 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\Bot;
use App\Core\Kernel;
use App\Core\StatisticsService;
use App\Errors\PlaylistNotFoundException; use App\Errors\PlaylistNotFoundException;
use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions; use chillerlan\QRCode\QROptions;
@@ -81,4 +84,106 @@ class ApiController extends BasicController
return $response->withStatus(200) return $response->withStatus(200)
->withHeader('Content-Type', $mime); ->withHeader('Content-Type', $mime);
} }
/**
* Возвращает информацию о плейлисте
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws Exception
*/
public function version(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return $this->responseJson($response, 200, [
'web' => Kernel::VERSION,
'php' => PHP_VERSION,
'keydb' => redis()->info('server')['redis_version'],
'checker' => 'todo',
]);
}
/**
* Возвращает информацию о плейлисте
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws Exception
*/
public function health(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
function getSize(string $directory): int
{
$size = 0;
foreach (glob($directory . '/*') as $path){
is_file($path) && $size += filesize($path);
is_dir($path) && $size += getSize($path);
}
return $size;
}
$tgBotInfo = config('bot.token') ? new Bot()->api->getMe() : null;
$redisInfoServer = redis()->info('server'); // General information about the Redis server
$redisInfoClients = redis()->info('clients'); // Client connections section
$redisInfoMemory = redis()->info('memory'); // Memory consumption related information
$redisInfoPers = redis()->info('persistence'); // RDB and AOF related information
$redisInfoStats = redis()->info('stats'); // General statistics
$redisInfoCpu = redis()->info('cpu'); // CPU consumption statistics
$redisInfoCmd = redis()->info('commandstats'); // Redis command statistics
$redisInfoKeysp = redis()->info('keyspace'); // Database related statistics
$redisInfoErr = redis()->info('errorstats'); // Redis error statistics
$health = [
'fileCache' => [
'tv-logos' => [
'sizeB' => $size = getSize(cache_path('tv-logos')),
'sizeMiB' => round($size / 1024 / 1024, 3),
'count' => count(glob(cache_path('tv-logos') . '/*')),
],
'qr-codes' => [
'sizeB' => $size = getSize(cache_path('qr-codes')),
'sizeMiB' => round($size / 1024 / 1024, 3),
'count' => count(glob(cache_path('qr-codes') . '/*')),
],
],
'telegram' => [
'id' => $tgBotInfo->getId(),
'first_name' => $tgBotInfo->getFirstName(),
'username' => $tgBotInfo->getUsername(),
],
'redis' => [
'isConnected' => redis()->isConnected(),
'info' => [
'server' => [
'uptime_in_seconds' => $redisInfoServer['uptime_in_seconds'],
'uptime_in_days' => $redisInfoServer['uptime_in_days'],
],
'clients' => ['connected_clients' => $redisInfoClients['connected_clients']],
'memory' => $redisInfoMemory,
'persistence' => $redisInfoPers,
'stats' => $redisInfoStats,
'cpu' => $redisInfoCpu,
'commandstats' => $redisInfoCmd,
'keyspace' => $redisInfoKeysp,
'errorstats' => $redisInfoErr,
],
],
];
return $this->responseJson($response, 200, $health);
}
/**
* Возвращает информацию о плейлисте
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws Exception
*/
public function stats(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return $this->responseJson($response, 200, new StatisticsService()->get());
}
} }

View File

@@ -49,8 +49,9 @@ class BasicController
* @param array $data * @param array $data
* @return ResponseInterface * @return ResponseInterface
*/ */
protected function responseJson(ResponseInterface $response, int $status, array $data): ResponseInterface protected function responseJson(ResponseInterface $response, int $status, mixed $data): ResponseInterface
{ {
is_scalar($data) && $data = [$data];
$data = array_merge(['timestamp' => time()], $data); $data = array_merge(['timestamp' => time()], $data);
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -33,37 +33,35 @@ class Bot
/** /**
* @var BotApi Объект Telegram Bot API * @var BotApi Объект Telegram Bot API
*/ */
protected BotApi $bot; public readonly BotApi $api;
/** /**
* @var Update Объект обновления бота * @var Update Объект обновления бота
*/ */
protected Update $update; protected Update $update;
/**
* @var ServerRequestInterface Пришедший от Telegram запрос
*/
protected ServerRequestInterface $request;
/** /**
* Конструктор * Конструктор
* *
* @param ServerRequestInterface $request * @param ServerRequestInterface|null $request
* @throws InvalidTelegramSecretException * @throws InvalidTelegramSecretException
* @throws JsonException
* @throws InvalidArgumentException
* @throws Exception
*/ */
public function __construct(ServerRequestInterface $request) public function __construct(?ServerRequestInterface $request = null)
{ {
$this->checkSecret($request); $this->api = new BotApi(config('bot.token'));
if ($request) {
$body = json_decode((string)$request->getBody(), true); $this->checkSecret($request);
if (json_last_error() !== JSON_ERROR_NONE) { $this->request = $request;
throw new JsonException(json_last_error_msg());
} }
$this->bot = new BotApi(config('bot.token'));
$this->update = Update::fromResponse($body);
} }
/** /**
* Запсукает обработку команды * Запускает обработку команды
* *
* @return bool * @return bool
* @throws InvalidArgumentException * @throws InvalidArgumentException
@@ -72,7 +70,9 @@ class Bot
*/ */
public function process(): bool public function process(): bool
{ {
$this->parseRequestBody();
$commandText = $this->getBotCommandText(); $commandText = $this->getBotCommandText();
return match (true) { return match (true) {
str_starts_with($commandText, '/start') => $this->processHelpCommand(), str_starts_with($commandText, '/start') => $this->processHelpCommand(),
str_starts_with($commandText, '/list') => $this->processListCommand(), str_starts_with($commandText, '/list') => $this->processListCommand(),
@@ -84,6 +84,22 @@ class Bot
}; };
} }
/**
* Подготавливает объект события бота
*
* @return void
* @throws InvalidArgumentException
* @throws JsonException
*/
protected function parseRequestBody(): void
{
$body = json_decode((string)$this->request->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new JsonException(json_last_error_msg());
}
$this->update = Update::fromResponse($body);
}
/** /**
* Обрабатывает команду /list * Обрабатывает команду /list
* *
@@ -94,7 +110,7 @@ class Bot
*/ */
protected function processListCommand(): bool protected function processListCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$playlists = ini()->getPlaylists(); $playlists = ini()->getPlaylists();
if (empty($playlists)) { if (empty($playlists)) {
@@ -139,7 +155,7 @@ class Bot
*/ */
protected function processInfoCommand(): bool protected function processInfoCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$message = $this->update->getMessage(); $message = $this->update->getMessage();
$text = $message->getText(); $text = $message->getText();
@@ -235,7 +251,7 @@ class Bot
*/ */
protected function processHelpCommand(): bool protected function processHelpCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' . $replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' .
$this->escape(base_url()) . '\.'; $this->escape(base_url()) . '\.';
@@ -265,7 +281,7 @@ class Bot
*/ */
protected function processLinksCommand(): bool protected function processLinksCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = '*Ресурсы и страницы*'; $replyText[] = '*Ресурсы и страницы*';
$replyText[] = ''; $replyText[] = '';
@@ -288,69 +304,7 @@ class Bot
*/ */
protected function processStatsCommand(): bool protected function processStatsCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$allChannels = [];
foreach (ini()->getPlaylists() as $pls) {
$allChannels = array_merge($allChannels, $pls['channels'] ?? []);
}
$onlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === true,
);
$offlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === false,
);
$unknownPls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === null,
);
$adultPls = array_filter(
$onlinePls,
static fn (array $pls) => in_array('adult', $pls['tags']),
);
$catchupPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasCatchup'] === true,
);
$tvgPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasTvg'] === true,
);
$grouppedPls = array_filter(
$onlinePls,
static fn (array $pls) => count($pls['groups'] ?? []) > 0
);
$onlineCh = $offlineCh = $adultCh = [];
foreach ($onlinePls as $pls) {
$tmpOnline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === true,
);
$tmpOffline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === false,
);
$tmpAdult = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => in_array('adult', $ch['tags']),
);
$onlineCh = array_merge($onlineCh, $tmpOnline);
$offlineCh = array_merge($offlineCh, $tmpOffline);
$adultCh = array_merge($adultCh, $tmpAdult);
}
$stats = new StatisticsService()->get(); $stats = new StatisticsService()->get();
$replyText[] = '📊 *Статистика*'; $replyText[] = '📊 *Статистика*';
@@ -437,7 +391,7 @@ class Bot
InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard = null, InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard = null,
): bool { ): bool {
try { try {
$this->bot->sendMessage( $this->api->sendMessage(
chatId: $this->update->getMessage()->getChat()->getId(), chatId: $this->update->getMessage()->getChat()->getId(),
text: $text, text: $text,
parseMode: 'MarkdownV2', parseMode: 'MarkdownV2',

View File

@@ -67,6 +67,24 @@ return [
'handler' => [ApiController::class, 'makeQrCode'], 'handler' => [ApiController::class, 'makeQrCode'],
'name' => 'api::makeQrCode', 'name' => 'api::makeQrCode',
], ],
[
'method' => 'GET',
'path' => '/api/version',
'handler' => [ApiController::class, 'version'],
'name' => 'api::version',
],
[
'method' => 'GET',
'path' => '/api/health',
'handler' => [ApiController::class, 'health'],
'name' => 'api::health',
],
[
'method' => 'GET',
'path' => '/api/stats',
'handler' => [ApiController::class, 'stats'],
'name' => 'api::stats',
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------