feat(telemetry): add canonical VPS host monitoring
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user