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
+48
View File
@@ -0,0 +1,48 @@
import { request } from './client';
/** Живые метрики инстанса для дашборда в настройках (AGENT.md 7.19). */
export interface InstanceMetrics {
cpu: {
/** Загрузка в процентах от доступного времени (лимит контейнера или ядра). */
percent: number;
cores: number;
source: 'cgroup' | 'host' | '';
};
memory: {
used_bytes: number;
total_bytes: number;
percent: number;
source: 'cgroup' | 'host' | '';
};
disk: {
used_bytes: number;
total_bytes: number;
free_bytes: number;
};
database: {
bytes: number;
wal_bytes: number;
};
checks: InstanceCheck[];
health: 'ok' | 'warn' | 'failed';
uptime_seconds: number;
version: string;
commit: string;
collected_at: string;
}
export interface InstanceCheck {
name: string;
status: 'ok' | 'warn' | 'failed' | 'off';
message?: string;
}
export const metricsQueryKey = ['instance', 'metrics'] as const;
export async function fetchInstanceMetrics(signal?: AbortSignal): Promise<InstanceMetrics> {
const payload = await request<{ metrics: InstanceMetrics }>(
'/instance/metrics',
signal === undefined ? {} : { signal },
);
return payload.metrics;
}
@@ -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';
}
}
+102
View File
@@ -0,0 +1,102 @@
interface GaugeProps {
/** Заполнение в процентах (0…100). */
value: number;
/** Подпись для скринридера и тултипа. */
label: string;
/** Крупное значение в центре (например, «23 %»). */
display: string;
/** Уточнение под значением (например, «1,2 / 3,4 ГБ»). */
caption?: string | undefined;
/** Порог, после которого индикатор считается нагруженным. */
warnAt?: number;
dangerAt?: number;
testId?: string;
}
/** Радиус и параметры полукруга: дуга 180° от левого края к правому. */
const RADIUS = 62;
const CENTER_X = 80;
const CENTER_Y = 74;
const STROKE = 12;
const LENGTH = Math.PI * RADIUS;
/** describeArc собирает путь полукруга (180°) в координатах SVG. */
function describeArc(): string {
const startX = CENTER_X - RADIUS;
const endX = CENTER_X + RADIUS;
return `M ${String(startX)} ${String(CENTER_Y)} A ${String(RADIUS)} ${String(RADIUS)} 0 0 1 ${String(endX)} ${String(CENTER_Y)}`;
}
function toneClass(value: number, warnAt: number, dangerAt: number): string {
if (value >= dangerAt) {
return 'text-danger';
}
if (value >= warnAt) {
return 'text-warning';
}
return 'text-accent';
}
/**
* Gauge — полукруглый индикатор нагрузки: заполняется по значению и меняет
* цвет при высокой загрузке. Длина дуги считается через `stroke-dasharray`,
* поэтому анимация — обычный CSS-переход (при `prefers-reduced-motion`
* анимации отключаются глобально).
*/
export function Gauge({
value,
label,
display,
caption,
warnAt = 70,
dangerAt = 90,
testId,
}: GaugeProps) {
const clamped = Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
const filled = (clamped / 100) * LENGTH;
const tone = toneClass(clamped, warnAt, dangerAt);
return (
<figure
className="flex flex-col items-center gap-1"
data-testid={testId}
role="meter"
aria-label={label}
aria-valuenow={Math.round(clamped)}
aria-valuemin={0}
aria-valuemax={100}
title={`${label}: ${display}`}
>
<svg viewBox="0 0 160 88" className="h-[88px] w-[160px]" aria-hidden="true">
<path
d={describeArc()}
className="stroke-surface-3"
strokeWidth={STROKE}
strokeLinecap="round"
fill="none"
/>
<path
d={describeArc()}
className={`${tone} transition-[stroke-dasharray] duration-[var(--duration-base)] ease-out`}
stroke="currentColor"
strokeWidth={STROKE}
strokeLinecap="round"
fill="none"
strokeDasharray={`${String(filled)} ${String(LENGTH)}`}
data-testid={testId === undefined ? undefined : `${testId}-arc`}
/>
</svg>
<figcaption className="-mt-8 flex flex-col items-center">
<span
className={`text-xl font-semibold tabular-nums ${tone}`}
data-testid={testId === undefined ? undefined : `${testId}-value`}
>
{display}
</span>
{caption === undefined ? null : (
<span className="text-xs text-fg-muted tabular-nums">{caption}</span>
)}
</figcaption>
</figure>
);
}
+42 -1
View File
@@ -729,7 +729,48 @@
"memberCount": "{{count}} members",
"adminBadge": "admin",
"mainBadge": "main",
"usersIdHint": "User IDs are visible to the instance administrator only."
"usersIdHint": "User IDs are visible to the instance administrator only.",
"dashboard": {
"title": "Server dashboard",
"refreshHint": "updates every second",
"cpu": "CPU",
"cpuCaption_one": "{{count}} core",
"cpuCaption_few": "{{count}} cores",
"cpuCaption_many": "{{count}} cores",
"cpuCaption_other": "{{count}} cores",
"memory": "Memory",
"diskHint": "Disk: used / total. {{free}} free",
"databaseHint": "Database: main file and WAL ({{wal}})"
},
"health": {
"title": "Instance healthcheck",
"collecting": "Collecting data…",
"unavailable": "The instance does not answer dashboard requests.",
"ping": "Ping",
"pingValue": "{{ms}} ms",
"pingUnavailable": "—",
"pingHint": "Instance response time for a dashboard request",
"summary": {
"ok": "All good",
"warn": "Warnings",
"failed": "Errors"
},
"status": {
"ok": "running",
"warn": "warning",
"failed": "failed",
"off": "disabled"
},
"checks": {
"app": "Application",
"database": "Database",
"disk": "Disk",
"storage": "Data writes",
"voice": "Voice (SFU)",
"gateway": "Gateway",
"metrics": "Metrics"
}
}
},
"servers": {
"pickerLabel": "My servers",
+42 -1
View File
@@ -729,7 +729,48 @@
"memberCount": "{{count}} участн.",
"adminBadge": "админ",
"mainBadge": "главный",
"usersIdHint": "ID пользователей видны только администратору инстанса."
"usersIdHint": "ID пользователей видны только администратору инстанса.",
"dashboard": {
"title": "Дашборд сервера",
"refreshHint": "обновляется раз в секунду",
"cpu": "Процессор",
"cpuCaption_one": "{{count}} ядро",
"cpuCaption_few": "{{count}} ядра",
"cpuCaption_many": "{{count}} ядер",
"cpuCaption_other": "{{count}} ядра",
"memory": "Память",
"diskHint": "Диск: занято / всего. Свободно {{free}}",
"databaseHint": "База данных: файл и журнал WAL ({{wal}})"
},
"health": {
"title": "Healthcheck инстанса",
"collecting": "Собираем данные…",
"unavailable": "Инстанс не отвечает на запросы дашборда.",
"ping": "Пинг",
"pingValue": "{{ms}} мс",
"pingUnavailable": "—",
"pingHint": "Время ответа инстанса на запрос дашборда",
"summary": {
"ok": "Всё в порядке",
"warn": "Есть предупреждения",
"failed": "Есть ошибки"
},
"status": {
"ok": "работает",
"warn": "внимание",
"failed": "ошибка",
"off": "выключено"
},
"checks": {
"app": "Приложение",
"database": "База данных",
"disk": "Диск",
"storage": "Запись данных",
"voice": "Голос (SFU)",
"gateway": "Gateway",
"metrics": "Метрики"
}
}
},
"servers": {
"pickerLabel": "Мои серверы",
+57
View File
@@ -0,0 +1,57 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { fetchInstanceMetrics, metricsQueryKey, type InstanceMetrics } from '@/api/metrics';
/** Как часто дашборд перезапрашивает метрики (AGENT.md 7.19: раз в секунду). */
export const METRICS_INTERVAL_MS = 1_000;
export interface InstanceMetricsState {
metrics: InstanceMetrics | undefined;
/** Время ответа инстанса на запрос дашборда, мс (оценка пинга). */
pingMs: number | null;
pending: boolean;
failed: boolean;
}
/**
* useInstanceMetrics опрашивает метрики инстанса раз в секунду и измеряет
* время ответа — оно же показывается как пинг пользователя до инстанса.
* Оба блока дашборда используют один ключ запроса: React Query выполняет
* единственный опрос независимо от числа подписчиков.
*/
export function useInstanceMetrics(enabled: boolean): InstanceMetricsState {
const [pingMs, setPingMs] = useState<number | null>(null);
const measure = useCallback((started: number) => {
if (typeof performance === 'undefined') {
return;
}
const elapsed = performance.now() - started;
// Округляем до миллисекунды и не показываем 0 мс: запрос всегда занимает время.
setPingMs(Math.max(1, Math.round(elapsed)));
}, []);
const query = useQuery({
queryKey: metricsQueryKey,
queryFn: async ({ signal }) => {
const started = typeof performance === 'undefined' ? 0 : performance.now();
const metrics = await fetchInstanceMetrics(signal);
measure(started);
return metrics;
},
enabled,
refetchInterval: METRICS_INTERVAL_MS,
// Опрос идёт только когда вкладка видима: незачем нагружать инстанс в фоне.
refetchIntervalInBackground: false,
retry: 1,
staleTime: 0,
});
return {
metrics: query.data,
pingMs,
pending: query.isPending,
failed: query.isError,
};
}
@@ -11,10 +11,13 @@ import {
instanceSettingsQueryKey,
instanceUsersQueryKey,
} from '@/api/instance';
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
import { InstanceHealth } from '@/components/instance/InstanceHealth';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
const AUDIT_LIMIT = 50;
@@ -49,6 +52,8 @@ export default function InstanceSettingsPage() {
enabled: isAdmin,
retry: 0,
});
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
const metrics = useInstanceMetrics(isAdmin);
if (!isAdmin) {
return (
@@ -69,6 +74,8 @@ export default function InstanceSettingsPage() {
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
</header>
<InstanceDashboard state={metrics} />
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
{settings.isPending ? <LoadingNotice /> : null}
@@ -204,6 +211,8 @@ export default function InstanceSettingsPage() {
</ul>
)}
</Card>
<InstanceHealth state={metrics} />
</div>
);
}