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,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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -729,7 +729,48 @@
|
|||||||
"memberCount": "{{count}} members",
|
"memberCount": "{{count}} members",
|
||||||
"adminBadge": "admin",
|
"adminBadge": "admin",
|
||||||
"mainBadge": "main",
|
"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": {
|
"servers": {
|
||||||
"pickerLabel": "My servers",
|
"pickerLabel": "My servers",
|
||||||
|
|||||||
@@ -729,7 +729,48 @@
|
|||||||
"memberCount": "{{count}} участн.",
|
"memberCount": "{{count}} участн.",
|
||||||
"adminBadge": "админ",
|
"adminBadge": "админ",
|
||||||
"mainBadge": "главный",
|
"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": {
|
"servers": {
|
||||||
"pickerLabel": "Мои серверы",
|
"pickerLabel": "Мои серверы",
|
||||||
|
|||||||
@@ -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,
|
instanceSettingsQueryKey,
|
||||||
instanceUsersQueryKey,
|
instanceUsersQueryKey,
|
||||||
} from '@/api/instance';
|
} from '@/api/instance';
|
||||||
|
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
||||||
|
import { InstanceHealth } from '@/components/instance/InstanceHealth';
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { formatDateTime } from '@/lib/identity';
|
import { formatDateTime } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
|
||||||
|
|
||||||
const AUDIT_LIMIT = 50;
|
const AUDIT_LIMIT = 50;
|
||||||
|
|
||||||
@@ -49,6 +52,8 @@ export default function InstanceSettingsPage() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
||||||
|
const metrics = useInstanceMetrics(isAdmin);
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +74,8 @@ export default function InstanceSettingsPage() {
|
|||||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<InstanceDashboard state={metrics} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
|
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
|
||||||
{settings.isPending ? <LoadingNotice /> : null}
|
{settings.isPending ? <LoadingNotice /> : null}
|
||||||
@@ -204,6 +211,8 @@ export default function InstanceSettingsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<InstanceHealth state={metrics} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
|
||||||
|
|
||||||
|
/** Метрики инстанса в формате ручки `GET /instance/metrics`. */
|
||||||
|
function metricsPayload(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
metrics: {
|
||||||
|
cpu: { percent: 23.4, cores: 4, source: 'cgroup' },
|
||||||
|
memory: {
|
||||||
|
used_bytes: 805_306_368 / 4,
|
||||||
|
total_bytes: 805_306_368,
|
||||||
|
percent: 25,
|
||||||
|
source: 'cgroup',
|
||||||
|
},
|
||||||
|
disk: {
|
||||||
|
used_bytes: 12 * 1024 ** 3,
|
||||||
|
total_bytes: 24 * 1024 ** 3,
|
||||||
|
free_bytes: 12 * 1024 ** 3,
|
||||||
|
},
|
||||||
|
database: { bytes: 1_572_864, wal_bytes: 262_144 },
|
||||||
|
checks: [
|
||||||
|
{ name: 'app', status: 'ok', message: 'v0.1.0' },
|
||||||
|
{ name: 'database', status: 'ok', message: 'schema_version=10' },
|
||||||
|
{ name: 'disk', status: 'ok', message: '50.0% free' },
|
||||||
|
{ name: 'storage', status: 'ok' },
|
||||||
|
{ name: 'voice', status: 'ok', message: 'RoomService' },
|
||||||
|
{ name: 'gateway', status: 'ok', message: 'sessions=3' },
|
||||||
|
],
|
||||||
|
health: 'ok',
|
||||||
|
uptime_seconds: 3600,
|
||||||
|
version: 'v0.1.0',
|
||||||
|
commit: 'abc1234',
|
||||||
|
collected_at: '2026-09-20T18:00:00.123Z',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appRoutes(current = makeUser(), extra: FetchRoute[] = []): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
...extra,
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user: current }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** adminRoutes: раздел «Инстанс» виден только администратору инстанса. */
|
||||||
|
function adminRoutes(metrics: unknown = metricsPayload()): FetchRoute[] {
|
||||||
|
return appRoutes(makeUser({ is_instance_admin: true }), [
|
||||||
|
{ match: '/api/v1/instance/metrics', response: () => json(metrics) },
|
||||||
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||||
|
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/instance/users', response: () => json({ users: [] }) },
|
||||||
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** openInstance открывает настройки и переходит на вкладку «Инстанс». */
|
||||||
|
async function openInstance(): Promise<HTMLElement> {
|
||||||
|
const gear = await screen.findByLabelText('Настройки пользователя');
|
||||||
|
await userEvent.click(gear);
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
await userEvent.click(await within(dialog).findByRole('button', { name: 'Инстанс' }));
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('дашборд инстанса', () => {
|
||||||
|
it('показывает полукруглые индикаторы процессора и памяти', async () => {
|
||||||
|
installFetch(adminRoutes());
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
const cpu = await within(dialog).findByTestId('gauge-cpu');
|
||||||
|
expect(cpu).toHaveAttribute('role', 'meter');
|
||||||
|
expect(within(cpu).getByTestId('gauge-cpu-value')).toHaveTextContent('23,4 %');
|
||||||
|
// Полукруг заполняется пропорционально: 23.4 % от длины дуги.
|
||||||
|
const arc = within(cpu).getByTestId('gauge-cpu-arc');
|
||||||
|
const [filled, total] = (arc.getAttribute('stroke-dasharray') ?? '').split(' ');
|
||||||
|
expect(Number(filled)).toBeGreaterThan(0);
|
||||||
|
expect(Number(filled) / Number(total)).toBeCloseTo(0.234, 2);
|
||||||
|
|
||||||
|
const memory = within(dialog).getByTestId('gauge-memory');
|
||||||
|
expect(within(memory).getByTestId('gauge-memory-value')).toHaveTextContent('25 %');
|
||||||
|
expect(memory).toHaveAttribute('aria-valuenow', '25');
|
||||||
|
// Подпись памяти — занято / всего.
|
||||||
|
expect(within(memory).getByText(/192 MB \/ 768 MB/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('показывает диск и базу без подписей — только в тултипах', async () => {
|
||||||
|
installFetch(adminRoutes());
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
const disk = await within(dialog).findByTestId('disk-usage');
|
||||||
|
const diskValue = within(disk).getByTestId('disk-usage-value');
|
||||||
|
expect(diskValue).toHaveTextContent('12 GB / 24 GB');
|
||||||
|
expect(diskValue).toHaveAttribute('title', expect.stringContaining('Диск: занято / всего'));
|
||||||
|
expect(diskValue).toHaveAttribute('title', expect.stringContaining('12 GB'));
|
||||||
|
|
||||||
|
const database = within(dialog).getByTestId('database-size');
|
||||||
|
const databaseValue = within(database).getByTestId('database-size-value');
|
||||||
|
// База = основной файл + журнал WAL (1.5 MB + 0.25 MB) → 1,8 MB
|
||||||
|
// (formatBytes оставляет одну цифру после запятой для значений < 10).
|
||||||
|
expect(databaseValue).toHaveTextContent('1,8 MB');
|
||||||
|
expect(databaseValue).toHaveAttribute('title', expect.stringContaining('База данных'));
|
||||||
|
|
||||||
|
// Видимых подписей у блоков нет: только значения.
|
||||||
|
expect(disk.textContent).toBe(diskValue.textContent);
|
||||||
|
expect(database.textContent).toBe(databaseValue.textContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('показывает healthcheck и живой пинг', async () => {
|
||||||
|
installFetch(adminRoutes());
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
const health = await within(dialog).findByTestId('instance-health');
|
||||||
|
expect(within(health).getByTestId('instance-health-summary')).toHaveTextContent(
|
||||||
|
'Всё в порядке',
|
||||||
|
);
|
||||||
|
expect(within(health).getByTestId('instance-check-app')).toHaveAttribute('data-status', 'ok');
|
||||||
|
expect(within(health).getByTestId('instance-check-database')).toHaveTextContent(
|
||||||
|
'schema_version=10',
|
||||||
|
);
|
||||||
|
expect(within(health).getByTestId('instance-check-voice')).toHaveTextContent('работает');
|
||||||
|
expect(within(health).getByTestId('instance-check-gateway')).toHaveTextContent('Gateway');
|
||||||
|
|
||||||
|
// Пинг измеряется на запросе дашборда и показывается в миллисекундах.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(health).getByTestId('instance-ping').getAttribute('data-ping')).not.toBe('');
|
||||||
|
});
|
||||||
|
expect(within(health).getByTestId('instance-ping')).toHaveTextContent('Пинг');
|
||||||
|
expect(within(health).getByTestId('instance-ping').textContent).toMatch(/\d+ мс/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('деградирует мягко: предупреждения и ошибки видно в списке', async () => {
|
||||||
|
installFetch(
|
||||||
|
adminRoutes(
|
||||||
|
metricsPayload({
|
||||||
|
health: 'failed',
|
||||||
|
checks: [
|
||||||
|
{ name: 'app', status: 'ok' },
|
||||||
|
{ name: 'database', status: 'failed', message: 'database is locked' },
|
||||||
|
{ name: 'disk', status: 'warn', message: '11.0% free' },
|
||||||
|
],
|
||||||
|
cpu: { percent: 0, cores: 0, source: '' },
|
||||||
|
memory: { used_bytes: 0, total_bytes: 0, percent: 0, source: '' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
const health = await within(dialog).findByTestId('instance-health');
|
||||||
|
expect(within(health).getByTestId('instance-health-summary')).toHaveTextContent('Есть ошибки');
|
||||||
|
expect(within(health).getByTestId('instance-check-database')).toHaveAttribute(
|
||||||
|
'data-status',
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
expect(within(health).getByTestId('instance-check-disk')).toHaveAttribute(
|
||||||
|
'data-status',
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обычный пользователь дашборд не видит', async () => {
|
||||||
|
installFetch(appRoutes(makeUser({ is_instance_admin: false })));
|
||||||
|
renderApp('/app/empty');
|
||||||
|
// Кнопка настроек в панели пользователя есть всегда, а раздел «Инстанс» —
|
||||||
|
// только у администратора, поэтому проверяем отсутствие пункта.
|
||||||
|
const gear = await screen.findByLabelText('Настройки пользователя');
|
||||||
|
await userEvent.click(gear);
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user