feat(web): дашборд инстанса в настройках с живыми метриками

Вкладка «Инстанс» сверху показывает нагрузку сервера, ниже — диск и базу, внизу
healthcheck с пингом (AGENT.md 7.19).

- `Gauge` — полукруглый индикатор: заполняется по значению, меняет цвет на
  порогах 70 % и 90 %, доступен как `role="meter"`;
- процессор и память обновляются раз в секунду (`useInstanceMetrics`), для
  простоя вкладки опрос останавливается;
- два блока диска: «занято / всего» и вес базы (файл + WAL); видимых подписей
  нет — они раскрываются в тултипе при наведении на значение;
- healthcheck: состояние каждой проверки, общий статус, техническая деталь и
  живой пинг — время ответа инстанса на запрос дашборда в миллисекундах;
- переводы ru/en, тесты на индикаторы, формат «занято / всего», тултипы,
  healthcheck и отсутствие раздела у обычного пользователя.
This commit is contained in:
2026-09-20 21:26:25 +03:00
parent 4c3736b5fb
commit e1107600ec
9 changed files with 715 additions and 2 deletions
@@ -0,0 +1,107 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/primitives';
import { Gauge } from '@/components/ui/Gauge';
import { formatBytes } from '@/lib/format';
import type { InstanceMetricsState } from '@/lib/useInstanceMetrics';
interface InstanceDashboardProps {
state: InstanceMetricsState;
}
/**
* Дашборд инстанса: полукруглые индикаторы процессора и памяти, ниже — два
* блока про диск (занято / всего) и вес базы данных. Значения обновляются раз
* в секунду, подписи у блоков диска показываются только при наведении.
*/
export function InstanceDashboard({ state }: InstanceDashboardProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const metrics = state.metrics;
return (
<Card data-testid="instance-dashboard">
<header className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-base font-semibold">{t('settings.instance.dashboard.title')}</h3>
<span className="text-xs text-fg-muted">
{t('settings.instance.dashboard.refreshHint')}
</span>
</header>
<div className="mt-4 flex flex-wrap items-center justify-around gap-6">
<Gauge
testId="gauge-cpu"
value={metrics?.cpu.percent ?? 0}
label={t('settings.instance.dashboard.cpu')}
display={formatPercent(metrics?.cpu.percent ?? 0, locale)}
caption={t('settings.instance.dashboard.cpuCaption', {
count: Math.round(metrics?.cpu.cores ?? 0),
})}
/>
<Gauge
testId="gauge-memory"
value={metrics?.memory.percent ?? 0}
label={t('settings.instance.dashboard.memory')}
display={formatPercent(metrics?.memory.percent ?? 0, locale)}
caption={`${formatBytes(metrics?.memory.used_bytes ?? 0, locale)} / ${formatBytes(
metrics?.memory.total_bytes ?? 0,
locale,
)}`}
/>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
{/* Подписи не показываем: они раскрываются при наведении на значение. */}
<DiskStat
testId="disk-usage"
value={`${formatBytes(metrics?.disk.used_bytes ?? 0, locale)} / ${formatBytes(
metrics?.disk.total_bytes ?? 0,
locale,
)}`}
title={t('settings.instance.dashboard.diskHint', {
free: formatBytes(metrics?.disk.free_bytes ?? 0, locale),
})}
/>
<DiskStat
testId="database-size"
value={formatBytes(
(metrics?.database.bytes ?? 0) + (metrics?.database.wal_bytes ?? 0),
locale,
)}
title={t('settings.instance.dashboard.databaseHint', {
wal: formatBytes(metrics?.database.wal_bytes ?? 0, locale),
})}
/>
</div>
</Card>
);
}
interface DiskStatProps {
value: string;
title: string;
testId: string;
}
/** DiskStat — блок с одним значением: подпись доступна только в тултипе. */
function DiskStat({ value, title, testId }: DiskStatProps) {
return (
<div
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-4 py-3 text-center"
data-testid={testId}
>
<span
className="cursor-help text-lg font-semibold tabular-nums"
title={title}
data-testid={`${testId}-value`}
>
{value}
</span>
</div>
);
}
function formatPercent(value: number, locale: string): string {
const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
return `${formatted} %`;
}
@@ -0,0 +1,126 @@
import { useTranslation } from 'react-i18next';
import type { InstanceCheck } from '@/api/metrics';
import { Card } from '@/components/ui/primitives';
import type { InstanceMetricsState } from '@/lib/useInstanceMetrics';
interface InstanceHealthProps {
state: InstanceMetricsState;
}
/** Порядок проверок в списке: сначала критичные для работы инстанса. */
const CHECK_ORDER = ['app', 'database', 'disk', 'storage', 'voice', 'gateway', 'metrics'];
const STATUS_CLASS: Record<InstanceCheck['status'], string> = {
ok: 'bg-success',
warn: 'bg-warning',
failed: 'bg-danger',
off: 'bg-fg-muted',
};
/**
* Healthcheck инстанса и живой пинг: состояние компонентов (приложение, база,
* диск, запись, SFU, Gateway) и время ответа инстанса на запрос дашборда.
*/
export function InstanceHealth({ state }: InstanceHealthProps) {
const { t } = useTranslation();
const metrics = state.metrics;
const checks = sortChecks(metrics?.checks ?? []);
return (
<Card data-testid="instance-health">
<header className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold">{t('settings.instance.health.title')}</h3>
<div className="flex items-center gap-4">
<span
className="flex items-center gap-2 text-sm"
data-testid="instance-health-summary"
data-health={metrics?.health ?? 'unknown'}
>
<span
aria-hidden="true"
className={`h-2 w-2 rounded-full ${statusDotClass(metrics?.health)}`}
/>
{metrics === undefined
? t('settings.instance.health.collecting')
: t(`settings.instance.health.summary.${metrics.health}`)}
</span>
<span
className="flex items-center gap-1 text-sm"
data-testid="instance-ping"
data-ping={state.pingMs ?? ''}
title={t('settings.instance.health.pingHint')}
>
<span className="text-fg-muted">{t('settings.instance.health.ping')}</span>
<span className="font-medium tabular-nums">
{state.pingMs === null
? t('settings.instance.health.pingUnavailable')
: t('settings.instance.health.pingValue', { ms: state.pingMs })}
</span>
</span>
</div>
</header>
{state.failed && metrics === undefined ? (
<p className="mt-3 text-sm text-danger">{t('settings.instance.health.unavailable')}</p>
) : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-checks">
{checks.map((check) => (
<li
key={check.name}
className="flex items-center gap-3 border-b border-border/40 py-2 last:border-b-0"
data-testid={`instance-check-${check.name}`}
data-status={check.status}
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${STATUS_CLASS[check.status]}`}
/>
<span className="flex-1 truncate">{checkName(t, check.name)}</span>
{check.message === undefined || check.message === '' ? null : (
<span className="max-w-[45%] truncate text-xs text-fg-muted" title={check.message}>
{check.message}
</span>
)}
<span className="shrink-0 text-xs text-fg-muted">
{t(`settings.instance.health.status.${check.status}`)}
</span>
</li>
))}
</ul>
)}
</Card>
);
}
/** sortChecks ставит известные проверки в привычном порядке, прочие — в конец. */
function sortChecks(checks: InstanceCheck[]): InstanceCheck[] {
return [...checks].sort((left, right) => {
const leftIndex = CHECK_ORDER.indexOf(left.name);
const rightIndex = CHECK_ORDER.indexOf(right.name);
return (
(leftIndex === -1 ? CHECK_ORDER.length : leftIndex) -
(rightIndex === -1 ? CHECK_ORDER.length : rightIndex)
);
});
}
/** checkName переводит имя проверки, неизвестные имена показываем как есть. */
function checkName(t: (key: string) => string, name: string): string {
const key = `settings.instance.health.checks.${name}`;
const translated = t(key);
return translated === key ? name : translated;
}
function statusDotClass(health: string | undefined): string {
switch (health) {
case 'ok':
return 'bg-success';
case 'warn':
return 'bg-warning';
case 'failed':
return 'bg-danger';
default:
return 'bg-fg-muted';
}
}