From bed6d4fcfcbe27025fadf279cb8d8aa6c4c24371 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 2 Aug 2026 13:49:48 +0300 Subject: [PATCH] Add diagnostics endpoints and types for improved system monitoring Implemented new diagnostics endpoints in the API and added corresponding route configurations in the frontend. Introduced types for diagnostics data, including host, forwarding, and system diagnostics, to enhance monitoring capabilities. Updated localization files to support diagnostics UI elements in both English and Russian, ensuring a comprehensive user experience. --- .../Endpoints/DiagnosticsEndpoints.cs | 162 +++++++++ backend/src/TeleWave.Api/Program.cs | 1 + .../Common/Interfaces/IDiagnosticsProbe.cs | 26 ++ .../Diagnostics/GetSystemDiagnosticsQuery.cs | 29 ++ .../GetSystemDiagnosticsQueryHandler.cs | 64 ++++ .../DependencyInjection.cs | 1 + .../Media/DiagnosticsProbe.cs | 111 ++++++ .../admin/diagnostics/DiagnosticsPanel.tsx | 343 ++++++++++++++++++ .../src/features/admin/diagnostics/api.ts | 7 + frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/admin.tsx | 7 + frontend/src/routes/admin/diagnostics.tsx | 4 + frontend/src/shared/api/query-keys.ts | 4 + frontend/src/shared/api/types.ts | 81 +++++ frontend/src/shared/lib/locales/en.ts | 55 +++ frontend/src/shared/lib/locales/ru.ts | 55 +++ 16 files changed, 971 insertions(+) create mode 100644 backend/src/TeleWave.Api/Endpoints/DiagnosticsEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IDiagnosticsProbe.cs create mode 100644 backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQuery.cs create mode 100644 backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQueryHandler.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/DiagnosticsProbe.cs create mode 100644 frontend/src/features/admin/diagnostics/DiagnosticsPanel.tsx create mode 100644 frontend/src/features/admin/diagnostics/api.ts create mode 100644 frontend/src/routes/admin/diagnostics.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/DiagnosticsEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/DiagnosticsEndpoints.cs new file mode 100644 index 0000000..cff5bf2 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/DiagnosticsEndpoints.cs @@ -0,0 +1,162 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using LiteCqrs; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Options; +using TeleWave.Application.Diagnostics; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// +/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет +/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут +/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен. +/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос. +/// +public static class DiagnosticsEndpoints +{ + public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/admin/diagnostics", Get) + .WithTags("Admin.Diagnostics") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)) + .Produces(); + + return app; + } + + private static async Task Get( + HttpContext http, + IOptions forwardedOptions, + IHostEnvironment env, + ISender sender, + CancellationToken cancellationToken + ) + { + var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken); + var host = BuildHost(env); + var forwarding = BuildForwarding(http, forwardedOptions.Value); + + return Results.Ok(new DiagnosticsResponse(host, forwarding, system)); + } + + private static HostDiagnosticsDto BuildHost(IHostEnvironment env) + { + var assembly = Assembly.GetEntryAssembly(); + var version = + assembly + ?.GetCustomAttribute() + ?.InformationalVersion + ?? assembly?.GetName().Version?.ToString(); + + return new HostDiagnosticsDto( + version, + env.EnvironmentName, + RuntimeInformation.FrameworkDescription, + RuntimeInformation.OSDescription, + DateTimeOffset.UtcNow + ); + } + + private static ForwardingDiagnosticsDto BuildForwarding( + HttpContext http, + ForwardedHeadersOptions options + ) + { + var request = http.Request; + var scheme = request.Scheme; + var host = request.Host.Value ?? string.Empty; + var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase); + var remoteIp = http.Connection.RemoteIpAddress?.ToString(); + + var forwardedProto = Header(request, "X-Forwarded-Proto"); + // X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и + // применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное». + var originalProto = Header(request, "X-Original-Proto"); + var originalRemoteIp = Header(request, "X-Original-For"); + var applied = originalProto is not null || originalRemoteIp is not null; + + // Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой. + var sentProto = originalProto ?? forwardedProto; + string verdict; + string? suggestedTrustedIp = null; + if (isHttps) + verdict = "ok"; + else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase)) + { + // Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных. + verdict = "proxyNotTrusted"; + suggestedTrustedIp = remoteIp; + } + else + verdict = "protoHeaderMissing"; + + return new ForwardingDiagnosticsDto( + scheme, + host, + $"{scheme}://{host}", + isHttps, + remoteIp, + forwardedProto, + Header(request, "X-Forwarded-For"), + Header(request, "X-Forwarded-Host"), + Header(request, "X-Real-IP"), + originalProto, + originalRemoteIp, + applied, + options.ForwardLimit, + options.ForwardedHeaders.ToString(), + [.. options.KnownProxies.Select(ip => ip.ToString())], + [.. options.KnownIPNetworks.Select(net => net.ToString())], + verdict, + suggestedTrustedIp + ); + } + + private static string? Header(HttpRequest request, string name) => + request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value) + ? value.ToString() + : null; +} + +/// Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры. +public sealed record DiagnosticsResponse( + HostDiagnosticsDto Host, + ForwardingDiagnosticsDto Forwarding, + SystemDiagnosticsDto System +); + +public sealed record HostDiagnosticsDto( + string? AppVersion, + string Environment, + string Framework, + string Os, + DateTimeOffset ServerTimeUtc +); + +/// +/// Картина X-Forwarded-*. Verdict: ok — схема https; proxyNotTrusted — прокси +/// прислал https, но Applied=false, и SuggestedTrustedIp нужно внести в KnownProxies; +/// protoHeaderMissing — прокси вообще не шлёт X-Forwarded-Proto. +/// +public sealed record ForwardingDiagnosticsDto( + string Scheme, + string Host, + string Origin, + bool IsHttps, + string? RemoteIp, + string? XForwardedProto, + string? XForwardedFor, + string? XForwardedHost, + string? XRealIp, + string? OriginalProto, + string? OriginalRemoteIp, + bool Applied, + int? ForwardLimit, + string ForwardedHeaders, + IReadOnlyList KnownProxies, + IReadOnlyList KnownNetworks, + string Verdict, + string? SuggestedTrustedIp +); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 8a61b26..84c926c 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -150,6 +150,7 @@ app.MapStorageEndpoints(); app.MapSettingsEndpoints(); app.MapMetadataEndpoints(); app.MapImageEndpoints(); +app.MapDiagnosticsEndpoints(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IDiagnosticsProbe.cs b/backend/src/TeleWave.Application/Common/Interfaces/IDiagnosticsProbe.cs new file mode 100644 index 0000000..4c78e24 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IDiagnosticsProbe.cs @@ -0,0 +1,26 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Живые проверки инфраструктуры для админской диагностики: свободное место на томе хранилища и +/// доступность внешних инструментов (ffmpeg/ffprobe). Отдельный порт — чтобы Application не ходил +/// в файловую систему и процессы напрямую: это забота Infrastructure. +/// +public interface IDiagnosticsProbe +{ + Task ProbeStorageAsync(CancellationToken cancellationToken); + Task ProbeFfmpegAsync(CancellationToken cancellationToken); + Task ProbeFfprobeAsync(CancellationToken cancellationToken); +} + +/// Состояние тома хранилища. Нули в размерах — ФС не отдала метрику (экзотический маунт). +public sealed record StorageProbe( + string RootPath, + bool Exists, + bool Writable, + long TotalBytes, + long FreeBytes, + long MinFreeBytes +); + +/// Доступность внешнего инструмента и его версия (первая строка вывода -version). +public sealed record ToolProbe(bool Available, string? Version); diff --git a/backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQuery.cs b/backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQuery.cs new file mode 100644 index 0000000..c248a06 --- /dev/null +++ b/backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQuery.cs @@ -0,0 +1,29 @@ +using LiteCqrs; + +namespace TeleWave.Application.Diagnostics; + +/// Живое состояние инфраструктуры для админской панели диагностики: БД, хранилище, ffmpeg. +public sealed record GetSystemDiagnosticsQuery : IQuery; + +public sealed record SystemDiagnosticsDto( + DatabaseDiagnosticsDto Database, + StorageDiagnosticsDto Storage, + ToolDiagnosticsDto Ffmpeg, + ToolDiagnosticsDto Ffprobe +); + +/// Доступность БД и время лёгкого запроса; при ошибке — её текст без latency. +public sealed record DatabaseDiagnosticsDto(bool Reachable, long? LatencyMs, string? Error); + +/// LowSpace — свободного меньше порога MinFreeBytes (место известно и оно мало). +public sealed record StorageDiagnosticsDto( + string RootPath, + bool Exists, + bool Writable, + long TotalBytes, + long FreeBytes, + long MinFreeBytes, + bool LowSpace +); + +public sealed record ToolDiagnosticsDto(bool Available, string? Version); diff --git a/backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQueryHandler.cs b/backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQueryHandler.cs new file mode 100644 index 0000000..a6928e0 --- /dev/null +++ b/backend/src/TeleWave.Application/Diagnostics/GetSystemDiagnosticsQueryHandler.cs @@ -0,0 +1,64 @@ +using System.Data.Common; +using System.Diagnostics; +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Diagnostics; + +public sealed class GetSystemDiagnosticsQueryHandler( + IAppDbContext dbContext, + IDiagnosticsProbe probe +) : IQueryHandler +{ + public async Task Handle( + GetSystemDiagnosticsQuery query, + CancellationToken cancellationToken + ) + { + var database = await CheckDatabaseAsync(cancellationToken); + var storageProbe = await probe.ProbeStorageAsync(cancellationToken); + var ffmpeg = await probe.ProbeFfmpegAsync(cancellationToken); + var ffprobe = await probe.ProbeFfprobeAsync(cancellationToken); + + var storage = new StorageDiagnosticsDto( + storageProbe.RootPath, + storageProbe.Exists, + storageProbe.Writable, + storageProbe.TotalBytes, + storageProbe.FreeBytes, + storageProbe.MinFreeBytes, + LowSpace: storageProbe.FreeBytes > 0 + && storageProbe.FreeBytes < storageProbe.MinFreeBytes + ); + + return new SystemDiagnosticsDto( + database, + storage, + new ToolDiagnosticsDto(ffmpeg.Available, ffmpeg.Version), + new ToolDiagnosticsDto(ffprobe.Available, ffprobe.Version) + ); + } + + /// + /// Лёгкая живая проверка БД: тривиальный запрос под секундомер. Не полагаемся на стартовый + /// health-check — админ хочет видеть состояние прямо сейчас. Ловим + /// (база провайдера Npgsql-исключений) — так Application не тянет ссылку на Npgsql. + /// + private async Task CheckDatabaseAsync( + CancellationToken cancellationToken + ) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await dbContext.AppSettings.AnyAsync(cancellationToken); + stopwatch.Stop(); + return new DatabaseDiagnosticsDto(true, stopwatch.ElapsedMilliseconds, null); + } + catch (DbException ex) + { + return new DatabaseDiagnosticsDto(false, null, ex.Message); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 01beee2..61d8da1 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -169,6 +169,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/TeleWave.Infrastructure/Media/DiagnosticsProbe.cs b/backend/src/TeleWave.Infrastructure/Media/DiagnosticsProbe.cs new file mode 100644 index 0000000..66f150c --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/DiagnosticsProbe.cs @@ -0,0 +1,111 @@ +using System.ComponentModel; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Инфраструктурные пробы для админской диагностики: том хранилища (место + право записи) и внешние +/// инструменты (ffmpeg/ffprobe). Проба записи ловит частую ошибку развёртывания — bind-mount с чужими +/// правами: без прав на запись нарезка молча не идёт, а тут это видно сразу. +/// +public sealed class DiagnosticsProbe( + IOptions storageOptions, + IOptions mediaOptions +) : IDiagnosticsProbe +{ + /// -version отрабатывает мгновенно; таймаут страхует от подвисшего/подменённого бинаря. + private static readonly TimeSpan ToolTimeout = TimeSpan.FromSeconds(10); + + private readonly StorageOptions _storage = storageOptions.Value; + private readonly MediaOptions _media = mediaOptions.Value; + + public Task ProbeStorageAsync(CancellationToken cancellationToken) + { + var root = Path.GetFullPath(_storage.RootPath); + var exists = Directory.Exists(root); + var (total, free) = Volume(root); + var writable = exists && IsWritable(root); + return Task.FromResult( + new StorageProbe( + _storage.RootPath, + exists, + writable, + total, + free, + _storage.MinFreeSpaceBytes + ) + ); + } + + public Task ProbeFfmpegAsync(CancellationToken cancellationToken) => + ProbeToolAsync(_media.FfmpegPath, cancellationToken); + + public Task ProbeFfprobeAsync(CancellationToken cancellationToken) => + ProbeToolAsync(_media.FfprobePath, cancellationToken); + + /// Запускает инструмент с -version: успех + первая строка вывода как версия. + private static async Task ProbeToolAsync( + string path, + CancellationToken cancellationToken + ) + { + try + { + var result = await ProcessRunner.RunAsync( + path, + ["-version"], + lowPriority: false, + ToolTimeout, + cancellationToken + ); + if (result.ExitCode != 0) + return new ToolProbe(false, null); + + var firstLine = result + .StdOut.Split( + '\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + .FirstOrDefault(); + return new ToolProbe(true, firstLine); + } + catch (Exception ex) when (ex is Win32Exception or TimeoutException) + { + // Бинаря нет по указанному пути/в PATH (Win32Exception) или он завис (TimeoutException) + // — считаем недоступным. OperationCanceledException не глушим: отмена запроса должна всплыть. + return new ToolProbe(false, null); + } + } + + /// Размер тома хранилища; нули — файловая система не отдала метрику (как в инспекторе). + private static (long Total, long Free) Volume(string root) + { + try + { + var drive = new DriveInfo(root); + return (drive.TotalSize, drive.AvailableFreeSpace); + } + catch (Exception ex) + when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + return (0, 0); + } + } + + /// Проба записи: создаём и тут же удаляем скрытый файл в корне хранилища. + private static bool IsWritable(string root) + { + var probe = Path.Combine(root, $".tw-diag-{Guid.NewGuid():N}"); + try + { + using (File.Create(probe)) { } + File.Delete(probe); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/frontend/src/features/admin/diagnostics/DiagnosticsPanel.tsx b/frontend/src/features/admin/diagnostics/DiagnosticsPanel.tsx new file mode 100644 index 0000000..0cfe706 --- /dev/null +++ b/frontend/src/features/admin/diagnostics/DiagnosticsPanel.tsx @@ -0,0 +1,343 @@ +import { useQuery } from '@tanstack/react-query' +import { AlertTriangle, CheckCircle2, Copy, RefreshCw, XCircle } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import type { + ForwardingDiagnosticsDto, + HostDiagnosticsDto, + SystemDiagnosticsDto, +} from '@/shared/api/types' +import { qk } from '@/shared/api/query-keys' +import { cn } from '@/shared/lib/cn' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { Skeleton } from '@/shared/ui/skeleton' +import { getDiagnostics } from './api' + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + +/** Байты в человеческий размер — локальная копия хелпера хранилища, чтобы не связывать фичи. */ +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1) + const value = bytes / 1024 ** power + return `${value.toFixed(power >= 3 && value < 100 ? 1 : 0)} ${UNITS[power]}` +} + +const DASH = '—' + +/** + * Диагностика развёртывания. Главный блок — вердикт по прокси-форвардингу: он прямо отвечает на + * вопрос «почему ссылки уходят по http» и даёт готовую строку для .env. Ниже — живое состояние + * инфраструктуры (БД, том хранилища, ffmpeg) и сведения о процессе. + */ +export function DiagnosticsPanel() { + const { t } = useTranslation() + + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: qk.diagnostics.all, + queryFn: getDiagnostics, + }) + + if (isLoading || !data) return + + const serverTime = new Date(data.host.serverTimeUtc).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + + return ( +
+
+ + {t('admin.diagnostics.serverTime', { time: serverTime })} + + +
+ + + + + +
+ ) +} + +/** Вердикт-баннер: зелёный при https, иначе предупреждение с готовой строкой для .env. */ +function Verdict({ forwarding }: Readonly<{ forwarding: ForwardingDiagnosticsDto }>) { + const { t } = useTranslation() + + if (forwarding.verdict === 'ok') { + return ( +
+ + {t('admin.diagnostics.verdict.ok', { origin: forwarding.origin })} +
+ ) + } + + const ip = forwarding.suggestedTrustedIp + const message = + forwarding.verdict === 'proxyNotTrusted' + ? t('admin.diagnostics.verdict.proxyNotTrusted', { ip: ip ?? DASH }) + : t('admin.diagnostics.verdict.protoHeaderMissing') + + return ( +
+
+ + {message} +
+ {forwarding.verdict === 'proxyNotTrusted' && ip && ( +
+ + {t('admin.diagnostics.verdict.applyHint')} + + +
+ )} +
+ ) +} + +function CopyableEnv({ value }: Readonly<{ value: string }>) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const copy = async () => { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // Буфер обмена недоступен (нет https/разрешения) — строку всегда можно выделить руками. + } + } + + return ( +
+ {value} + +
+ ) +} + +function ForwardingCard({ forwarding }: Readonly<{ forwarding: ForwardingDiagnosticsDto }>) { + const { t } = useTranslation() + const f = forwarding + + return ( + + + {t('admin.diagnostics.forwarding.title')} + + +
+ {f.origin} + +
+ +
+ + + + + + + + + + + + +
+
+
+ ) +} + +function SystemCards({ system }: Readonly<{ system: SystemDiagnosticsDto }>) { + const { t } = useTranslation() + const { database: db, storage, ffmpeg, ffprobe } = system + + return ( +
+ + + {t('admin.diagnostics.database.title')} + + + + {db.reachable && db.latencyMs != null && ( + + {t('admin.diagnostics.database.latency', { ms: db.latencyMs })} + + )} + {db.error && {db.error}} + + + + + + {t('admin.diagnostics.storage.title')} + + +
+ + + {storage.lowSpace && ( + {t('admin.diagnostics.storage.lowSpace')} + )} +
+
+ + 0 + ? `${formatBytes(storage.freeBytes)} / ${formatBytes(storage.totalBytes)}` + : DASH + } + mono + /> +
+
+
+ + + + ffmpeg + + + + {ffmpeg.version && ( + + {ffmpeg.version} + + )} + + + + + + ffprobe + + + + {ffprobe.version && ( + + {ffprobe.version} + + )} + + +
+ ) +} + +function HostCard({ host }: Readonly<{ host: HostDiagnosticsDto }>) { + const { t } = useTranslation() + + return ( + + + {t('admin.diagnostics.host.title')} + + +
+ + + + +
+
+
+ ) +} + +function StatusBadge({ + ok, + okText, + badText, +}: Readonly<{ ok: boolean; okText: string; badText: string }>) { + return ( + + {ok ? : } + {ok ? okText : badText} + + ) +} + +function Row({ + label, + value, + mono, +}: Readonly<{ label: string; value: string | null; mono?: boolean }>) { + return ( +
+
{label}
+
{value ?? DASH}
+
+ ) +} + +function DiagnosticsSkeleton(): ReactNode { + return ( +
+ + + +
+ ) +} diff --git a/frontend/src/features/admin/diagnostics/api.ts b/frontend/src/features/admin/diagnostics/api.ts new file mode 100644 index 0000000..03a7a8b --- /dev/null +++ b/frontend/src/features/admin/diagnostics/api.ts @@ -0,0 +1,7 @@ +import { apiRequest } from '@/shared/api/client' +import type { DiagnosticsDto } from '@/shared/api/types' + +/** Снимок диагностики: форвардинг, БД, хранилище, ffmpeg. Пересчитывается на каждый запрос. */ +export function getDiagnostics() { + return apiRequest('/admin/diagnostics') +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index b309b94..ad9b61d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as AdminBumpersRouteImport } from './routes/admin/bumpers' import { Route as AdminChannelsRouteImport } from './routes/admin/channels' import { Route as AdminCollectionsRouteImport } from './routes/admin/collections' +import { Route as AdminDiagnosticsRouteImport } from './routes/admin/diagnostics' import { Route as AdminGalleryRouteImport } from './routes/admin/gallery' import { Route as AdminGenresRouteImport } from './routes/admin/genres' import { Route as AdminGroupsRouteImport } from './routes/admin/groups' @@ -91,6 +92,11 @@ const AdminCollectionsRoute = AdminCollectionsRouteImport.update({ path: '/collections', getParentRoute: () => AdminRoute, } as any) +const AdminDiagnosticsRoute = AdminDiagnosticsRouteImport.update({ + id: '/diagnostics', + path: '/diagnostics', + getParentRoute: () => AdminRoute, +} as any) const AdminGalleryRoute = AdminGalleryRouteImport.update({ id: '/gallery', path: '/gallery', @@ -208,6 +214,7 @@ export interface FileRoutesByFullPath { '/admin/bumpers': typeof AdminBumpersRoute '/admin/channels': typeof AdminChannelsRouteWithChildren '/admin/collections': typeof AdminCollectionsRouteWithChildren + '/admin/diagnostics': typeof AdminDiagnosticsRoute '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/groups': typeof AdminGroupsRouteWithChildren @@ -238,6 +245,7 @@ export interface FileRoutesByTo { '/register': typeof RegisterRoute '/settings': typeof SettingsRoute '/admin/bumpers': typeof AdminBumpersRoute + '/admin/diagnostics': typeof AdminDiagnosticsRoute '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/interstitials': typeof AdminInterstitialsRoute @@ -270,6 +278,7 @@ export interface FileRoutesById { '/admin/bumpers': typeof AdminBumpersRoute '/admin/channels': typeof AdminChannelsRouteWithChildren '/admin/collections': typeof AdminCollectionsRouteWithChildren + '/admin/diagnostics': typeof AdminDiagnosticsRoute '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/groups': typeof AdminGroupsRouteWithChildren @@ -305,6 +314,7 @@ export interface FileRouteTypes { | '/admin/bumpers' | '/admin/channels' | '/admin/collections' + | '/admin/diagnostics' | '/admin/gallery' | '/admin/genres' | '/admin/groups' @@ -335,6 +345,7 @@ export interface FileRouteTypes { | '/register' | '/settings' | '/admin/bumpers' + | '/admin/diagnostics' | '/admin/gallery' | '/admin/genres' | '/admin/interstitials' @@ -366,6 +377,7 @@ export interface FileRouteTypes { | '/admin/bumpers' | '/admin/channels' | '/admin/collections' + | '/admin/diagnostics' | '/admin/gallery' | '/admin/genres' | '/admin/groups' @@ -471,6 +483,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminCollectionsRouteImport parentRoute: typeof AdminRoute } + '/admin/diagnostics': { + id: '/admin/diagnostics' + path: '/diagnostics' + fullPath: '/admin/diagnostics' + preLoaderRoute: typeof AdminDiagnosticsRouteImport + parentRoute: typeof AdminRoute + } '/admin/gallery': { id: '/admin/gallery' path: '/gallery' @@ -680,6 +699,7 @@ interface AdminRouteChildren { AdminBumpersRoute: typeof AdminBumpersRoute AdminChannelsRoute: typeof AdminChannelsRouteWithChildren AdminCollectionsRoute: typeof AdminCollectionsRouteWithChildren + AdminDiagnosticsRoute: typeof AdminDiagnosticsRoute AdminGalleryRoute: typeof AdminGalleryRoute AdminGenresRoute: typeof AdminGenresRoute AdminGroupsRoute: typeof AdminGroupsRouteWithChildren @@ -700,6 +720,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminBumpersRoute: AdminBumpersRoute, AdminChannelsRoute: AdminChannelsRouteWithChildren, AdminCollectionsRoute: AdminCollectionsRouteWithChildren, + AdminDiagnosticsRoute: AdminDiagnosticsRoute, AdminGalleryRoute: AdminGalleryRoute, AdminGenresRoute: AdminGenresRoute, AdminGroupsRoute: AdminGroupsRouteWithChildren, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 977d195..2afe70c 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -127,6 +127,13 @@ function AdminLayout() { > {t('admin.settings.title')} + + {t('admin.diagnostics.title')} + diff --git a/frontend/src/routes/admin/diagnostics.tsx b/frontend/src/routes/admin/diagnostics.tsx new file mode 100644 index 0000000..64d4b43 --- /dev/null +++ b/frontend/src/routes/admin/diagnostics.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { DiagnosticsPanel } from '@/features/admin/diagnostics/DiagnosticsPanel' + +export const Route = createFileRoute('/admin/diagnostics')({ component: DiagnosticsPanel }) diff --git a/frontend/src/shared/api/query-keys.ts b/frontend/src/shared/api/query-keys.ts index cf7c1d1..fe8f319 100644 --- a/frontend/src/shared/api/query-keys.ts +++ b/frontend/src/shared/api/query-keys.ts @@ -113,6 +113,10 @@ export const qk = { all: ['admin', 'storage'] as const, }, + diagnostics: { + all: ['admin', 'diagnostics'] as const, + }, + metadata: { providers: ['admin', 'metadata', 'providers'] as const, }, diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index a8cc30b..a601357 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -503,6 +503,87 @@ export type StorageStatsDto = { computedAt: string } +// ── Диагностика ─────────────────────────────────────────────────────────── +/** Процесс и рантайм: версия сборки, окружение, .NET/ОС, серверное время (UTC). */ +export type HostDiagnosticsDto = { + appVersion: string | null + environment: string + framework: string + os: string + serverTimeUtc: string +} + +/** + * Вердикт по прокси-форвардингу: + * - `ok` — итоговая схема https, ссылки строятся правильно; + * - `proxyNotTrusted` — прокси прислал `X-Forwarded-Proto: https`, но непосредственный сосед не + * доверен, схема осталась http; в `suggestedTrustedIp` — что внести в `KnownProxies`; + * - `protoHeaderMissing` — прокси вообще не шлёт `X-Forwarded-Proto`. + */ +export type ForwardingVerdict = 'ok' | 'proxyNotTrusted' | 'protoHeaderMissing' + +/** Картина `X-Forwarded-*`: что пришло, что применилось и кому приложение доверяет. */ +export type ForwardingDiagnosticsDto = { + scheme: string + host: string + /** Итоговый origin — ровно то, из чего строятся абсолютные ссылки M3U/EPG. */ + origin: string + isHttps: boolean + /** Адрес непосредственного соседа (после обработки форвардинга). */ + remoteIp: string | null + xForwardedProto: string | null + xForwardedFor: string | null + xForwardedHost: string | null + xRealIp: string | null + /** Заголовки, которые middleware отложил при применении форвардинга (признак «применено»). */ + originalProto: string | null + originalRemoteIp: string | null + applied: boolean + forwardLimit: number | null + forwardedHeaders: string + knownProxies: string[] + knownNetworks: string[] + verdict: ForwardingVerdict + suggestedTrustedIp: string | null +} + +/** Доступность БД: живой лёгкий запрос под секундомер; при ошибке — её текст. */ +export type DatabaseDiagnosticsDto = { + reachable: boolean + latencyMs: number | null + error: string | null +} + +/** Том хранилища: наличие, право записи, место. `lowSpace` — свободного меньше порога. */ +export type StorageDiagnosticsDto = { + rootPath: string + exists: boolean + writable: boolean + totalBytes: number + freeBytes: number + minFreeBytes: number + lowSpace: boolean +} + +/** Внешний инструмент (ffmpeg/ffprobe): доступен ли и его версия. */ +export type ToolDiagnosticsDto = { + available: boolean + version: string | null +} + +export type SystemDiagnosticsDto = { + database: DatabaseDiagnosticsDto + storage: StorageDiagnosticsDto + ffmpeg: ToolDiagnosticsDto + ffprobe: ToolDiagnosticsDto +} + +export type DiagnosticsDto = { + host: HostDiagnosticsDto + forwarding: ForwardingDiagnosticsDto + system: SystemDiagnosticsDto +} + // ── Каналы ──────────────────────────────────────────────────────────────── type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff' export type BumperFont = 'Sans' | 'Serif' diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index f49265d..6a6d825 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -989,6 +989,61 @@ export const en = { Other: 'files outside the known directories — usually zero', }, }, + diagnostics: { + title: 'Diagnostics', + refresh: 'Refresh', + serverTime: 'server time {{time}}', + verdict: { + ok: 'Links are built over HTTPS ({{origin}}) — forwarding works.', + proxyNotTrusted: + 'The proxy sends X-Forwarded-Proto: https, but the immediate peer {{ip}} is not trusted — the scheme stayed HTTP, so M3U/EPG links go out over http.', + protoHeaderMissing: + 'The scheme stayed HTTP and no X-Forwarded-Proto arrives from the proxy. Configure the proxy to send this header.', + applyHint: 'Add to .env and restart the container:', + }, + copy: 'Copy', + copied: 'Copied', + forwarding: { + title: 'Proxy forwarding', + https: 'HTTPS', + http: 'HTTP', + remoteIp: 'Immediate peer', + applied: 'Forwarding applied', + appliedYes: 'yes — headers trusted', + appliedNo: 'no — headers discarded', + forwardLimit: 'Forward limit', + flags: 'Processed headers', + knownProxies: 'Known proxies', + knownNetworks: 'Known networks', + }, + database: { + title: 'Database', + reachable: 'Reachable', + unreachable: 'Unreachable', + latency: 'response {{ms}} ms', + }, + storage: { + title: 'Storage', + exists: 'Directory present', + missing: 'Directory missing', + writable: 'Writable', + readOnly: 'Read-only', + lowSpace: 'Low space', + root: 'Root', + free: 'Free / total', + }, + tools: { + available: 'Available', + missing: 'Not found', + }, + host: { + title: 'Process and runtime', + appVersion: 'Build version', + environment: 'Environment', + framework: 'Framework', + os: 'OS', + }, + }, telegram: { title: 'Telegram', enable: 'Bot enabled', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 7834ac8..0a3a9e5 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -984,6 +984,61 @@ export const ru = { Other: 'файлы мимо известных каталогов — обычно ноль', }, }, + diagnostics: { + title: 'Диагностика', + refresh: 'Обновить', + serverTime: 'серверное время {{time}}', + verdict: { + ok: 'Ссылки формируются по HTTPS ({{origin}}) — форвардинг работает.', + proxyNotTrusted: + 'Прокси присылает X-Forwarded-Proto: https, но непосредственный сосед {{ip}} не в доверенных — схема осталась HTTP, ссылки в M3U/EPG уходят по http.', + protoHeaderMissing: + 'Схема осталась HTTP, а X-Forwarded-Proto от прокси не приходит. Настройте прокси слать этот заголовок.', + applyHint: 'Добавьте в .env и перезапустите контейнер:', + }, + copy: 'Копировать', + copied: 'Скопировано', + forwarding: { + title: 'Прокси-форвардинг', + https: 'HTTPS', + http: 'HTTP', + remoteIp: 'Непосредственный сосед', + applied: 'Форвардинг применён', + appliedYes: 'да — заголовкам доверяем', + appliedNo: 'нет — заголовки отброшены', + forwardLimit: 'Лимит переходов', + flags: 'Обрабатываемые заголовки', + knownProxies: 'Доверенные прокси', + knownNetworks: 'Доверенные сети', + }, + database: { + title: 'База данных', + reachable: 'Доступна', + unreachable: 'Недоступна', + latency: 'отклик {{ms}} мс', + }, + storage: { + title: 'Хранилище', + exists: 'Каталог есть', + missing: 'Каталога нет', + writable: 'Запись доступна', + readOnly: 'Только чтение', + lowSpace: 'Мало места', + root: 'Корень', + free: 'Свободно / всего', + }, + tools: { + available: 'Доступен', + missing: 'Не найден', + }, + host: { + title: 'Процесс и рантайм', + appVersion: 'Версия сборки', + environment: 'Окружение', + framework: 'Платформа', + os: 'ОС', + }, + }, telegram: { title: 'Телеграм', enable: 'Бот включён',