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: 'Бот включён',