Add diagnostics endpoints and types for improved system monitoring
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 38s
ci / tests (push) Successful in 1m21s
ci / sonar (push) Successful in 3m46s

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.
This commit is contained in:
Leonid Pershin
2026-08-02 13:49:48 +03:00
parent 88cfe3716b
commit bed6d4fcfc
16 changed files with 971 additions and 0 deletions
@@ -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;
/// <summary>
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
/// </summary>
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<DiagnosticsResponse>();
return app;
}
private static async Task<IResult> Get(
HttpContext http,
IOptions<ForwardedHeadersOptions> 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<AssemblyInformationalVersionAttribute>()
?.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;
}
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
public sealed record DiagnosticsResponse(
HostDiagnosticsDto Host,
ForwardingDiagnosticsDto Forwarding,
SystemDiagnosticsDto System
);
public sealed record HostDiagnosticsDto(
string? AppVersion,
string Environment,
string Framework,
string Os,
DateTimeOffset ServerTimeUtc
);
/// <summary>
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
/// </summary>
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<string> KnownProxies,
IReadOnlyList<string> KnownNetworks,
string Verdict,
string? SuggestedTrustedIp
);
+1
View File
@@ -150,6 +150,7 @@ app.MapStorageEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
app.MapDiagnosticsEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -0,0 +1,26 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Живые проверки инфраструктуры для админской диагностики: свободное место на томе хранилища и
/// доступность внешних инструментов (ffmpeg/ffprobe). Отдельный порт — чтобы Application не ходил
/// в файловую систему и процессы напрямую: это забота Infrastructure.
/// </summary>
public interface IDiagnosticsProbe
{
Task<StorageProbe> ProbeStorageAsync(CancellationToken cancellationToken);
Task<ToolProbe> ProbeFfmpegAsync(CancellationToken cancellationToken);
Task<ToolProbe> ProbeFfprobeAsync(CancellationToken cancellationToken);
}
/// <summary>Состояние тома хранилища. Нули в размерах — ФС не отдала метрику (экзотический маунт).</summary>
public sealed record StorageProbe(
string RootPath,
bool Exists,
bool Writable,
long TotalBytes,
long FreeBytes,
long MinFreeBytes
);
/// <summary>Доступность внешнего инструмента и его версия (первая строка вывода <c>-version</c>).</summary>
public sealed record ToolProbe(bool Available, string? Version);
@@ -0,0 +1,29 @@
using LiteCqrs;
namespace TeleWave.Application.Diagnostics;
/// <summary>Живое состояние инфраструктуры для админской панели диагностики: БД, хранилище, ffmpeg.</summary>
public sealed record GetSystemDiagnosticsQuery : IQuery<SystemDiagnosticsDto>;
public sealed record SystemDiagnosticsDto(
DatabaseDiagnosticsDto Database,
StorageDiagnosticsDto Storage,
ToolDiagnosticsDto Ffmpeg,
ToolDiagnosticsDto Ffprobe
);
/// <summary>Доступность БД и время лёгкого запроса; при ошибке — её текст без latency.</summary>
public sealed record DatabaseDiagnosticsDto(bool Reachable, long? LatencyMs, string? Error);
/// <summary><c>LowSpace</c> — свободного меньше порога <c>MinFreeBytes</c> (место известно и оно мало).</summary>
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);
@@ -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<GetSystemDiagnosticsQuery, SystemDiagnosticsDto>
{
public async Task<SystemDiagnosticsDto> 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)
);
}
/// <summary>
/// Лёгкая живая проверка БД: тривиальный запрос под секундомер. Не полагаемся на стартовый
/// health-check — админ хочет видеть состояние прямо сейчас. Ловим <see cref="DbException"/>
/// (база провайдера Npgsql-исключений) — так Application не тянет ссылку на Npgsql.
/// </summary>
private async Task<DatabaseDiagnosticsDto> 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);
}
}
}
@@ -169,6 +169,7 @@ public static class DependencyInjection
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IDiagnosticsProbe, DiagnosticsProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IMediaProcessingLimits, MediaProcessingLimits>();
@@ -0,0 +1,111 @@
using System.ComponentModel;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Инфраструктурные пробы для админской диагностики: том хранилища (место + право записи) и внешние
/// инструменты (ffmpeg/ffprobe). Проба записи ловит частую ошибку развёртывания — bind-mount с чужими
/// правами: без прав на запись нарезка молча не идёт, а тут это видно сразу.
/// </summary>
public sealed class DiagnosticsProbe(
IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions
) : IDiagnosticsProbe
{
/// <summary>-version отрабатывает мгновенно; таймаут страхует от подвисшего/подменённого бинаря.</summary>
private static readonly TimeSpan ToolTimeout = TimeSpan.FromSeconds(10);
private readonly StorageOptions _storage = storageOptions.Value;
private readonly MediaOptions _media = mediaOptions.Value;
public Task<StorageProbe> 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<ToolProbe> ProbeFfmpegAsync(CancellationToken cancellationToken) =>
ProbeToolAsync(_media.FfmpegPath, cancellationToken);
public Task<ToolProbe> ProbeFfprobeAsync(CancellationToken cancellationToken) =>
ProbeToolAsync(_media.FfprobePath, cancellationToken);
/// <summary>Запускает инструмент с <c>-version</c>: успех + первая строка вывода как версия.</summary>
private static async Task<ToolProbe> 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);
}
}
/// <summary>Размер тома хранилища; нули — файловая система не отдала метрику (как в инспекторе).</summary>
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);
}
}
/// <summary>Проба записи: создаём и тут же удаляем скрытый файл в корне хранилища.</summary>
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;
}
}
}
@@ -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 <DiagnosticsSkeleton />
const serverTime = new Date(data.host.serverTimeUtc).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end gap-3">
<span className="text-xs text-muted-foreground">
{t('admin.diagnostics.serverTime', { time: serverTime })}
</span>
<Button size="sm" variant="outline" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
{t('admin.diagnostics.refresh')}
</Button>
</div>
<Verdict forwarding={data.forwarding} />
<ForwardingCard forwarding={data.forwarding} />
<SystemCards system={data.system} />
<HostCard host={data.host} />
</div>
)
}
/** Вердикт-баннер: зелёный при https, иначе предупреждение с готовой строкой для .env. */
function Verdict({ forwarding }: Readonly<{ forwarding: ForwardingDiagnosticsDto }>) {
const { t } = useTranslation()
if (forwarding.verdict === 'ok') {
return (
<div className="flex items-center gap-2 rounded-md border border-emerald-600/40 bg-emerald-600/10 px-3 py-2 text-sm">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
<span>{t('admin.diagnostics.verdict.ok', { origin: forwarding.origin })}</span>
</div>
)
}
const ip = forwarding.suggestedTrustedIp
const message =
forwarding.verdict === 'proxyNotTrusted'
? t('admin.diagnostics.verdict.proxyNotTrusted', { ip: ip ?? DASH })
: t('admin.diagnostics.verdict.protoHeaderMissing')
return (
<div className="flex flex-col gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
<span>{message}</span>
</div>
{forwarding.verdict === 'proxyNotTrusted' && ip && (
<div className="flex flex-col gap-1 pl-6">
<span className="text-xs text-muted-foreground">
{t('admin.diagnostics.verdict.applyHint')}
</span>
<CopyableEnv value={`ForwardedHeaders__KnownProxies=${ip}`} />
</div>
)}
</div>
)
}
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 (
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-2 py-1 font-mono text-xs">{value}</code>
<Button size="sm" variant="ghost" onClick={copy}>
<Copy className="h-3.5 w-3.5" />
{copied ? t('admin.diagnostics.copied') : t('admin.diagnostics.copy')}
</Button>
</div>
)
}
function ForwardingCard({ forwarding }: Readonly<{ forwarding: ForwardingDiagnosticsDto }>) {
const { t } = useTranslation()
const f = forwarding
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.diagnostics.forwarding.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-mono text-lg break-all">{f.origin}</span>
<StatusBadge
ok={f.isHttps}
okText={t('admin.diagnostics.forwarding.https')}
badText={t('admin.diagnostics.forwarding.http')}
/>
</div>
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<Row label={t('admin.diagnostics.forwarding.remoteIp')} value={f.remoteIp} mono />
<Row
label={t('admin.diagnostics.forwarding.applied')}
value={
f.applied
? t('admin.diagnostics.forwarding.appliedYes')
: t('admin.diagnostics.forwarding.appliedNo')
}
/>
<Row label="X-Forwarded-Proto" value={f.xForwardedProto} mono />
<Row label="X-Forwarded-For" value={f.xForwardedFor} mono />
<Row label="X-Forwarded-Host" value={f.xForwardedHost} mono />
<Row label="X-Real-IP" value={f.xRealIp} mono />
<Row label="X-Original-Proto" value={f.originalProto} mono />
<Row label="X-Original-For" value={f.originalRemoteIp} mono />
<Row
label={t('admin.diagnostics.forwarding.forwardLimit')}
value={f.forwardLimit?.toString() ?? DASH}
mono
/>
<Row label={t('admin.diagnostics.forwarding.flags')} value={f.forwardedHeaders} mono />
<Row
label={t('admin.diagnostics.forwarding.knownProxies')}
value={f.knownProxies.length ? f.knownProxies.join(', ') : DASH}
mono
/>
<Row
label={t('admin.diagnostics.forwarding.knownNetworks')}
value={f.knownNetworks.length ? f.knownNetworks.join(', ') : DASH}
mono
/>
</dl>
</CardContent>
</Card>
)
}
function SystemCards({ system }: Readonly<{ system: SystemDiagnosticsDto }>) {
const { t } = useTranslation()
const { database: db, storage, ffmpeg, ffprobe } = system
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.diagnostics.database.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<StatusBadge
ok={db.reachable}
okText={t('admin.diagnostics.database.reachable')}
badText={t('admin.diagnostics.database.unreachable')}
/>
{db.reachable && db.latencyMs != null && (
<span className="text-sm text-muted-foreground">
{t('admin.diagnostics.database.latency', { ms: db.latencyMs })}
</span>
)}
{db.error && <span className="font-mono text-xs text-destructive">{db.error}</span>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.diagnostics.storage.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<StatusBadge
ok={storage.exists}
okText={t('admin.diagnostics.storage.exists')}
badText={t('admin.diagnostics.storage.missing')}
/>
<StatusBadge
ok={storage.writable}
okText={t('admin.diagnostics.storage.writable')}
badText={t('admin.diagnostics.storage.readOnly')}
/>
{storage.lowSpace && (
<Badge variant="destructive">{t('admin.diagnostics.storage.lowSpace')}</Badge>
)}
</div>
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<Row label={t('admin.diagnostics.storage.root')} value={storage.rootPath} mono />
<Row
label={t('admin.diagnostics.storage.free')}
value={
storage.totalBytes > 0
? `${formatBytes(storage.freeBytes)} / ${formatBytes(storage.totalBytes)}`
: DASH
}
mono
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">ffmpeg</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<StatusBadge
ok={ffmpeg.available}
okText={t('admin.diagnostics.tools.available')}
badText={t('admin.diagnostics.tools.missing')}
/>
{ffmpeg.version && (
<span className="font-mono text-xs text-muted-foreground break-all">
{ffmpeg.version}
</span>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">ffprobe</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<StatusBadge
ok={ffprobe.available}
okText={t('admin.diagnostics.tools.available')}
badText={t('admin.diagnostics.tools.missing')}
/>
{ffprobe.version && (
<span className="font-mono text-xs text-muted-foreground break-all">
{ffprobe.version}
</span>
)}
</CardContent>
</Card>
</div>
)
}
function HostCard({ host }: Readonly<{ host: HostDiagnosticsDto }>) {
const { t } = useTranslation()
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.diagnostics.host.title')}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<Row label={t('admin.diagnostics.host.appVersion')} value={host.appVersion} mono />
<Row label={t('admin.diagnostics.host.environment')} value={host.environment} />
<Row label={t('admin.diagnostics.host.framework')} value={host.framework} mono />
<Row label={t('admin.diagnostics.host.os')} value={host.os} mono />
</dl>
</CardContent>
</Card>
)
}
function StatusBadge({
ok,
okText,
badText,
}: Readonly<{ ok: boolean; okText: string; badText: string }>) {
return (
<Badge variant={ok ? 'default' : 'destructive'} className="gap-1">
{ok ? <CheckCircle2 className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
{ok ? okText : badText}
</Badge>
)
}
function Row({
label,
value,
mono,
}: Readonly<{ label: string; value: string | null; mono?: boolean }>) {
return (
<div className="flex flex-col">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className={cn('text-sm break-all', mono && 'font-mono')}>{value ?? DASH}</dd>
</div>
)
}
function DiagnosticsSkeleton(): ReactNode {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-40 w-full" />
</div>
)
}
@@ -0,0 +1,7 @@
import { apiRequest } from '@/shared/api/client'
import type { DiagnosticsDto } from '@/shared/api/types'
/** Снимок диагностики: форвардинг, БД, хранилище, ffmpeg. Пересчитывается на каждый запрос. */
export function getDiagnostics() {
return apiRequest<DiagnosticsDto>('/admin/diagnostics')
}
+21
View File
@@ -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,
+7
View File
@@ -127,6 +127,13 @@ function AdminLayout() {
>
{t('admin.settings.title')}
</Link>
<Link
to="/admin/diagnostics"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.diagnostics.title')}
</Link>
</nav>
<Outlet />
</div>
@@ -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 })
+4
View File
@@ -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,
},
+81
View File
@@ -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'
+55
View File
@@ -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',
+55
View File
@@ -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: 'Бот включён',