feat(telemetry): add canonical VPS host monitoring

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 17:29:30 +03:00
parent a3e7a015de
commit 23ea96fbb2
33 changed files with 2541 additions and 51 deletions
+270 -3
View File
@@ -3,6 +3,7 @@ import {
Button,
GlassSurface,
Icon,
IconButton,
Select,
SettingsCard,
StatusBadge,
@@ -41,6 +42,7 @@ import type {
DeviceManagerSession,
EdgeView,
InfrastructureHostView,
InfrastructureServiceInstanceView,
ModelProfileView,
ProjectWorkspace,
} from "./types";
@@ -79,12 +81,14 @@ export function DeviceControlView({
workspace,
session,
onRefresh,
onPoll,
onError,
}: {
view: ControlViewId;
workspace: ProjectWorkspace;
session: DeviceManagerSession;
onRefresh: () => Promise<void>;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [dialog, setDialog] = useState<DialogId>(null);
@@ -181,6 +185,8 @@ export function DeviceControlView({
assetBindingRef: binding.assetBindingRef,
validTo: new Date().toISOString(),
}))}
onPoll={onPoll}
onError={onError}
/>
) : null}
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
@@ -472,6 +478,8 @@ function HostsView({
onCreateAsset,
onCreateAssetBinding,
onCloseAssetBinding,
onPoll,
onError,
}: {
workspace: ProjectWorkspace;
canManageInfrastructure: boolean;
@@ -485,8 +493,25 @@ function HostsView({
onCreateAsset: () => void;
onCreateAssetBinding: () => void;
onCloseAssetBinding: (binding: AssetBindingView) => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [selectedHostRef, setSelectedHostRef] = useState<string | null>(null);
const topology = workspace.ontology;
const selectedHost = selectedHostRef
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
: null;
if (selectedHost) {
return (
<HostTelemetryWorkspace
host={selectedHost}
services={topology.serviceInstances.filter((service) => service.hostRef === selectedHost.hostRef)}
onBack={() => setSelectedHostRef(null)}
onPoll={onPoll}
onError={onError}
/>
);
}
return (
<ControlStack>
<ControlToolbar
@@ -517,9 +542,12 @@ function HostsView({
`${hostEndpoints.length} endpoints · ${hostServices.length} services`,
`management · ${host.managementCredentialConfigured ? "configured" : "unconfigured"}`,
]}
action={canManageInfrastructure ? (
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
) : null}
action={<>
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
{canManageInfrastructure ? (
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
) : null}
</>}
/>
);
})}
@@ -624,6 +652,245 @@ function HostsView({
);
}
function HostTelemetryWorkspace({
host,
services,
onBack,
onPoll,
onError,
}: {
host: InfrastructureHostView;
services: InfrastructureServiceInstanceView[];
onBack: () => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
useEffect(() => {
let active = true;
const timer = window.setInterval(() => {
onPoll().catch((reason) => active && onError(reason));
}, 3_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [onError, onPoll]);
const telemetry = host.telemetry;
const current = telemetry.current;
const networkRate = calculateNetworkRate(telemetry.history);
const cpuHistory = telemetry.history.map((sample) => sample.cpuUsagePercent);
const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent);
const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received");
const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent");
const runtimeServices = current?.services ?? [];
return (
<div className="host-telemetry-workspace">
<header className="host-telemetry-header">
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
<div className="host-telemetry-header__copy">
<small>СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</small>
<h2>{host.displayName}</h2>
<p>Аппаратный и процессинговый срез VPS. Метрики снимает host-agent; Device Core хранит только канонические наблюдения.</p>
</div>
<div className="host-telemetry-header__actions">
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : telemetry.freshness === "stale" ? "warning" : undefined}>
{telemetry.freshness === "fresh" ? "Свежие данные" : telemetry.freshness === "stale" ? "Данные устарели" : "Нет данных"}
</StatusBadge>
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
<Icon name="refresh" size={17} />
</IconButton>
</div>
</header>
<section className="host-telemetry-metric-grid" aria-label="Ключевые метрики VPS">
<TelemetryMetricCard label="CPU" value={formatPercent(current?.cpu.usagePercent)} points={cpuHistory} detail={formatLoad(current?.cpu)} />
<TelemetryMetricCard label="RAM" value={formatPercent(current?.memory.usedPercent)} points={memoryHistory} detail={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
<TelemetryMetricCard label="NETWORK RX" value={formatRate(networkRate.received)} points={receiveHistory} detail="входящий трафик" />
<TelemetryMetricCard label="NETWORK TX" value={formatRate(networkRate.sent)} points={sendHistory} detail="исходящий трафик" />
</section>
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading">
<div><small>HARDWARE</small><h3>{current?.hardware.hostname ?? host.hostKey}</h3></div>
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : "warning"}>{telemetry.state}</StatusBadge>
</div>
<div className="host-telemetry-facts">
<TelemetryFact label="Процессор" value={current?.hardware.cpuModel} />
<TelemetryFact label="Логические ядра" value={formatNullable(current?.hardware.logicalProcessors)} />
<TelemetryFact label="Память занята" value={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
<TelemetryFact label="Uptime" value={formatDuration(current?.system.uptimeSeconds)} />
<TelemetryFact label="Платформа" value={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || null} />
<TelemetryFact label="Kernel" value={current?.hardware.kernelRelease} />
<TelemetryFact label="Процессы" value={formatNullable(current?.system.processes.total)} />
<TelemetryFact label="Load average" value={formatLoad(current?.cpu)} />
</div>
</section>
<div className="host-telemetry-split">
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading"><div><small>STORAGE</small><h3>Файловые системы</h3></div><StatusBadge>{current?.disks.length ?? 0}</StatusBadge></div>
<div className="host-telemetry-list">
{(current?.disks ?? []).map((disk, index) => (
<div className="host-telemetry-list__row" key={`${disk.device}:${disk.mount}:${index}`}>
<span><strong>{disk.mount ?? disk.device ?? "Диск"}</strong><small>{[disk.device, disk.filesystem].filter(Boolean).join(" · ")}</small></span>
<span><strong>{formatPercent(disk.usedPercent)}</strong><small>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</small></span>
</div>
))}
{!current?.disks.length ? <div className="device-manager-panel-empty">Данные о дисках ещё не поступили.</div> : null}
</div>
</section>
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading"><div><small>NETWORK</small><h3>Сетевые интерфейсы</h3></div><StatusBadge>{current?.network.length ?? 0}</StatusBadge></div>
<div className="host-telemetry-list">
{(current?.network ?? []).filter((item) => item.interface !== "lo").map((item, index) => (
<div className="host-telemetry-list__row" key={`${item.interface}:${index}`}>
<span><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></span>
<span><strong> {formatMetricBytes(item.bytesReceived)}</strong><small> {formatMetricBytes(item.bytesSent)}</small></span>
</div>
))}
{!current?.network.filter((item) => item.interface !== "lo").length ? <div className="device-manager-panel-empty">Сетевые счётчики ещё не поступили.</div> : null}
</div>
</section>
</div>
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading">
<div><small>PROCESSING RUNTIME</small><h3>Сервисы VPS</h3><p>Состояние systemd-юнитов и их ресурсный профиль.</p></div>
<StatusBadge tone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : "success"}>{runtimeServices.length} units</StatusBadge>
</div>
<div className="host-telemetry-service-grid">
{runtimeServices.map((service, index) => (
<div className="host-telemetry-service" key={`${service.name}:${index}`}>
<span><strong>{service.name ?? "systemd unit"}</strong><small>{service.subState ?? service.loadState ?? "—"}</small></span>
<span><StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge><small>{formatMetricBytes(service.memoryBytes)}</small></span>
</div>
))}
{!runtimeServices.length ? <div className="device-manager-panel-empty">Состояние сервисов ещё не поступило.</div> : null}
</div>
</section>
<section className="host-telemetry-section host-telemetry-evidence">
<div><small>ONTOLOGY / OBSERVATION</small><strong>{telemetry.observation?.entityId ?? "observation.observation"}</strong></div>
<div><small>Источник</small><strong>{current ? `${current.source.agent} ${current.source.agentVersion}` : "—"}</strong></div>
<div><small>Последнее наблюдение</small><strong>{formatDate(telemetry.observedAt)}</strong></div>
<div><small>Связанные сервисы</small><strong>{services.length}</strong></div>
</section>
</div>
);
}
function TelemetryMetricCard({ label, value, points, detail }: { label: string; value: string; points: Array<number | null>; detail: string }) {
return (
<div className="host-telemetry-metric">
<span>{label}</span>
<strong>{value}</strong>
<Sparkline values={points} />
<small>{detail}</small>
</div>
);
}
function Sparkline({ values }: { values: Array<number | null> }) {
const normalized = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
if (normalized.length < 2) return <div className="host-telemetry-sparkline host-telemetry-sparkline--empty" />;
const minimum = Math.min(...normalized);
const maximum = Math.max(...normalized);
const spread = Math.max(1, maximum - minimum);
const points = normalized.map((value, index) => {
const x = (index / (normalized.length - 1)) * 100;
const y = 28 - ((value - minimum) / spread) * 24;
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
return <svg className="host-telemetry-sparkline" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true"><polyline points={points} /></svg>;
}
function TelemetryFact({ label, value }: { label: string; value: string | null | undefined }) {
return <div><small>{label}</small><strong>{value || "—"}</strong></div>;
}
function calculateNetworkRate(history: InfrastructureHostView["telemetry"]["history"]) {
if (history.length < 2) return { received: null, sent: null };
const previous = history[history.length - 2];
const latest = history[history.length - 1];
const seconds = (new Date(latest.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000;
if (!Number.isFinite(seconds) || seconds <= 0) return { received: null, sent: null };
const previousTotals = networkTotals(previous.network);
const latestTotals = networkTotals(latest.network);
return {
received: nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
sent: nonNegativeRate(latestTotals.sent - previousTotals.sent, seconds),
};
}
function calculateNetworkRateHistory(history: InfrastructureHostView["telemetry"]["history"], direction: "received" | "sent") {
return history.slice(1).map((sample, index) => {
const previous = history[index];
const seconds = (new Date(sample.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000;
if (seconds <= 0) return null;
const currentTotals = networkTotals(sample.network);
const previousTotals = networkTotals(previous.network);
return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds);
});
}
function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) {
return network.filter((item) => item.interface !== "lo").reduce((total, item) => ({
received: total.received + (item.bytesReceived ?? 0),
sent: total.sent + (item.bytesSent ?? 0),
}), { received: 0, sent: 0 });
}
function nonNegativeRate(bytes: number, seconds: number) {
const value = bytes / seconds;
return Number.isFinite(value) && value >= 0 ? value : null;
}
function formatPercent(value: number | null | undefined) {
return value == null ? "—" : `${value.toFixed(value >= 10 ? 0 : 1)}%`;
}
function formatRate(value: number | null) {
return value == null ? "—" : `${formatMetricBytes(value)}/s`;
}
function formatMetricBytes(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return "—";
if (value < 1024) return `${Math.round(value)} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MiB`;
return `${(value / 1024 ** 3).toFixed(1)} GiB`;
}
function formatUsedTotal(used: number | null | undefined, total: number | null | undefined) {
return used == null || total == null ? "—" : `${formatMetricBytes(used)} / ${formatMetricBytes(total)}`;
}
function formatNullable(value: number | null | undefined) {
return value == null ? "—" : new Intl.NumberFormat("ru-RU").format(value);
}
function formatDuration(seconds: number | null | undefined) {
if (seconds == null) return "—";
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
return [days ? `${days} д` : null, hours ? `${hours} ч` : null, `${minutes} мин`].filter(Boolean).join(" ");
}
function formatLoad(cpu: { load1: number | null; load5: number | null; load15: number | null } | null | undefined) {
if (!cpu || cpu.load1 == null) return "load average —";
return `load ${[cpu.load1, cpu.load5, cpu.load15].map((value) => value?.toFixed(2) ?? "—").join(" / ")}`;
}
function formatPackets(received: number | null | undefined, sent: number | null | undefined) {
return `${formatNullable(received)} пакетов · ↑ ${formatNullable(sent)} пакетов`;
}
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
return (
<ControlStack>