feat(device-core): ship host telemetry and scalable VPS inventory

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 11:58:58 +03:00
parent 5d58dd6c37
commit ebc264eb60
13 changed files with 1983 additions and 439 deletions
@@ -84,6 +84,74 @@ test("Device Core overview follows the Mission Core landing-stage geometry", asy
assert.ok(!client.includes('<GlassSurface className="device-manager-home"'));
});
test("VPS inventory and monitoring follow the Mission Core system workspace geometry", async () => {
const client = await readFile(
new URL("../src/DeviceControlViews.tsx", import.meta.url),
"utf8",
);
const styles = await readFile(
new URL("../src/styles.css", import.meta.url),
"utf8",
);
const infrastructureSource = client.slice(
client.indexOf("function HostsView"),
client.indexOf("function SessionsView"),
);
for (const expected of [
'className="infrastructure-system-workspace"',
'className="infrastructure-system-workspace host-monitoring-workspace"',
'className="infrastructure-overview-block"',
'className="infrastructure-section infrastructure-hosts-block"',
'className="infrastructure-host-list"',
'className="infrastructure-host-card__summary"',
'className="infrastructure-host-card__freshness"',
'className="infrastructure-host-card__toggle"',
'className="infrastructure-host-card__details"',
'className="infrastructure-host-relations"',
'aria-expanded={expanded}',
'const [expandedHostRefs, setExpandedHostRefs] = useState<Set<string>>',
'topology.endpoints.filter((item) => item.hostRef === host.hostRef)',
'topology.deployments.filter((item) => item.hostRef === host.hostRef)',
'topology.serviceInstances.filter((item) => item.hostRef === host.hostRef)',
'className="host-monitoring-series-grid"',
'className="host-monitoring-hardware"',
'className="host-monitoring-hardware-facts"',
'className="infrastructure-runtime-grid"',
'percentageTelemetryWindow(cpuHistory, 5)',
'percentageTelemetryWindow(memoryHistory, 4)',
'className="host-monitoring-series__range"',
'resource={networkRate.received == null ? "нет данных" : "входящий трафик"}',
'if (!counters.length) return null;',
]) assert.ok(infrastructureSource.includes(expected), expected);
for (const expected of [
".infrastructure-system-workspace {",
".infrastructure-overview-block,",
".infrastructure-hosts-block,",
".infrastructure-host-card__summary {",
"align-items: center;",
".infrastructure-host-card__freshness[data-freshness=\"fresh\"] {",
".infrastructure-host-card[data-expanded=\"true\"] .infrastructure-host-card__toggle svg {",
".infrastructure-host-relations {",
"grid-template-columns: repeat(3, minmax(0, 1fr));",
"grid-template-columns: repeat(4, minmax(0, 1fr));",
"background: var(--infrastructure-panel-soft);",
".host-monitoring-series polyline {",
"stroke: var(--nodedc-text-primary);",
".host-monitoring-hardware-facts {",
"grid-template-columns: repeat(2, minmax(0, 1fr));",
]) assert.ok(styles.includes(expected), expected);
assert.ok(!infrastructureSource.includes("<ResourceCard"));
assert.ok(!infrastructureSource.includes('title="Сервисы хостов"'));
assert.ok(!infrastructureSource.includes('title="Deployments и endpoints"'));
assert.ok(!infrastructureSource.includes("ceiling={100}"));
assert.ok(!infrastructureSource.includes("Канонический ontology catalog"));
assert.ok(!styles.includes(".host-telemetry-metric,"));
assert.ok(!styles.includes(".host-telemetry-facts"));
});
test("legacy single teaser migrates into the environment media playlist", () => {
const environment = normalizeEnvironmentPresentation({
defaultTeaser: {
+402 -238
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
import {
Button,
GlassSurface,
@@ -497,10 +497,24 @@ function HostsView({
onError: (reason: unknown) => void;
}) {
const [selectedHostRef, setSelectedHostRef] = useState<string | null>(null);
const [expandedHostRefs, setExpandedHostRefs] = useState<Set<string>>(() => new Set());
const inventoryRef = useRef<HTMLDivElement>(null);
const topology = workspace.ontology;
const selectedHost = selectedHostRef
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
: null;
useEffect(() => {
if (selectedHostRef) return;
resetApplicationPanelScroll(inventoryRef.current);
}, [selectedHostRef]);
const toggleHost = (hostRef: string) => {
setExpandedHostRefs((current) => {
const next = new Set(current);
if (next.has(hostRef)) next.delete(hostRef);
else next.add(hostRef);
return next;
});
};
if (selectedHost) {
return (
<HostTelemetryWorkspace
@@ -513,142 +527,152 @@ function HostsView({
);
}
return (
<ControlStack>
<ControlToolbar
copy={`Канонический ontology catalog ${topology.ontology.catalogHash}: Host, endpoint, deployment и service instance существуют отдельно. Edge — опциональная роль service instance; credentials остаются server-side.`}
actions={canManageInfrastructure ? <>
<Button size="compact" onClick={onCreateHost}>Новый VPS</Button>
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
<Button size="compact" variant="primary" onClick={onCreateService} disabled={!topology.deployments.length}>Service</Button>
</> : null}
/>
<ControlSection title="VPS и хосты" count={topology.hosts.length}>
<ResourceGrid empty="VPS и хосты для проекта пока не зарегистрированы.">
{topology.hosts.map((host) => {
const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
return (
<ResourceCard
key={host.hostRef}
eyebrow="INFRASTRUCTURE / HOST"
title={host.displayName}
description={host.externalRef || host.hostKey}
status={host.health.state}
meta={[
`lifecycle · ${host.lifecycleState}`,
`health · ${host.health.freshness}`,
...(host.providerRef ? [`provider · ${host.providerRef}`] : []),
`${hostEndpoints.length} endpoints · ${hostServices.length} services`,
`management · ${host.managementCredentialConfigured ? "configured" : "unconfigured"}`,
]}
action={<>
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
{canManageInfrastructure ? (
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
<div className="infrastructure-system-workspace" ref={inventoryRef}>
<section className="infrastructure-overview-block">
<div className="infrastructure-workspace-lead">
<div>
<span className="infrastructure-eyebrow">ИНФРАСТРУКТУРА / VPS И ХОСТЫ</span>
<h2>VPS и хосты</h2>
<p>Вычислительные узлы проекта, их подключения и запущенные сервисы.</p>
</div>
{canManageInfrastructure ? (
<div className="infrastructure-workspace-actions">
<Button size="compact" variant="primary" onClick={onCreateHost}>Новый VPS</Button>
</div>
) : null}
</div>
<div className="infrastructure-overview-grid" aria-label="Сводка инфраструктуры">
<InfrastructureCount label="ХОСТЫ" value={topology.hosts.length} detail={`${topology.hosts.filter((host) => host.telemetry.freshness === "fresh").length} со свежими данными`} />
<InfrastructureCount label="ENDPOINTS" value={topology.endpoints.length} detail="точки подключения" />
<InfrastructureCount label="DEPLOYMENTS" value={topology.deployments.length} detail="развёрнутые контуры" />
<InfrastructureCount label="SERVICES" value={topology.serviceInstances.length} detail="экземпляры сервисов" />
</div>
</section>
<section className="infrastructure-section infrastructure-hosts-block">
<InfrastructureSectionHeading
eyebrow="ВЫЧИСЛИТЕЛЬНЫЕ УЗЛЫ"
title="Зарегистрированные хосты"
description="Компактный список VPS. Раскройте только тот хост, связи которого нужно посмотреть."
status={russianCount(topology.hosts.length, "хост", "хоста", "хостов")}
actions={canManageInfrastructure ? <>
<Button size="compact" onClick={onRecordHealth}>Наблюдение</Button>
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
<Button size="compact" variant="primary" onClick={onCreateService} disabled={!topology.deployments.length}>Service</Button>
</> : null}
/>
{topology.hosts.length ? (
<div className="infrastructure-host-list">
{topology.hosts.map((host) => {
const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
const hostDeployments = topology.deployments.filter((item) => item.hostRef === host.hostRef);
const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
const expanded = expandedHostRefs.has(host.hostRef);
const detailsId = `infrastructure-host-details-${host.hostRef.replace(/[^A-Za-z0-9_-]/g, "-")}`;
return (
<article className="infrastructure-host-card" data-expanded={expanded ? "true" : undefined} key={host.hostRef}>
<header className="infrastructure-host-card__summary">
<div className="infrastructure-host-card__identity">
<span className="infrastructure-eyebrow">COMPUTE HOST</span>
<h4>{host.displayName}</h4>
<code>{host.externalRef || host.hostKey}</code>
</div>
<div className="infrastructure-host-card__actions">
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
<span
className="infrastructure-host-card__freshness"
data-freshness={host.telemetry.freshness}
role="status"
aria-label={freshnessLabel(host.telemetry.freshness)}
title={freshnessLabel(host.telemetry.freshness)}
/>
<IconButton
className="infrastructure-host-card__toggle"
label={expanded ? `Свернуть ${host.displayName}` : `Развернуть ${host.displayName}`}
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => toggleHost(host.hostRef)}
>
<Icon name="chevron-down" size={18} />
</IconButton>
</div>
</header>
{expanded ? (
<div className="infrastructure-host-card__details" id={detailsId}>
<dl className="infrastructure-host-card__facts">
<div><dt>Состояние</dt><dd>{host.lifecycleState}</dd></div>
<div><dt>Провайдер</dt><dd>{host.providerRef || "Не указан"}</dd></div>
<div><dt>Доступ управления</dt><dd>{host.managementCredentialConfigured ? "Настроен" : "Не настроен"}</dd></div>
<div><dt>Последнее наблюдение</dt><dd>{formatDate(host.telemetry.observedAt)}</dd></div>
</dl>
<div className="infrastructure-host-relations">
<section>
<header><span className="infrastructure-eyebrow">ENDPOINTS</span><strong>{hostEndpoints.length}</strong></header>
{hostEndpoints.length ? <div className="infrastructure-registry-list">{hostEndpoints.map((endpoint) => (
<InfrastructureRegistryRow key={endpoint.endpointRef} label={endpoint.purpose} title={endpoint.endpointKey} description={endpoint.endpointUri} status={endpoint.lifecycleState} />
))}</div> : <p>Точки подключения не зарегистрированы.</p>}
</section>
<section>
<header><span className="infrastructure-eyebrow">DEPLOYMENTS</span><strong>{hostDeployments.length}</strong></header>
{hostDeployments.length ? <div className="infrastructure-registry-list">{hostDeployments.map((deployment) => (
<InfrastructureRegistryRow key={deployment.deploymentRef} label="DEPLOYMENT" title={deployment.displayName} description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`} status={deployment.lifecycleState} />
))}</div> : <p>Развёртывания не зарегистрированы.</p>}
</section>
<section>
<header><span className="infrastructure-eyebrow">SERVICES</span><strong>{hostServices.length}</strong></header>
{hostServices.length ? <div className="infrastructure-registry-list">{hostServices.map((service) => {
const edge = service.edgeRef ? workspace.edges.find((item) => item.edgeRef === service.edgeRef) : null;
const runtimeState = edge?.channel.runtimeState || service.health.state;
return <InfrastructureRegistryRow key={service.serviceInstanceRef} label={service.serviceRole} title={service.displayName} description={`${service.serviceKey} · ${edge?.displayName || "Edge не связан"}`} status={runtimeState} />;
})}</div> : <p>Сервисы не зарегистрированы.</p>}
</section>
</div>
</div>
) : null}
</>}
/>
);
})}
</ResourceGrid>
</ControlSection>
<ControlSection title="Service instances" count={topology.serviceInstances.length}>
<ResourceGrid empty="Service instances ещё не связаны с deployments.">
{topology.serviceInstances.map((service) => {
const edge = service.edgeRef
? workspace.edges.find((item) => item.edgeRef === service.edgeRef)
: null;
return (
<ResourceCard
key={service.serviceInstanceRef}
eyebrow={service.serviceRole}
title={service.displayName}
description={service.serviceKey}
status={edge?.channel.runtimeState || service.health.state}
meta={[
`service · ${service.lifecycleState}`,
`health · ${service.health.freshness}`,
...(edge ? [
`Edge · ${edge.displayName}`,
`Core↔Edge · ${edge.channel.runtimeState}`,
] : []),
service.deploymentRef,
]}
/>
);
})}
</ResourceGrid>
</ControlSection>
<ControlSection title="Deployments и endpoints" count={topology.deployments.length + topology.endpoints.length}>
<ResourceList empty="Deployments и endpoints отсутствуют.">
{topology.deployments.map((deployment) => (
<ResourceRow
key={deployment.deploymentRef}
title={deployment.displayName}
description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`}
status={deployment.lifecycleState}
trailing="deployment"
/>
))}
{topology.endpoints.map((endpoint) => (
<ResourceRow
key={endpoint.endpointRef}
title={endpoint.endpointKey}
description={endpoint.endpointUri}
status={endpoint.lifecycleState}
trailing={endpoint.purpose}
/>
))}
</ResourceList>
</ControlSection>
<ControlToolbar
copy="Asset — стабильный трайк или другой объект. B2 остаётся Device и связывается с Asset временным binding; замена трекера не меняет историю Asset."
actions={<>
{canManageAssets ? <Button size="compact" onClick={onCreateAsset}>Новый Asset</Button> : null}
{canManageBindings ? <Button size="compact" variant="primary" onClick={onCreateAssetBinding} disabled={!topology.assets.length || !workspace.devices.length}>Привязать tracker</Button> : null}
</>}
/>
<ControlSection title="Assets" count={topology.assets.length}>
<ResourceGrid empty="Assets проекта пока не созданы.">
{topology.assets.map((asset) => {
const activeBindings = topology.assetBindings.filter(
(binding) => binding.assetRef === asset.assetRef && !binding.validTo,
);
return (
<ResourceCard
key={asset.assetRef}
eyebrow="ASSET / STABLE IDENTITY"
title={asset.displayName}
description={asset.assetTypeRef}
status={asset.lifecycleState}
meta={[asset.assetKey, `${activeBindings.length} active device bindings`]}
/>
);
})}
</ResourceGrid>
</ControlSection>
<ControlSection title="Device ↔ Asset history" count={topology.assetBindings.length}>
<ResourceList empty="Tracker bindings отсутствуют.">
{topology.assetBindings.map((binding) => (
<ResourceRow
key={binding.assetBindingRef}
title={`${binding.deviceName}${binding.assetName}`}
description={`${binding.bindingKind} · ${binding.provenanceRef} · ${formatDate(binding.validFrom)}`}
status={binding.validTo ? "closed" : "active"}
trailing={!binding.validTo && canManageBindings ? (
<Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button>
) : formatDate(binding.validTo)}
/>
))}
</ResourceList>
</ControlSection>
<GlassSurface padding="md" tone="soft">
<p className="device-manager-card-copy">
Отсутствующее или просроченное health evidence отображается как unobserved, а не unreachable. Arbitrary WebSSH console отключена; будущая консоль потребует отдельной короткоживущей management session и break-glass аудита.
</p>
</GlassSurface>
</ControlStack>
</article>
);
})}
</div>
) : <div className="infrastructure-empty">VPS и хосты для проекта пока не зарегистрированы.</div>}
</section>
<section className="infrastructure-section infrastructure-assets-block">
<InfrastructureSectionHeading
eyebrow="DEVICE ASSETS"
title="Объекты и трекеры"
description="Стабильные объекты проекта и история привязанных к ним устройств."
status={russianCount(topology.assets.length, "объект", "объекта", "объектов")}
actions={<>
{canManageAssets ? <Button size="compact" onClick={onCreateAsset}>Новый Asset</Button> : null}
{canManageBindings ? <Button size="compact" variant="primary" onClick={onCreateAssetBinding} disabled={!topology.assets.length || !workspace.devices.length}>Привязать tracker</Button> : null}
</>}
/>
{topology.assets.length ? (
<div className="infrastructure-asset-grid">
{topology.assets.map((asset) => {
const activeBindings = topology.assetBindings.filter((binding) => binding.assetRef === asset.assetRef && !binding.validTo);
return (
<GlassSurface className="infrastructure-asset-card" padding="md" tone="soft" key={asset.assetRef}>
<div><span className="infrastructure-eyebrow">{asset.assetTypeRef}</span><strong>{asset.displayName}</strong><small>{asset.assetKey}</small></div>
<div><StatusBadge tone={statusTone(asset.lifecycleState)}>{asset.lifecycleState}</StatusBadge><small>{activeBindings.length} активных привязок</small></div>
</GlassSurface>
);
})}
</div>
) : <div className="infrastructure-empty">Объекты проекта пока не созданы.</div>}
{topology.assetBindings.length ? (
<div className="infrastructure-registry-list">
{topology.assetBindings.map((binding) => (
<div className="infrastructure-registry-row" key={binding.assetBindingRef}>
<div><span className="infrastructure-eyebrow">DEVICE ASSET</span><strong>{binding.deviceName} {binding.assetName}</strong><small>{binding.bindingKind} · {formatDate(binding.validFrom)}</small></div>
<div><StatusBadge tone={binding.validTo ? "neutral" : "success"}>{binding.validTo ? "Закрыта" : "Активна"}</StatusBadge>{!binding.validTo && canManageBindings ? <Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button> : null}</div>
</div>
))}
</div>
) : null}
</section>
</div>
);
}
@@ -665,6 +689,12 @@ function HostTelemetryWorkspace({
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const workspaceRef = useRef<HTMLDivElement>(null);
useEffect(() => {
resetApplicationPanelScroll(workspaceRef.current);
}, [host.hostRef]);
useEffect(() => {
let active = true;
const timer = window.setInterval(() => {
@@ -683,134 +713,262 @@ function HostTelemetryWorkspace({
const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent);
const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received");
const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent");
const cpuDomain = percentageTelemetryWindow(cpuHistory, 5);
const memoryDomain = percentageTelemetryWindow(memoryHistory, 4);
const runtimeServices = current?.services ?? [];
const networkInterfaces = current?.network.filter((item) => item.interface !== "lo") ?? [];
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>
<div className="infrastructure-system-workspace host-monitoring-workspace" ref={workspaceRef}>
<section className="infrastructure-workspace-lead">
<div>
<span className="infrastructure-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
<h2>{host.displayName}</h2>
<p>Аппаратный и процессинговый срез VPS. Метрики снимает host-agent; Device Core хранит только канонические наблюдения.</p>
<p>Аппаратный и процессинговый срез выбранного VPS. Последнее обновление: {formatDate(telemetry.observedAt)}.</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>
<div className="infrastructure-workspace-actions">
<StatusBadge tone={freshnessTone(telemetry.freshness)}>{freshnessLabel(telemetry.freshness)}</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)} />
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
</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-monitoring-series-grid" aria-label="Аппаратная телеметрия VPS">
<HostTelemetrySeries label="CPU" value={formatPercent(current?.cpu.usagePercent)} resource={formatLoad(current?.cpu)} values={cpuHistory} domain={cpuDomain} />
<HostTelemetrySeries label="RAM" value={formatPercent(current?.memory.usedPercent)} resource={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} values={memoryHistory} domain={memoryDomain} />
<HostTelemetrySeries label="NETWORK RX" value={formatRate(networkRate.received)} resource={networkRate.received == null ? "нет данных" : "входящий трафик"} values={receiveHistory} />
<HostTelemetrySeries label="NETWORK TX" value={formatRate(networkRate.sent)} resource={networkRate.sent == null ? "нет данных" : "исходящий трафик"} values={sendHistory} />
</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>
<GlassSurface className="host-monitoring-hardware" padding="lg">
<InfrastructureSectionHeading
eyebrow="HARDWARE"
title={current?.hardware.hostname ?? host.hostKey}
description={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || "Аппаратный профиль недоступен"}
status={telemetry.state}
statusTone={freshnessTone(telemetry.freshness)}
/>
<div className="host-monitoring-hardware-facts">
<dl>
<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)} />
</dl>
<dl>
<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)} />
</dl>
</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 className="host-monitoring-disk-list">
{(current?.disks ?? []).map((disk, index) => (
<div key={`${disk.device}:${disk.mount}:${index}`}>
<span>Диск {disk.mount ?? disk.device ?? "—"}</span>
<strong>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</strong>
</div>
))}
{!runtimeServices.length ? <div className="device-manager-panel-empty">Состояние сервисов ещё не поступило.</div> : null}
{!current?.disks.length ? <div className="infrastructure-empty">Данные о дисках ещё не поступили.</div> : null}
</div>
</GlassSurface>
<section className="infrastructure-section">
<InfrastructureSectionHeading
eyebrow="NETWORK"
title="Сетевые интерфейсы"
description="Счётчики трафика и ошибок по активным интерфейсам VPS."
status={russianCount(networkInterfaces.length, "интерфейс", "интерфейса", "интерфейсов")}
/>
{networkInterfaces.length ? (
<div className="host-monitoring-network-grid">
{networkInterfaces.map((item, index) => (
<GlassSurface className="host-monitoring-network-card" padding="md" tone="soft" key={`${item.interface}:${index}`}>
<header><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></header>
<dl>
<div><dt>Получено</dt><dd>{formatMetricBytes(item.bytesReceived)}</dd></div>
<div><dt>Отправлено</dt><dd>{formatMetricBytes(item.bytesSent)}</dd></div>
<div><dt>Ошибки RX / TX</dt><dd>{formatNullable(item.errorsReceived)} / {formatNullable(item.errorsSent)}</dd></div>
<div><dt>Потери RX / TX</dt><dd>{formatNullable(item.droppedReceived)} / {formatNullable(item.droppedSent)}</dd></div>
</dl>
</GlassSurface>
))}
</div>
) : <div className="infrastructure-empty">Сетевые счётчики ещё не поступили.</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 className="infrastructure-section">
<InfrastructureSectionHeading
eyebrow="PROCESSING RUNTIME"
title="Сервисы VPS"
description={`Состояние systemd-юнитов; с хостом связано ${russianCount(services.length, "сервис", "сервиса", "сервисов")} Device Core.`}
status={russianCount(runtimeServices.length, "юнит", "юнита", "юнитов")}
statusTone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : runtimeServices.length ? "success" : "warning"}
/>
{runtimeServices.length ? (
<div className="infrastructure-runtime-grid">
{runtimeServices.map((service, index) => (
<GlassSurface className="host-monitoring-runtime-card" padding="md" tone="soft" key={`${service.name}:${index}`}>
<header>
<div><span className="infrastructure-eyebrow">SYSTEMD UNIT</span><h3>{service.name ?? "systemd unit"}</h3><code>{service.subState ?? "—"}</code></div>
<StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge>
</header>
<dl>
<div><dt>Load</dt><dd>{service.loadState ?? "—"}</dd></div>
<div><dt>Память</dt><dd>{formatMetricBytes(service.memoryBytes)}</dd></div>
<div><dt>Перезапуски</dt><dd>{formatNullable(service.restarts)}</dd></div>
<div><dt>PID</dt><dd>{formatNullable(service.pid)}</dd></div>
</dl>
</GlassSurface>
))}
</div>
) : <div className="infrastructure-empty">Состояние сервисов ещё не поступило.</div>}
</section>
</div>
);
}
function TelemetryMetricCard({ label, value, points, detail }: { label: string; value: string; points: Array<number | null>; detail: string }) {
function InfrastructureCount({ label, value, detail }: { label: string; value: number; detail: string }) {
return (
<div className="host-telemetry-metric">
<span>{label}</span>
<strong>{value}</strong>
<Sparkline values={points} />
<small>{detail}</small>
<div className="infrastructure-count-card">
<span>{label}</span><strong>{value}</strong><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)}`;
function InfrastructureSectionHeading({ eyebrow, title, description, status, statusTone: tone = "neutral", actions = null }: {
eyebrow: string;
title: string;
description: string;
status: string;
statusTone?: "neutral" | "success" | "warning" | "danger";
actions?: ReactNode;
}) {
return (
<header className="infrastructure-section-heading">
<div><span className="infrastructure-eyebrow">{eyebrow}</span><h3>{title}</h3><p>{description}</p></div>
<div className="infrastructure-section-actions"><StatusBadge tone={tone}>{status}</StatusBadge>{actions}</div>
</header>
);
}
function InfrastructureRegistryRow({ label, title, description, status }: { label: string; title: string; description: string; status: string }) {
return (
<div className="infrastructure-registry-row">
<div><span className="infrastructure-eyebrow">{label}</span><strong>{title}</strong><small>{description}</small></div>
<StatusBadge tone={statusTone(status)}>{status}</StatusBadge>
</div>
);
}
type TelemetryLineDomain = {
minimum: number;
maximum: number;
label?: string;
};
function HostTelemetrySeries({ label, values, value, resource, domain }: { label: string; values: Array<number | null>; value: string; resource?: string | null; domain?: TelemetryLineDomain | null }) {
const points = telemetryLinePoints(values, domain);
return (
<div className="host-monitoring-series">
<div>
<span className="host-monitoring-series__label"><span>{label}</span>{resource ? <small>{resource}</small> : null}</span>
<strong>{value}</strong>
</div>
<svg viewBox="0 0 100 38" preserveAspectRatio="none" role="img" aria-label={`${label}: ${value}`}>
<path d="M0 37 H100" />
{points ? <polyline points={points} /> : null}
</svg>
{domain?.label ? <small className="host-monitoring-series__range">{domain.label}</small> : null}
</div>
);
}
function telemetryLinePoints(values: Array<number | null>, domain?: TelemetryLineDomain | null) {
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
if (!finite.length) return "";
const minimum = domain?.minimum ?? 0;
const maximum = Math.max(domain?.maximum ?? Math.max(...finite, 1), minimum + Number.EPSILON);
const range = maximum - minimum;
const denominator = Math.max(1, values.length - 1);
return values.flatMap((value, index) => {
if (value === null || !Number.isFinite(value)) return [];
const x = index / denominator * 100;
const normalized = Math.max(minimum, Math.min(maximum, value));
const y = 36 - (normalized - minimum) / range * 34;
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 percentageTelemetryWindow(values: Array<number | null>, minimumSpan: number): TelemetryLineDomain | null {
const finite = values
.filter((value): value is number => value !== null && Number.isFinite(value))
.map((value) => Math.max(0, Math.min(100, value)));
if (!finite.length) return null;
const observedMinimum = Math.min(...finite);
const observedMaximum = Math.max(...finite);
const padding = Math.max(0.5, (observedMaximum - observedMinimum) * 0.15);
let minimum = observedMinimum - padding;
let maximum = observedMaximum + padding;
if (maximum - minimum < minimumSpan) {
const center = (observedMinimum + observedMaximum) / 2;
minimum = center - minimumSpan / 2;
maximum = center + minimumSpan / 2;
}
if (minimum < 0) {
maximum = Math.min(100, maximum - minimum);
minimum = 0;
}
if (maximum > 100) {
minimum = Math.max(0, minimum - (maximum - 100));
maximum = 100;
}
minimum = Math.floor(minimum * 10) / 10;
maximum = Math.ceil(maximum * 10) / 10;
return {
minimum,
maximum,
label: `шкала ${formatScalePercent(minimum)}${formatScalePercent(maximum)}`,
};
}
function formatScalePercent(value: number) {
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}%`;
}
function TelemetryFact({ label, value }: { label: string; value: string | null | undefined }) {
return <div><small>{label}</small><strong>{value || "—"}</strong></div>;
return <div><dt>{label}</dt><dd>{value || "—"}</dd></div>;
}
function freshnessTone(freshness: "fresh" | "stale" | "missing"): "success" | "warning" | "danger" {
if (freshness === "fresh") return "success";
if (freshness === "stale") return "warning";
return "danger";
}
function freshnessLabel(freshness: "fresh" | "stale" | "missing") {
if (freshness === "fresh") return "Свежие данные";
if (freshness === "stale") return "Данные устарели";
return "Нет данных";
}
function russianCount(value: number, one: string, few: string, many: string) {
const absolute = Math.abs(value) % 100;
const last = absolute % 10;
const form = absolute > 10 && absolute < 20 ? many : last === 1 ? one : last > 1 && last < 5 ? few : many;
return `${new Intl.NumberFormat("ru-RU").format(value)} ${form}`;
}
function resetApplicationPanelScroll(element: HTMLElement | null) {
const scroller = element?.closest<HTMLElement>(".nodedc-application-panel__body");
if (scroller) scroller.scrollTop = 0;
}
function calculateNetworkRate(history: InfrastructureHostView["telemetry"]["history"]) {
@@ -821,9 +979,10 @@ function calculateNetworkRate(history: InfrastructureHostView["telemetry"]["hist
if (!Number.isFinite(seconds) || seconds <= 0) return { received: null, sent: null };
const previousTotals = networkTotals(previous.network);
const latestTotals = networkTotals(latest.network);
if (!previousTotals || !latestTotals) return { received: null, sent: null };
return {
received: nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
sent: nonNegativeRate(latestTotals.sent - previousTotals.sent, seconds),
received: latestTotals.received == null || previousTotals.received == null ? null : nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
sent: latestTotals.sent == null || previousTotals.sent == null ? null : nonNegativeRate(latestTotals.sent - previousTotals.sent, seconds),
};
}
@@ -834,15 +993,20 @@ function calculateNetworkRateHistory(history: InfrastructureHostView["telemetry"
if (seconds <= 0) return null;
const currentTotals = networkTotals(sample.network);
const previousTotals = networkTotals(previous.network);
if (!currentTotals || !previousTotals || currentTotals[direction] == null || previousTotals[direction] == null) return null;
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 });
const counters = network.filter((item) => item.interface !== "lo" && (item.bytesReceived != null || item.bytesSent != null));
if (!counters.length) return null;
const receivedCounters = counters.map((item) => item.bytesReceived).filter((value): value is number => value !== null && Number.isFinite(value));
const sentCounters = counters.map((item) => item.bytesSent).filter((value): value is number => value !== null && Number.isFinite(value));
return {
received: receivedCounters.length ? receivedCounters.reduce((total, value) => total + value, 0) : null,
sent: sentCounters.length ? sentCounters.reduce((total, value) => total + value, 0) : null,
};
}
function nonNegativeRate(bytes: number, seconds: number) {
+628 -179
View File
@@ -127,231 +127,655 @@
padding-right: 3px;
}
.host-telemetry-workspace {
.infrastructure-system-workspace {
--infrastructure-panel-soft: var(--nodedc-canvas-soft);
--infrastructure-accent-soft: color-mix(in srgb, var(--nodedc-text-primary) 4.5%, transparent);
--infrastructure-hairline: color-mix(in srgb, var(--nodedc-text-primary) 8%, transparent);
display: grid;
gap: 18px;
min-width: 0;
gap: 1rem;
padding-bottom: 1rem;
}
.host-telemetry-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 14px;
.infrastructure-overview-block,
.infrastructure-hosts-block,
.infrastructure-assets-block {
min-width: 0;
border-radius: var(--nodedc-radius-card);
background: var(--infrastructure-panel-soft);
padding: 1rem;
}
.host-telemetry-header__copy {
display: grid;
gap: 3px;
.infrastructure-system-workspace .nodedc-status {
min-height: auto;
justify-content: flex-start;
gap: 0.42rem;
border-radius: 0;
background: transparent;
color: var(--nodedc-text-primary);
padding: 0;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.host-telemetry-header__copy small,
.host-telemetry-section__heading small,
.host-telemetry-evidence small {
color: var(--nodedc-text-tertiary);
.infrastructure-system-workspace .nodedc-status::before {
width: 0.42rem;
height: 0.42rem;
flex: 0 0 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.infrastructure-system-workspace .nodedc-status[data-tone="success"],
.infrastructure-system-workspace .nodedc-status[data-tone="warning"],
.infrastructure-system-workspace .nodedc-status[data-tone="danger"],
.infrastructure-system-workspace .nodedc-status[data-tone="accent"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.infrastructure-system-workspace .nodedc-status[data-tone="success"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.infrastructure-system-workspace .nodedc-status[data-tone="warning"]::before {
background: rgb(var(--nodedc-warning-rgb));
}
.infrastructure-system-workspace .nodedc-status[data-tone="danger"]::before {
background: rgb(var(--nodedc-danger-rgb));
}
.infrastructure-system-workspace .nodedc-status[data-tone="accent"]::before {
background: var(--nodedc-text-primary);
}
.infrastructure-eyebrow {
display: block;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
font-weight: 780;
font-weight: 820;
letter-spacing: 0.12em;
line-height: 1.2;
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;
.infrastructure-workspace-lead,
.infrastructure-section-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 1.2rem;
}
.host-telemetry-header__copy h2 {
font-size: 1.4rem;
.infrastructure-workspace-lead {
padding: 0;
}
.infrastructure-workspace-lead h2,
.infrastructure-section-heading h3 {
margin: 0.4rem 0 0;
color: var(--nodedc-text-primary);
letter-spacing: -0.035em;
}
.host-telemetry-header__copy p,
.host-telemetry-section__heading p {
color: var(--nodedc-text-tertiary);
font-size: 0.76rem;
.infrastructure-workspace-lead h2 {
font-size: 1.42rem;
}
.infrastructure-section-heading h3 {
font-size: 1.02rem;
}
.infrastructure-workspace-lead p,
.infrastructure-section-heading p {
max-width: 48rem;
margin: 0.42rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.48;
}
.infrastructure-workspace-actions,
.infrastructure-section-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.55rem;
}
.infrastructure-overview-grid,
.host-monitoring-series-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.infrastructure-overview-block .infrastructure-overview-grid {
margin-top: 1rem;
}
.infrastructure-count-card,
.host-monitoring-series {
min-width: 0;
border-radius: var(--nodedc-radius-card);
background: var(--infrastructure-panel-soft);
}
.infrastructure-count-card {
display: grid;
align-content: center;
gap: 0.3rem;
min-height: 5.4rem;
padding: 0.82rem;
background: var(--infrastructure-accent-soft);
}
.infrastructure-count-card span,
.infrastructure-count-card small,
.host-monitoring-series span {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.infrastructure-count-card strong,
.host-monitoring-series strong {
color: var(--nodedc-text-primary);
font-size: 1.14rem;
font-weight: 720;
letter-spacing: -0.035em;
white-space: nowrap;
}
.infrastructure-count-card small {
overflow: hidden;
font-size: 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-section {
display: grid;
min-width: 0;
gap: 0.75rem;
}
.infrastructure-section--separated {
padding-top: 0.25rem;
border-top: 1px solid var(--infrastructure-hairline);
}
.infrastructure-runtime-grid,
.infrastructure-asset-grid,
.host-monitoring-network-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.infrastructure-host-list {
display: grid;
min-width: 0;
gap: 0.5rem;
}
.infrastructure-host-card,
.infrastructure-runtime-card,
.host-monitoring-runtime-card,
.host-monitoring-network-card {
display: grid;
min-width: 0;
gap: 0.9rem;
}
.infrastructure-host-card {
overflow: hidden;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-accent-soft);
}
.infrastructure-host-card__summary,
.infrastructure-runtime-card > header,
.host-monitoring-runtime-card > header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.infrastructure-host-card__summary {
min-height: 4.35rem;
padding: 0.62rem 0.72rem;
}
.infrastructure-host-card__identity {
min-width: 0;
}
.infrastructure-host-card__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.6rem;
}
.infrastructure-host-card__freshness {
width: 0.48rem;
height: 0.48rem;
flex: 0 0 0.48rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.infrastructure-host-card__freshness[data-freshness="fresh"] {
background: rgb(var(--nodedc-success-rgb));
}
.infrastructure-host-card__freshness[data-freshness="stale"] {
background: rgb(var(--nodedc-warning-rgb));
}
.infrastructure-host-card__toggle svg {
transition: transform 160ms ease;
}
.infrastructure-host-card[data-expanded="true"] .infrastructure-host-card__toggle svg {
transform: rotate(180deg);
}
.infrastructure-host-card h4,
.infrastructure-runtime-card h3,
.host-monitoring-runtime-card h3 {
margin: 0.38rem 0 0.18rem;
color: var(--nodedc-text-primary);
font-size: 0.9rem;
letter-spacing: -0.025em;
}
.infrastructure-host-card code,
.infrastructure-runtime-card code,
.host-monitoring-runtime-card code {
display: block;
max-width: 23rem;
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.56rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-host-card__details {
display: grid;
min-width: 0;
gap: 0.8rem;
padding: 0 0.72rem 0.72rem;
}
.infrastructure-host-card__facts {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
padding: 0.72rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.infrastructure-host-card__facts > div {
display: grid;
min-width: 0;
gap: 0.22rem;
padding-right: 0.65rem;
}
.infrastructure-host-card__facts dt {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.infrastructure-host-card__facts dd {
overflow: hidden;
margin: 0;
color: var(--nodedc-text-primary);
font-size: 0.67rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-host-relations {
display: grid;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.infrastructure-host-relations > section {
display: grid;
min-width: 0;
align-content: start;
gap: 0.55rem;
padding: 0.72rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.infrastructure-host-relations > section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.infrastructure-host-relations > section > header strong {
color: var(--nodedc-text-primary);
font-size: 0.68rem;
}
.infrastructure-host-relations > section > p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
line-height: 1.45;
}
.infrastructure-host-relations .infrastructure-registry-row {
padding: 0.58rem 0.62rem;
}
.infrastructure-host-relations .infrastructure-registry-row .nodedc-status {
font-size: 0;
}
.infrastructure-host-relations .infrastructure-registry-row .nodedc-status::before {
margin: 0;
}
.infrastructure-host-card dl,
.infrastructure-runtime-card dl,
.host-monitoring-runtime-card dl,
.host-monitoring-network-card dl {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
gap: 0;
}
.infrastructure-host-card dl > div,
.infrastructure-runtime-card dl > div,
.host-monitoring-runtime-card dl > div,
.host-monitoring-network-card dl > div {
display: grid;
min-width: 0;
gap: 0.22rem;
padding-right: 0.65rem;
}
.infrastructure-host-card dt,
.infrastructure-runtime-card dt,
.host-monitoring-runtime-card dt,
.host-monitoring-network-card dt,
.host-monitoring-hardware-facts dt {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.infrastructure-host-card dd,
.infrastructure-runtime-card dd,
.host-monitoring-runtime-card dd,
.host-monitoring-network-card dd,
.host-monitoring-hardware-facts dd {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--nodedc-text-primary);
font-size: 0.67rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-host-card footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.infrastructure-host-card footer > span {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-registry-list {
display: grid;
min-width: 0;
gap: 0.48rem;
}
.infrastructure-registry-row,
.infrastructure-asset-card {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
padding: 0.72rem 0.8rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.infrastructure-registry-row > div,
.infrastructure-asset-card > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.infrastructure-registry-row > div:last-child,
.infrastructure-asset-card > div:last-child {
flex: 0 0 auto;
justify-items: end;
}
.infrastructure-registry-row strong,
.infrastructure-asset-card strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-registry-row small,
.infrastructure-asset-card small {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-empty {
padding: 0.8rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
color: var(--nodedc-text-muted);
font-size: 0.65rem;
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;
.host-monitoring-series {
overflow: hidden;
padding: 0.82rem 0.82rem 0.32rem;
}
.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 {
.host-monitoring-series > div {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
gap: 0.75rem;
}
.host-telemetry-section__heading > div {
.host-monitoring-series__label {
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);
gap: 0.16rem;
}
.host-telemetry-facts strong {
.host-monitoring-series__label small {
overflow: hidden;
font-size: 0.78rem;
font-weight: 590;
max-width: 11rem;
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-telemetry-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
.host-monitoring-series svg {
display: block;
width: 100%;
height: 2.45rem;
margin-top: 0.45rem;
}
.host-telemetry-list,
.host-telemetry-service-grid {
display: grid;
gap: 7px;
.host-monitoring-series path {
fill: none;
stroke: var(--infrastructure-hairline);
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
.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-monitoring-series polyline {
fill: none;
stroke: var(--nodedc-text-primary);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.15;
vector-effect: non-scaling-stroke;
}
.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;
.host-monitoring-series__range {
display: block;
margin-top: -0.1rem;
color: var(--nodedc-text-secondary);
font-size: 0.5rem;
line-height: 1.2;
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 {
.host-monitoring-hardware {
display: grid;
gap: 5px;
min-width: 0;
gap: 1rem;
}
.host-telemetry-evidence strong {
.host-monitoring-hardware-facts {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.host-monitoring-hardware-facts dl {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
gap: 0;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.host-monitoring-hardware-facts dl > div {
display: grid;
min-width: 0;
gap: 0.22rem;
padding: 0.75rem;
}
.host-monitoring-disk-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.55rem;
}
.host-monitoring-disk-list > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.62rem 0.72rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-accent-soft);
}
.host-monitoring-disk-list span {
color: var(--nodedc-text-muted);
font-size: 0.61rem;
}
.host-monitoring-disk-list strong {
color: var(--nodedc-text-primary);
font-size: 0.64rem;
}
.host-monitoring-network-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.host-monitoring-network-card header strong,
.host-monitoring-network-card header small {
display: block;
overflow: hidden;
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-monitoring-network-card header strong {
color: var(--nodedc-text-primary);
font-size: 0.71rem;
}
.host-monitoring-network-card header small {
margin-top: 0.25rem;
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.host-monitoring-network-card dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 1100px) {
.infrastructure-overview-grid,
.host-monitoring-series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.host-monitoring-network-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.infrastructure-host-relations {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.device-control-resource-grid,
.device-control-policy-grid {
@@ -394,22 +818,47 @@
justify-content: flex-start;
}
.host-telemetry-header {
grid-template-columns: auto minmax(0, 1fr);
.infrastructure-workspace-lead,
.infrastructure-section-heading {
display: grid;
}
.host-telemetry-header__actions {
grid-column: 2;
justify-content: flex-start;
.infrastructure-workspace-actions,
.infrastructure-section-actions {
justify-content: space-between;
}
.host-telemetry-metric-grid,
.host-telemetry-facts,
.host-telemetry-service-grid,
.host-telemetry-evidence,
.host-telemetry-split {
.infrastructure-overview-grid,
.host-monitoring-series-grid,
.infrastructure-host-list,
.infrastructure-runtime-grid,
.infrastructure-asset-grid,
.host-monitoring-hardware-facts,
.host-monitoring-network-grid {
grid-template-columns: 1fr;
}
.infrastructure-host-card__facts,
.infrastructure-runtime-card dl,
.host-monitoring-runtime-card dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.infrastructure-host-card__summary,
.infrastructure-registry-row,
.infrastructure-asset-card {
align-items: flex-start;
}
.infrastructure-host-card__summary {
display: grid;
}
.infrastructure-host-card__actions {
width: 100%;
justify-content: flex-end;
}
}
html,