feat(data): add read-only artifact health monitor
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
@@ -44,11 +43,11 @@ import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "..
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { WorkspaceRendererProps } from "./contracts";
|
||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
|
||||
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
|
||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
@@ -1156,7 +1155,6 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
switch (props.definition.kind) {
|
||||
case "spatial":
|
||||
@@ -1185,6 +1183,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
return <NetworkWorkspace />;
|
||||
case "datasets":
|
||||
return <DatasetGatewayWorkspace />;
|
||||
case "artifact-health":
|
||||
return <ArtifactHealthWorkspace />;
|
||||
case "lab-archive":
|
||||
return (
|
||||
<LaboratoryArchiveWorkspace
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchArtifactHealth,
|
||||
type ArtifactHealth,
|
||||
} from "../../core/data/artifactHealth";
|
||||
|
||||
const POLL_MILLISECONDS = 30_000;
|
||||
|
||||
function formatBytes(value: number | null): string {
|
||||
if (value === null || !Number.isFinite(value)) return "—";
|
||||
const absolute = Math.abs(value);
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let scaled = absolute;
|
||||
let unit = units[0];
|
||||
for (const candidate of units) {
|
||||
unit = candidate;
|
||||
if (scaled < 1_000 || candidate === units.at(-1)) break;
|
||||
scaled /= 1_000;
|
||||
}
|
||||
const formatted = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: scaled < 10 ? 2 : 1,
|
||||
}).format(scaled);
|
||||
return `${value < 0 ? "−" : ""}${formatted} ${unit}`;
|
||||
}
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
const timestamp = new Date(value);
|
||||
if (!Number.isFinite(timestamp.valueOf())) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
}).format(timestamp);
|
||||
}
|
||||
|
||||
function deltaLabel(value: number | null, unit: "files" | "bytes"): string {
|
||||
if (value === null) return "Базовый замер";
|
||||
if (value === 0) return "Без изменений";
|
||||
const prefix = value > 0 ? "+" : "−";
|
||||
const absolute = Math.abs(value);
|
||||
return unit === "files"
|
||||
? `${prefix}${formatInteger(absolute)} файлов`
|
||||
: `${prefix}${formatBytes(absolute)}`;
|
||||
}
|
||||
|
||||
function errorLabel(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Срез здоровья данных недоступен.";
|
||||
}
|
||||
|
||||
export function ArtifactHealthWorkspace() {
|
||||
const [health, setHealth] = useState<ArtifactHealth | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timer: number | null = null;
|
||||
const poll = () => {
|
||||
void fetchArtifactHealth(controller.signal)
|
||||
.then((document) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setHealth(document);
|
||||
setError(null);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorLabel(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = window.setTimeout(poll, POLL_MILLISECONDS);
|
||||
}
|
||||
});
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [generation]);
|
||||
|
||||
if (!health) {
|
||||
return (
|
||||
<div className="artifact-health-workspace">
|
||||
<section className="artifact-health-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">ДАННЫЕ / ЗДОРОВЬЕ АРТЕФАКТОВ</span>
|
||||
<h2>Живой контур хранения</h2>
|
||||
<p>
|
||||
Метаданные локальных артефактов и последний точный content-аудит
|
||||
без изменения или автоматического удаления данных.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<GlassSurface className="artifact-health-state" padding="lg">
|
||||
<StatusBadge tone={error ? "danger" : "accent"}>
|
||||
{error ? "Срез недоступен" : "Сканируем метаданные"}
|
||||
</StatusBadge>
|
||||
<h3>{error ?? "Собираем файловый инвентарь"}</h3>
|
||||
<p>Содержимое больших файлов не читается и повторно не хешируется.</p>
|
||||
{error ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setGeneration((value) => value + 1)}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const largestScope = Math.max(
|
||||
...health.inventory.scopes.map((scope) => scope.logical_bytes),
|
||||
1,
|
||||
);
|
||||
const exactCurrent = health.exact_audit.state === "exact-current";
|
||||
return (
|
||||
<div className="artifact-health-workspace">
|
||||
<section className="artifact-health-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">ДАННЫЕ / ЗДОРОВЬЕ АРТЕФАКТОВ</span>
|
||||
<h2>Живой контур хранения</h2>
|
||||
<p>
|
||||
Текущий объём runtime, рост между замерами и доказанное дублирование.
|
||||
Контур остаётся только наблюдающим.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={health.overall_state === "live" ? "success" : "warning"}>
|
||||
{health.overall_state === "live" ? "Стабильно" : "Требует оптимизации"}
|
||||
</StatusBadge>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<GlassSurface className="artifact-health-notice" padding="md" tone="soft">
|
||||
<StatusBadge tone="warning">{error}</StatusBadge>
|
||||
<span>Показан последний успешный срез.</span>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<section className="artifact-health-metrics" aria-label="Сводка хранения">
|
||||
<GlassSurface padding="md">
|
||||
<span>Runtime всего</span>
|
||||
<strong>{formatBytes(health.inventory.logical_bytes)}</strong>
|
||||
<small>{deltaLabel(health.inventory.bytes_delta_since_previous, "bytes")}</small>
|
||||
</GlassSurface>
|
||||
<GlassSurface padding="md">
|
||||
<span>Файлов</span>
|
||||
<strong>{formatInteger(health.inventory.file_count)}</strong>
|
||||
<small>{deltaLabel(health.inventory.files_delta_since_previous, "files")}</small>
|
||||
</GlassSurface>
|
||||
<GlassSurface padding="md">
|
||||
<span>Доказанные дубли</span>
|
||||
<strong>{formatBytes(health.exact_audit.duplicate_bytes)}</strong>
|
||||
<small>{formatPercent(health.exact_audit.duplicate_fraction)} audit-выборки</small>
|
||||
</GlassSurface>
|
||||
<GlassSurface padding="md">
|
||||
<span>Content-групп</span>
|
||||
<strong>{formatInteger(health.exact_audit.duplicate_content_groups)}</strong>
|
||||
<small>Коэффициент {health.exact_audit.amplification_ratio.toFixed(2)}×</small>
|
||||
</GlassSurface>
|
||||
</section>
|
||||
|
||||
<GlassSurface className="artifact-scope-panel" padding="lg">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">LIVE INVENTORY</span>
|
||||
<h3>Распределение runtime</h3>
|
||||
<p>
|
||||
Метаданные обновлены {formatTimestamp(health.inventory.scanned_at_utc)}
|
||||
{" · "}{Math.round(health.inventory.scan_duration_ms)} мс
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={health.inventory.state === "live" ? "success" : "warning"}>
|
||||
{health.inventory.state === "live" ? "Live" : "Частичный срез"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<ol className="artifact-scope-list">
|
||||
{health.inventory.scopes.slice(0, 8).map((scope) => (
|
||||
<li key={scope.scope_id}>
|
||||
<div>
|
||||
<strong>{scope.scope_id}</strong>
|
||||
<span>{formatInteger(scope.file_count)} файлов</span>
|
||||
</div>
|
||||
<div className="artifact-scope-list__measure">
|
||||
<span
|
||||
style={{ width: `${scope.logical_bytes / largestScope * 100}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<strong>{formatBytes(scope.logical_bytes)}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</GlassSurface>
|
||||
|
||||
<section className="artifact-health-details">
|
||||
<GlassSurface padding="lg">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">E44 / EXACT SHA-256 AUDIT</span>
|
||||
<h3>Доказанное дублирование</h3>
|
||||
</div>
|
||||
<StatusBadge tone={exactCurrent ? "success" : "warning"}>
|
||||
{exactCurrent ? "Точный срез актуален" : "Нужен повторный аудит"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>Охвачено корней</dt><dd>{health.exact_audit.root_count}</dd></div>
|
||||
<div><dt>Логический объём</dt><dd>{formatBytes(health.exact_audit.logical_bytes)}</dd></div>
|
||||
<div><dt>Уникальный content</dt><dd>{formatBytes(health.exact_audit.unique_content_bytes)}</dd></div>
|
||||
<div><dt>Дата аудита</dt><dd>{formatTimestamp(health.exact_audit.audited_at_utc)}</dd></div>
|
||||
</dl>
|
||||
{health.exact_audit.changed_roots.length ? (
|
||||
<p>Изменены корни: {health.exact_audit.changed_roots.join(", ")}.</p>
|
||||
) : (
|
||||
<p>Все 14 измеренных корней совпадают с точным аудитом по числу файлов и байтам.</p>
|
||||
)}
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface padding="lg">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">CONTENT REFERENCES</span>
|
||||
<h3>Материализованные копии</h3>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Не индексировано</StatusBadge>
|
||||
</header>
|
||||
<p>
|
||||
Content-addressed ссылки ещё не включены. Миграция хранения не
|
||||
авторизована: сначала выбираются классы дублей с максимальным
|
||||
измеренным эффектом.
|
||||
</p>
|
||||
<dl>
|
||||
<div><dt>Режим</dt><dd>Наблюдение</dd></div>
|
||||
<div><dt>Удаление</dt><dd>Запрещено</dd></div>
|
||||
<div><dt>Автомиграция</dt><dd>Запрещена</dd></div>
|
||||
<div><dt>Следующий gate</dt><dd>Content refs / chunking</dd></div>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user