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