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
@@ -204,7 +204,10 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
},
assets: projectValues(assets, projectRef),
assetBindings: projectValues(assetBindings, projectRef),
hosts: projectHosts.map((host) => withHealth("host", host, host.hostRef)),
hosts: projectHosts.map((host) => ({
...withHealth("host", host, host.hostRef),
telemetry: host.telemetry ?? emptyPreviewHostTelemetry(),
})),
endpoints: projectValues(endpoints, projectRef),
deployments: projectValues(deployments, projectRef),
serviceInstances: projectServices.map((service) =>
@@ -245,6 +248,10 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
routes,
sessions,
configurationStates,
hosts,
deployments,
serviceInstances,
healthObservations,
});
else if (fixture != null && fixture !== "") {
throw serviceError("device_manager_preview_fixture_invalid", 400);
@@ -890,6 +897,88 @@ function previewOntology(entityId) {
return { entityId, catalogHash: "229c61c02a790906" };
}
function emptyPreviewHostTelemetry() {
return {
state: "unobserved",
freshness: "missing",
observedAt: null,
receivedAt: null,
expiresAt: null,
current: null,
history: [],
observation: null,
};
}
function previewHostTelemetry(hostRef, serviceInstanceRef, edgeRef) {
const now = Date.now();
const history = Array.from({ length: 60 }, (_, index) => {
const observedAt = new Date(now - (59 - index) * 2_000).toISOString();
const phase = index / 7;
return {
observedAt,
cpuUsagePercent: 18 + Math.sin(phase) * 8 + (index % 5),
memoryUsedPercent: 42 + Math.sin(phase / 2) * 3,
network: [{
interface: "eth0",
bytesReceived: 8_000_000 + index * (32_000 + (index % 4) * 4_000),
bytesSent: 3_000_000 + index * (14_000 + (index % 3) * 2_000),
}],
};
});
const observedAt = history.at(-1).observedAt;
const current = {
schemaVersion: "nodedc.infrastructure.host-telemetry.v1",
profile: "linux-host-telegraf-v1",
hostKey: "robot2b-b2-edge-vps",
observedAt,
source: {
agent: "telegraf",
agentVersion: "1.38.4",
collectorRef: "service:nodedc-host-telemetry-agent",
},
hardware: {
hostname: "koffyvngij",
architecture: "x64",
platform: "linux",
kernelRelease: "6.8.0-79-generic",
cpuModel: "AMD EPYC Processor (KVM)",
logicalProcessors: 1,
},
cpu: { usagePercent: history.at(-1).cpuUsagePercent, load1: 0.31, load5: 0.24, load15: 0.18 },
memory: { totalBytes: 1_007_681_536, availableBytes: 570_425_344, freeBytes: null, usedBytes: 437_256_192, usedPercent: history.at(-1).memoryUsedPercent },
swap: { totalBytes: 0, availableBytes: null, freeBytes: 0, usedBytes: 0, usedPercent: 0 },
system: { uptimeSeconds: 723_419, users: 1, processes: { total: 118, running: 2, sleeping: 115, blocked: 0, zombies: 1 } },
disks: [{ device: "/dev/vda1", mount: "/", filesystem: "ext4", totalBytes: 21_474_836_480, freeBytes: 14_495_514_624, usedBytes: 6_979_321_856, usedPercent: 32.5 }],
network: [{ interface: "eth0", bytesReceived: history.at(-1).network[0].bytesReceived, bytesSent: history.at(-1).network[0].bytesSent, packetsReceived: 91_482, packetsSent: 64_501, errorsReceived: 0, errorsSent: 0, droppedReceived: 0, droppedSent: 0 }],
services: [
{ name: "nodedc-device-edge-channel.service", loadState: "loaded", activeState: "active", subState: "running", memoryBytes: 71_303_168, restarts: 0, pid: 1482 },
{ name: "nodedc-host-telemetry-agent.service", loadState: "loaded", activeState: "active", subState: "running", memoryBytes: 35_651_584, restarts: 0, pid: 1510 },
{ name: "ssh.service", loadState: "loaded", activeState: "active", subState: "running", memoryBytes: 8_388_608, restarts: 0, pid: 712 },
],
};
return {
state: "online",
freshness: "fresh",
observedAt,
receivedAt: new Date(now).toISOString(),
expiresAt: new Date(now + 15_000).toISOString(),
current,
history,
observation: {
observationRef: "observation:preview-host-telemetry",
entityId: "observation.observation",
catalogHash: "229c61c02a790906",
targetRef: hostRef,
serviceInstanceRef,
edgeRef,
profileRef: "linux-host-telegraf-v1",
source: { ...current.source, provenanceRef: `${edgeRef}:${current.source.collectorRef}` },
observedProperties: ["host.cpu.utilization", "host.memory.utilization", "host.disk.utilization", "host.network.counters", "host.systemd.unit-state"],
},
};
}
function requirePlatformOwner(actor) {
if (actor?.hubRole !== "owner") {
throw serviceError("device_platform_catalog_access_denied", 403);
@@ -905,6 +994,10 @@ function seedArusnaviB2Preview({
routes,
sessions,
configurationStates,
hosts,
deployments,
serviceInstances,
healthObservations,
}) {
const timestamp = new Date().toISOString();
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
@@ -913,6 +1006,9 @@ function seedArusnaviB2Preview({
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
const hostRef = "host:adf2a5b6-3c0b-4a39-998c-07dfb7818ad1";
const deploymentRef = "deployment:49b296f8-4cc8-470f-9dc1-2e3a543fd224";
const serviceInstanceRef = "service-instance:01f14736-f5c2-4867-9cbc-2d268996a871";
const scope = {
ownerScopeRef,
scopeKind: "personal",
@@ -963,6 +1059,52 @@ function seedArusnaviB2Preview({
createdAt: timestamp,
updatedAt: timestamp,
});
hosts.set(hostRef, {
hostRef,
projectRef,
hostKey: "robot2b-b2-edge-vps",
displayName: "Robot2B B2 Edge VPS",
providerRef: "provider:beget",
externalRef: "host:koffyvngij",
managementCredentialConfigured: true,
lifecycleState: "active",
telemetry: previewHostTelemetry(hostRef, serviceInstanceRef, edgeRef),
ontology: previewOntology("infrastructure.host"),
});
deployments.set(deploymentRef, {
deploymentRef,
projectRef,
hostRef,
deploymentKey: "device-edge-vps-command-transport",
displayName: "Device Edge VPS runtime",
artifactRef: "device-edge-vps-command-transport-20260812-013",
artifactDigest: "sha256:c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef",
lifecycleState: "active",
ontology: previewOntology("infrastructure.deployment"),
});
serviceInstances.set(serviceInstanceRef, {
serviceInstanceRef,
projectRef,
hostRef,
deploymentRef,
edgeRef,
serviceKey: "device-edge",
displayName: "Robot2B B2 Device Edge",
serviceRole: "device.edge",
lifecycleState: "active",
ontology: previewOntology("infrastructure.service_instance"),
});
const healthObservationRef = "health-observation:b912a45a-2b97-4562-8f9e-824cc24e0710";
healthObservations.set(healthObservationRef, {
healthObservationRef,
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "agent.telemetry",
observedAt: timestamp,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
routes.set(routeRef, {
routeRef,
projectRef,
+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>
@@ -806,6 +806,7 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, canConfi
workspace={workspace}
session={session}
onRefresh={onRefresh}
onPoll={onPoll}
onError={onError}
/>;
}
+242
View File
@@ -127,6 +127,231 @@
padding-right: 3px;
}
.host-telemetry-workspace {
display: grid;
gap: 18px;
min-width: 0;
}
.host-telemetry-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 14px;
}
.host-telemetry-header__copy {
display: grid;
gap: 3px;
}
.host-telemetry-header__copy small,
.host-telemetry-section__heading small,
.host-telemetry-evidence small {
color: var(--nodedc-text-tertiary);
font-size: 0.62rem;
font-weight: 780;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.host-telemetry-header__copy h2,
.host-telemetry-header__copy p,
.host-telemetry-section__heading h3,
.host-telemetry-section__heading p {
margin: 0;
}
.host-telemetry-header__copy h2 {
font-size: 1.4rem;
letter-spacing: -0.035em;
}
.host-telemetry-header__copy p,
.host-telemetry-section__heading p {
color: var(--nodedc-text-tertiary);
font-size: 0.76rem;
line-height: 1.5;
}
.host-telemetry-header__actions {
display: flex;
align-items: center;
gap: 8px;
}
.host-telemetry-metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.host-telemetry-metric,
.host-telemetry-section {
border: 1px solid var(--nodedc-glass-outline);
border-radius: var(--nodedc-radius-lg);
background: color-mix(in srgb, var(--nodedc-surface) 82%, transparent);
}
.host-telemetry-metric {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 12px;
min-height: 112px;
padding: 14px;
overflow: hidden;
}
.host-telemetry-metric > span,
.host-telemetry-metric > small,
.host-telemetry-facts small,
.host-telemetry-list__row small,
.host-telemetry-service small {
color: var(--nodedc-text-tertiary);
font-size: 0.66rem;
}
.host-telemetry-metric > strong {
font-size: 1.35rem;
font-weight: 620;
}
.host-telemetry-metric > small {
grid-column: 1 / -1;
}
.host-telemetry-sparkline {
grid-column: 1 / -1;
width: 100%;
height: 32px;
overflow: visible;
}
.host-telemetry-sparkline polyline {
fill: none;
stroke: rgb(var(--nodedc-accent-rgb));
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.host-telemetry-sparkline--empty {
border-bottom: 1px solid var(--nodedc-glass-outline);
}
.host-telemetry-section {
display: grid;
gap: 14px;
padding: 18px;
}
.host-telemetry-section__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.host-telemetry-section__heading > div {
display: grid;
gap: 4px;
}
.host-telemetry-section__heading h3 {
font-size: 1rem;
}
.host-telemetry-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border-radius: var(--nodedc-radius-md);
background: var(--nodedc-glass-outline);
}
.host-telemetry-facts > div {
display: grid;
gap: 5px;
min-width: 0;
padding: 12px;
background: var(--nodedc-surface-soft);
}
.host-telemetry-facts strong {
overflow: hidden;
font-size: 0.78rem;
font-weight: 590;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-telemetry-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.host-telemetry-list,
.host-telemetry-service-grid {
display: grid;
gap: 7px;
}
.host-telemetry-list__row,
.host-telemetry-service {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 10px 12px;
border-radius: var(--nodedc-radius-md);
background: var(--nodedc-surface-soft);
}
.host-telemetry-list__row > span,
.host-telemetry-service > span {
display: grid;
gap: 3px;
min-width: 0;
}
.host-telemetry-list__row > span:last-child,
.host-telemetry-service > span:last-child {
justify-items: end;
text-align: right;
}
.host-telemetry-list__row strong,
.host-telemetry-service strong {
overflow: hidden;
font-size: 0.76rem;
font-weight: 590;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-telemetry-service-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.host-telemetry-evidence {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.host-telemetry-evidence > div {
display: grid;
gap: 5px;
min-width: 0;
}
.host-telemetry-evidence strong {
overflow: hidden;
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.device-control-resource-grid,
.device-control-policy-grid {
@@ -168,6 +393,23 @@
grid-column: 2;
justify-content: flex-start;
}
.host-telemetry-header {
grid-template-columns: auto minmax(0, 1fr);
}
.host-telemetry-header__actions {
grid-column: 2;
justify-content: flex-start;
}
.host-telemetry-metric-grid,
.host-telemetry-facts,
.host-telemetry-service-grid,
.host-telemetry-evidence,
.host-telemetry-split {
grid-template-columns: 1fr;
}
}
html,
+54
View File
@@ -358,9 +358,63 @@ export interface InfrastructureHostView {
managementCredentialConfigured: boolean;
lifecycleState: string;
health: HealthProjectionView;
telemetry: InfrastructureHostTelemetryView;
ontology: OntologyRefView;
}
export interface InfrastructureHostTelemetrySnapshot {
schemaVersion: "nodedc.infrastructure.host-telemetry.v1";
profile: "linux-host-telegraf-v1";
hostKey: string;
observedAt: string;
source: { agent: string; agentVersion: string; collectorRef: string };
hardware: {
hostname: string | null;
architecture: string | null;
platform: string | null;
kernelRelease: string | null;
cpuModel: string | null;
logicalProcessors: number | null;
};
cpu: { usagePercent: number | null; load1: number | null; load5: number | null; load15: number | null };
memory: { totalBytes: number | null; availableBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null };
swap: { totalBytes: number | null; availableBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null };
system: {
uptimeSeconds: number | null;
users: number | null;
processes: { total: number | null; running: number | null; sleeping: number | null; blocked: number | null; zombies: number | null };
};
disks: Array<{ device: string | null; mount: string | null; filesystem: string | null; totalBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null }>;
network: Array<{ interface: string | null; bytesReceived: number | null; bytesSent: number | null; packetsReceived: number | null; packetsSent: number | null; errorsReceived: number | null; errorsSent: number | null; droppedReceived: number | null; droppedSent: number | null }>;
services: Array<{ name: string | null; loadState: string | null; activeState: string | null; subState: string | null; memoryBytes: number | null; restarts: number | null; pid: number | null }>;
}
export interface InfrastructureHostTelemetryView {
state: string;
freshness: "fresh" | "stale" | "missing";
observedAt: string | null;
receivedAt: string | null;
expiresAt: string | null;
current: InfrastructureHostTelemetrySnapshot | null;
history: Array<{
observedAt: string;
cpuUsagePercent: number | null;
memoryUsedPercent: number | null;
network: Array<{ interface: string | null; bytesReceived: number | null; bytesSent: number | null }>;
}>;
observation: null | {
observationRef: string;
entityId: string;
catalogHash: string;
targetRef: string;
serviceInstanceRef: string;
edgeRef: string;
profileRef: string;
source: { agent: string; agentVersion: string; collectorRef: string; provenanceRef: string };
observedProperties: string[];
};
}
export interface InfrastructureEndpointView {
endpointRef: string;
hostRef: string;