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