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
+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,
};
}