feat(web): дашборд инстанса в настройках с живыми метриками
Вкладка «Инстанс» сверху показывает нагрузку сервера, ниже — диск и базу, внизу healthcheck с пингом (AGENT.md 7.19). - `Gauge` — полукруглый индикатор: заполняется по значению, меняет цвет на порогах 70 % и 90 %, доступен как `role="meter"`; - процессор и память обновляются раз в секунду (`useInstanceMetrics`), для простоя вкладки опрос останавливается; - два блока диска: «занято / всего» и вес базы (файл + WAL); видимых подписей нет — они раскрываются в тултипе при наведении на значение; - healthcheck: состояние каждой проверки, общий статус, техническая деталь и живой пинг — время ответа инстанса на запрос дашборда в миллисекундах; - переводы ru/en, тесты на индикаторы, формат «занято / всего», тултипы, healthcheck и отсутствие раздела у обычного пользователя.
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user