feat(device-core): ship host telemetry and scalable VPS inventory
This commit is contained in:
@@ -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"'));
|
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", () => {
|
test("legacy single teaser migrates into the environment media playlist", () => {
|
||||||
const environment = normalizeEnvironmentPresentation({
|
const environment = normalizeEnvironmentPresentation({
|
||||||
defaultTeaser: {
|
defaultTeaser: {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
@@ -497,10 +497,24 @@ function HostsView({
|
|||||||
onError: (reason: unknown) => void;
|
onError: (reason: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedHostRef, setSelectedHostRef] = useState<string | null>(null);
|
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 topology = workspace.ontology;
|
||||||
const selectedHost = selectedHostRef
|
const selectedHost = selectedHostRef
|
||||||
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
|
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
|
||||||
: 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) {
|
if (selectedHost) {
|
||||||
return (
|
return (
|
||||||
<HostTelemetryWorkspace
|
<HostTelemetryWorkspace
|
||||||
@@ -513,142 +527,152 @@ function HostsView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ControlStack>
|
<div className="infrastructure-system-workspace" ref={inventoryRef}>
|
||||||
<ControlToolbar
|
<section className="infrastructure-overview-block">
|
||||||
copy={`Канонический ontology catalog ${topology.ontology.catalogHash}: Host, endpoint, deployment и service instance существуют отдельно. Edge — опциональная роль service instance; credentials остаются server-side.`}
|
<div className="infrastructure-workspace-lead">
|
||||||
actions={canManageInfrastructure ? <>
|
<div>
|
||||||
<Button size="compact" onClick={onCreateHost}>Новый VPS</Button>
|
<span className="infrastructure-eyebrow">ИНФРАСТРУКТУРА / VPS И ХОСТЫ</span>
|
||||||
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
|
<h2>VPS и хосты</h2>
|
||||||
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
|
<p>Вычислительные узлы проекта, их подключения и запущенные сервисы.</p>
|
||||||
<Button size="compact" variant="primary" onClick={onCreateService} disabled={!topology.deployments.length}>Service</Button>
|
</div>
|
||||||
</> : null}
|
{canManageInfrastructure ? (
|
||||||
/>
|
<div className="infrastructure-workspace-actions">
|
||||||
<ControlSection title="VPS и хосты" count={topology.hosts.length}>
|
<Button size="compact" variant="primary" onClick={onCreateHost}>Новый VPS</Button>
|
||||||
<ResourceGrid empty="VPS и хосты для проекта пока не зарегистрированы.">
|
</div>
|
||||||
{topology.hosts.map((host) => {
|
) : null}
|
||||||
const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
|
</div>
|
||||||
const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
|
<div className="infrastructure-overview-grid" aria-label="Сводка инфраструктуры">
|
||||||
return (
|
<InfrastructureCount label="ХОСТЫ" value={topology.hosts.length} detail={`${topology.hosts.filter((host) => host.telemetry.freshness === "fresh").length} со свежими данными`} />
|
||||||
<ResourceCard
|
<InfrastructureCount label="ENDPOINTS" value={topology.endpoints.length} detail="точки подключения" />
|
||||||
key={host.hostRef}
|
<InfrastructureCount label="DEPLOYMENTS" value={topology.deployments.length} detail="развёрнутые контуры" />
|
||||||
eyebrow="INFRASTRUCTURE / HOST"
|
<InfrastructureCount label="SERVICES" value={topology.serviceInstances.length} detail="экземпляры сервисов" />
|
||||||
title={host.displayName}
|
</div>
|
||||||
description={host.externalRef || host.hostKey}
|
</section>
|
||||||
status={host.health.state}
|
|
||||||
meta={[
|
<section className="infrastructure-section infrastructure-hosts-block">
|
||||||
`lifecycle · ${host.lifecycleState}`,
|
<InfrastructureSectionHeading
|
||||||
`health · ${host.health.freshness}`,
|
eyebrow="ВЫЧИСЛИТЕЛЬНЫЕ УЗЛЫ"
|
||||||
...(host.providerRef ? [`provider · ${host.providerRef}`] : []),
|
title="Зарегистрированные хосты"
|
||||||
`${hostEndpoints.length} endpoints · ${hostServices.length} services`,
|
description="Компактный список VPS. Раскройте только тот хост, связи которого нужно посмотреть."
|
||||||
`management · ${host.managementCredentialConfigured ? "configured" : "unconfigured"}`,
|
status={russianCount(topology.hosts.length, "хост", "хоста", "хостов")}
|
||||||
]}
|
actions={canManageInfrastructure ? <>
|
||||||
action={<>
|
<Button size="compact" onClick={onRecordHealth}>Наблюдение</Button>
|
||||||
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
|
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
|
||||||
{canManageInfrastructure ? (
|
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
|
||||||
<Button size="compact" onClick={onRecordHealth}>Health evidence</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}
|
) : null}
|
||||||
</>}
|
</article>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</ResourceGrid>
|
) : <div className="infrastructure-empty">VPS и хосты для проекта пока не зарегистрированы.</div>}
|
||||||
</ControlSection>
|
</section>
|
||||||
<ControlSection title="Service instances" count={topology.serviceInstances.length}>
|
|
||||||
<ResourceGrid empty="Service instances ещё не связаны с deployments.">
|
<section className="infrastructure-section infrastructure-assets-block">
|
||||||
{topology.serviceInstances.map((service) => {
|
<InfrastructureSectionHeading
|
||||||
const edge = service.edgeRef
|
eyebrow="DEVICE ASSETS"
|
||||||
? workspace.edges.find((item) => item.edgeRef === service.edgeRef)
|
title="Объекты и трекеры"
|
||||||
: null;
|
description="Стабильные объекты проекта и история привязанных к ним устройств."
|
||||||
return (
|
status={russianCount(topology.assets.length, "объект", "объекта", "объектов")}
|
||||||
<ResourceCard
|
actions={<>
|
||||||
key={service.serviceInstanceRef}
|
{canManageAssets ? <Button size="compact" onClick={onCreateAsset}>Новый Asset</Button> : null}
|
||||||
eyebrow={service.serviceRole}
|
{canManageBindings ? <Button size="compact" variant="primary" onClick={onCreateAssetBinding} disabled={!topology.assets.length || !workspace.devices.length}>Привязать tracker</Button> : null}
|
||||||
title={service.displayName}
|
</>}
|
||||||
description={service.serviceKey}
|
/>
|
||||||
status={edge?.channel.runtimeState || service.health.state}
|
{topology.assets.length ? (
|
||||||
meta={[
|
<div className="infrastructure-asset-grid">
|
||||||
`service · ${service.lifecycleState}`,
|
{topology.assets.map((asset) => {
|
||||||
`health · ${service.health.freshness}`,
|
const activeBindings = topology.assetBindings.filter((binding) => binding.assetRef === asset.assetRef && !binding.validTo);
|
||||||
...(edge ? [
|
return (
|
||||||
`Edge · ${edge.displayName}`,
|
<GlassSurface className="infrastructure-asset-card" padding="md" tone="soft" key={asset.assetRef}>
|
||||||
`Core↔Edge · ${edge.channel.runtimeState}`,
|
<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>
|
||||||
service.deploymentRef,
|
</GlassSurface>
|
||||||
]}
|
);
|
||||||
/>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
) : <div className="infrastructure-empty">Объекты проекта пока не созданы.</div>}
|
||||||
</ResourceGrid>
|
{topology.assetBindings.length ? (
|
||||||
</ControlSection>
|
<div className="infrastructure-registry-list">
|
||||||
<ControlSection title="Deployments и endpoints" count={topology.deployments.length + topology.endpoints.length}>
|
{topology.assetBindings.map((binding) => (
|
||||||
<ResourceList empty="Deployments и endpoints отсутствуют.">
|
<div className="infrastructure-registry-row" key={binding.assetBindingRef}>
|
||||||
{topology.deployments.map((deployment) => (
|
<div><span className="infrastructure-eyebrow">DEVICE ↔ ASSET</span><strong>{binding.deviceName} → {binding.assetName}</strong><small>{binding.bindingKind} · {formatDate(binding.validFrom)}</small></div>
|
||||||
<ResourceRow
|
<div><StatusBadge tone={binding.validTo ? "neutral" : "success"}>{binding.validTo ? "Закрыта" : "Активна"}</StatusBadge>{!binding.validTo && canManageBindings ? <Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button> : null}</div>
|
||||||
key={deployment.deploymentRef}
|
</div>
|
||||||
title={deployment.displayName}
|
))}
|
||||||
description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`}
|
</div>
|
||||||
status={deployment.lifecycleState}
|
) : null}
|
||||||
trailing="deployment"
|
</section>
|
||||||
/>
|
</div>
|
||||||
))}
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +689,12 @@ function HostTelemetryWorkspace({
|
|||||||
onPoll: () => Promise<void>;
|
onPoll: () => Promise<void>;
|
||||||
onError: (reason: unknown) => void;
|
onError: (reason: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetApplicationPanelScroll(workspaceRef.current);
|
||||||
|
}, [host.hostRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -683,134 +713,262 @@ function HostTelemetryWorkspace({
|
|||||||
const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent);
|
const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent);
|
||||||
const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received");
|
const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received");
|
||||||
const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent");
|
const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent");
|
||||||
|
const cpuDomain = percentageTelemetryWindow(cpuHistory, 5);
|
||||||
|
const memoryDomain = percentageTelemetryWindow(memoryHistory, 4);
|
||||||
const runtimeServices = current?.services ?? [];
|
const runtimeServices = current?.services ?? [];
|
||||||
|
const networkInterfaces = current?.network.filter((item) => item.interface !== "lo") ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="host-telemetry-workspace">
|
<div className="infrastructure-system-workspace host-monitoring-workspace" ref={workspaceRef}>
|
||||||
<header className="host-telemetry-header">
|
<section className="infrastructure-workspace-lead">
|
||||||
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
|
<div>
|
||||||
<Icon name="chevron-left" size={18} />
|
<span className="infrastructure-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
|
||||||
</IconButton>
|
|
||||||
<div className="host-telemetry-header__copy">
|
|
||||||
<small>СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</small>
|
|
||||||
<h2>{host.displayName}</h2>
|
<h2>{host.displayName}</h2>
|
||||||
<p>Аппаратный и процессинговый срез VPS. Метрики снимает host-agent; Device Core хранит только канонические наблюдения.</p>
|
<p>Аппаратный и процессинговый срез выбранного VPS. Последнее обновление: {formatDate(telemetry.observedAt)}.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="host-telemetry-header__actions">
|
<div className="infrastructure-workspace-actions">
|
||||||
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : telemetry.freshness === "stale" ? "warning" : undefined}>
|
<StatusBadge tone={freshnessTone(telemetry.freshness)}>{freshnessLabel(telemetry.freshness)}</StatusBadge>
|
||||||
{telemetry.freshness === "fresh" ? "Свежие данные" : telemetry.freshness === "stale" ? "Данные устарели" : "Нет данных"}
|
|
||||||
</StatusBadge>
|
|
||||||
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
|
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
|
||||||
<Icon name="refresh" size={17} />
|
<Icon name="refresh" size={17} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
|
||||||
</header>
|
<Icon name="chevron-left" size={18} />
|
||||||
|
</IconButton>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="host-telemetry-split">
|
<section className="host-monitoring-series-grid" aria-label="Аппаратная телеметрия VPS">
|
||||||
<section className="host-telemetry-section">
|
<HostTelemetrySeries label="CPU" value={formatPercent(current?.cpu.usagePercent)} resource={formatLoad(current?.cpu)} values={cpuHistory} domain={cpuDomain} />
|
||||||
<div className="host-telemetry-section__heading"><div><small>STORAGE</small><h3>Файловые системы</h3></div><StatusBadge>{current?.disks.length ?? 0}</StatusBadge></div>
|
<HostTelemetrySeries label="RAM" value={formatPercent(current?.memory.usedPercent)} resource={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} values={memoryHistory} domain={memoryDomain} />
|
||||||
<div className="host-telemetry-list">
|
<HostTelemetrySeries label="NETWORK RX" value={formatRate(networkRate.received)} resource={networkRate.received == null ? "нет данных" : "входящий трафик"} values={receiveHistory} />
|
||||||
{(current?.disks ?? []).map((disk, index) => (
|
<HostTelemetrySeries label="NETWORK TX" value={formatRate(networkRate.sent)} resource={networkRate.sent == null ? "нет данных" : "исходящий трафик"} values={sendHistory} />
|
||||||
<div className="host-telemetry-list__row" key={`${disk.device}:${disk.mount}:${index}`}>
|
</section>
|
||||||
<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">
|
<GlassSurface className="host-monitoring-hardware" padding="lg">
|
||||||
<div className="host-telemetry-section__heading"><div><small>NETWORK</small><h3>Сетевые интерфейсы</h3></div><StatusBadge>{current?.network.length ?? 0}</StatusBadge></div>
|
<InfrastructureSectionHeading
|
||||||
<div className="host-telemetry-list">
|
eyebrow="HARDWARE"
|
||||||
{(current?.network ?? []).filter((item) => item.interface !== "lo").map((item, index) => (
|
title={current?.hardware.hostname ?? host.hostKey}
|
||||||
<div className="host-telemetry-list__row" key={`${item.interface}:${index}`}>
|
description={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || "Аппаратный профиль недоступен"}
|
||||||
<span><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></span>
|
status={telemetry.state}
|
||||||
<span><strong>↓ {formatMetricBytes(item.bytesReceived)}</strong><small>↑ {formatMetricBytes(item.bytesSent)}</small></span>
|
statusTone={freshnessTone(telemetry.freshness)}
|
||||||
</div>
|
/>
|
||||||
))}
|
<div className="host-monitoring-hardware-facts">
|
||||||
{!current?.network.filter((item) => item.interface !== "lo").length ? <div className="device-manager-panel-empty">Сетевые счётчики ещё не поступили.</div> : null}
|
<dl>
|
||||||
</div>
|
<TelemetryFact label="Процессор" value={current?.hardware.cpuModel} />
|
||||||
</section>
|
<TelemetryFact label="Логические ядра" value={formatNullable(current?.hardware.logicalProcessors)} />
|
||||||
</div>
|
<TelemetryFact label="Память занята" value={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
|
||||||
|
<TelemetryFact label="Uptime" value={formatDuration(current?.system.uptimeSeconds)} />
|
||||||
<section className="host-telemetry-section">
|
</dl>
|
||||||
<div className="host-telemetry-section__heading">
|
<dl>
|
||||||
<div><small>PROCESSING RUNTIME</small><h3>Сервисы VPS</h3><p>Состояние systemd-юнитов и их ресурсный профиль.</p></div>
|
<TelemetryFact label="Платформа" value={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || null} />
|
||||||
<StatusBadge tone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : "success"}>{runtimeServices.length} units</StatusBadge>
|
<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>
|
||||||
<div className="host-telemetry-service-grid">
|
<div className="host-monitoring-disk-list">
|
||||||
{runtimeServices.map((service, index) => (
|
{(current?.disks ?? []).map((disk, index) => (
|
||||||
<div className="host-telemetry-service" key={`${service.name}:${index}`}>
|
<div key={`${disk.device}:${disk.mount}:${index}`}>
|
||||||
<span><strong>{service.name ?? "systemd unit"}</strong><small>{service.subState ?? service.loadState ?? "—"}</small></span>
|
<span>Диск {disk.mount ?? disk.device ?? "—"}</span>
|
||||||
<span><StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge><small>{formatMetricBytes(service.memoryBytes)}</small></span>
|
<strong>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!runtimeServices.length ? <div className="device-manager-panel-empty">Состояние сервисов ещё не поступило.</div> : null}
|
{!current?.disks.length ? <div className="infrastructure-empty">Данные о дисках ещё не поступили.</div> : null}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="host-telemetry-section host-telemetry-evidence">
|
<section className="infrastructure-section">
|
||||||
<div><small>ONTOLOGY / OBSERVATION</small><strong>{telemetry.observation?.entityId ?? "observation.observation"}</strong></div>
|
<InfrastructureSectionHeading
|
||||||
<div><small>Источник</small><strong>{current ? `${current.source.agent} ${current.source.agentVersion}` : "—"}</strong></div>
|
eyebrow="PROCESSING RUNTIME"
|
||||||
<div><small>Последнее наблюдение</small><strong>{formatDate(telemetry.observedAt)}</strong></div>
|
title="Сервисы VPS"
|
||||||
<div><small>Связанные сервисы</small><strong>{services.length}</strong></div>
|
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>
|
</section>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="host-telemetry-metric">
|
<div className="infrastructure-count-card">
|
||||||
<span>{label}</span>
|
<span>{label}</span><strong>{value}</strong><small>{detail}</small>
|
||||||
<strong>{value}</strong>
|
|
||||||
<Sparkline values={points} />
|
|
||||||
<small>{detail}</small>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sparkline({ values }: { values: Array<number | null> }) {
|
function InfrastructureSectionHeading({ eyebrow, title, description, status, statusTone: tone = "neutral", actions = null }: {
|
||||||
const normalized = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
eyebrow: string;
|
||||||
if (normalized.length < 2) return <div className="host-telemetry-sparkline host-telemetry-sparkline--empty" />;
|
title: string;
|
||||||
const minimum = Math.min(...normalized);
|
description: string;
|
||||||
const maximum = Math.max(...normalized);
|
status: string;
|
||||||
const spread = Math.max(1, maximum - minimum);
|
statusTone?: "neutral" | "success" | "warning" | "danger";
|
||||||
const points = normalized.map((value, index) => {
|
actions?: ReactNode;
|
||||||
const x = (index / (normalized.length - 1)) * 100;
|
}) {
|
||||||
const y = 28 - ((value - minimum) / spread) * 24;
|
return (
|
||||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
<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(" ");
|
}).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 }) {
|
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"]) {
|
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 };
|
if (!Number.isFinite(seconds) || seconds <= 0) return { received: null, sent: null };
|
||||||
const previousTotals = networkTotals(previous.network);
|
const previousTotals = networkTotals(previous.network);
|
||||||
const latestTotals = networkTotals(latest.network);
|
const latestTotals = networkTotals(latest.network);
|
||||||
|
if (!previousTotals || !latestTotals) return { received: null, sent: null };
|
||||||
return {
|
return {
|
||||||
received: nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
|
received: latestTotals.received == null || previousTotals.received == null ? null : nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
|
||||||
sent: nonNegativeRate(latestTotals.sent - previousTotals.sent, 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;
|
if (seconds <= 0) return null;
|
||||||
const currentTotals = networkTotals(sample.network);
|
const currentTotals = networkTotals(sample.network);
|
||||||
const previousTotals = networkTotals(previous.network);
|
const previousTotals = networkTotals(previous.network);
|
||||||
|
if (!currentTotals || !previousTotals || currentTotals[direction] == null || previousTotals[direction] == null) return null;
|
||||||
return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds);
|
return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) {
|
function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) {
|
||||||
return network.filter((item) => item.interface !== "lo").reduce((total, item) => ({
|
const counters = network.filter((item) => item.interface !== "lo" && (item.bytesReceived != null || item.bytesSent != null));
|
||||||
received: total.received + (item.bytesReceived ?? 0),
|
if (!counters.length) return null;
|
||||||
sent: total.sent + (item.bytesSent ?? 0),
|
const receivedCounters = counters.map((item) => item.bytesReceived).filter((value): value is number => value !== null && Number.isFinite(value));
|
||||||
}), { received: 0, sent: 0 });
|
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) {
|
function nonNegativeRate(bytes: number, seconds: number) {
|
||||||
|
|||||||
+628
-179
@@ -127,231 +127,655 @@
|
|||||||
padding-right: 3px;
|
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;
|
display: grid;
|
||||||
gap: 18px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header {
|
.infrastructure-overview-block,
|
||||||
display: grid;
|
.infrastructure-hosts-block,
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
.infrastructure-assets-block {
|
||||||
align-items: start;
|
min-width: 0;
|
||||||
gap: 14px;
|
border-radius: var(--nodedc-radius-card);
|
||||||
|
background: var(--infrastructure-panel-soft);
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header__copy {
|
.infrastructure-system-workspace .nodedc-status {
|
||||||
display: grid;
|
min-height: auto;
|
||||||
gap: 3px;
|
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,
|
.infrastructure-system-workspace .nodedc-status::before {
|
||||||
.host-telemetry-section__heading small,
|
width: 0.42rem;
|
||||||
.host-telemetry-evidence small {
|
height: 0.42rem;
|
||||||
color: var(--nodedc-text-tertiary);
|
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-size: 0.62rem;
|
||||||
font-weight: 780;
|
font-weight: 820;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
|
line-height: 1.2;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header__copy h2,
|
.infrastructure-workspace-lead,
|
||||||
.host-telemetry-header__copy p,
|
.infrastructure-section-heading {
|
||||||
.host-telemetry-section__heading h3,
|
display: flex;
|
||||||
.host-telemetry-section__heading p {
|
min-width: 0;
|
||||||
margin: 0;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header__copy h2 {
|
.infrastructure-workspace-lead {
|
||||||
font-size: 1.4rem;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-workspace-lead h2,
|
||||||
|
.infrastructure-section-heading h3 {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
letter-spacing: -0.035em;
|
letter-spacing: -0.035em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header__copy p,
|
.infrastructure-workspace-lead h2 {
|
||||||
.host-telemetry-section__heading p {
|
font-size: 1.42rem;
|
||||||
color: var(--nodedc-text-tertiary);
|
}
|
||||||
font-size: 0.76rem;
|
|
||||||
|
.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;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header__actions {
|
.host-monitoring-series {
|
||||||
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;
|
overflow: hidden;
|
||||||
|
padding: 0.82rem 0.82rem 0.32rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-metric > span,
|
.host-monitoring-series > div {
|
||||||
.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;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-section__heading > div {
|
.host-monitoring-series__label {
|
||||||
display: grid;
|
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;
|
min-width: 0;
|
||||||
padding: 12px;
|
gap: 0.16rem;
|
||||||
background: var(--nodedc-surface-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-facts strong {
|
.host-monitoring-series__label small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.78rem;
|
max-width: 11rem;
|
||||||
font-weight: 590;
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
line-height: 1.15;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-split {
|
.host-monitoring-series svg {
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
width: 100%;
|
||||||
gap: 12px;
|
height: 2.45rem;
|
||||||
|
margin-top: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-list,
|
.host-monitoring-series path {
|
||||||
.host-telemetry-service-grid {
|
fill: none;
|
||||||
display: grid;
|
stroke: var(--infrastructure-hairline);
|
||||||
gap: 7px;
|
stroke-width: 0.8;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-list__row,
|
.host-monitoring-series polyline {
|
||||||
.host-telemetry-service {
|
fill: none;
|
||||||
display: flex;
|
stroke: var(--nodedc-text-primary);
|
||||||
align-items: center;
|
stroke-linecap: round;
|
||||||
justify-content: space-between;
|
stroke-linejoin: round;
|
||||||
gap: 16px;
|
stroke-width: 1.15;
|
||||||
min-width: 0;
|
vector-effect: non-scaling-stroke;
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--nodedc-radius-md);
|
|
||||||
background: var(--nodedc-surface-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-list__row > span,
|
.host-monitoring-series__range {
|
||||||
.host-telemetry-service > span {
|
display: block;
|
||||||
display: grid;
|
margin-top: -0.1rem;
|
||||||
gap: 3px;
|
color: var(--nodedc-text-secondary);
|
||||||
min-width: 0;
|
font-size: 0.5rem;
|
||||||
}
|
line-height: 1.2;
|
||||||
|
|
||||||
.host-telemetry-list__row > span:last-child,
|
|
||||||
.host-telemetry-service > span:last-child {
|
|
||||||
justify-items: end;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-list__row strong,
|
.host-monitoring-hardware {
|
||||||
.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;
|
display: grid;
|
||||||
gap: 5px;
|
|
||||||
min-width: 0;
|
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;
|
overflow: hidden;
|
||||||
font-size: 0.75rem;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.device-control-resource-grid,
|
.device-control-resource-grid,
|
||||||
.device-control-policy-grid {
|
.device-control-policy-grid {
|
||||||
@@ -394,22 +818,47 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header {
|
.infrastructure-workspace-lead,
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
.infrastructure-section-heading {
|
||||||
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-header__actions {
|
.infrastructure-workspace-actions,
|
||||||
grid-column: 2;
|
.infrastructure-section-actions {
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-telemetry-metric-grid,
|
.infrastructure-overview-grid,
|
||||||
.host-telemetry-facts,
|
.host-monitoring-series-grid,
|
||||||
.host-telemetry-service-grid,
|
.infrastructure-host-list,
|
||||||
.host-telemetry-evidence,
|
.infrastructure-runtime-grid,
|
||||||
.host-telemetry-split {
|
.infrastructure-asset-grid,
|
||||||
|
.host-monitoring-hardware-facts,
|
||||||
|
.host-monitoring-network-grid {
|
||||||
grid-template-columns: 1fr;
|
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,
|
html,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-control-core-release.v4",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "migration-replay-checkpoint-recovery",
|
||||||
|
"patchId": "device-control-core-migration-replay-checkpoint-recovery-20260822-046",
|
||||||
|
"artifactSha256": "46000c76977fb583fc7c9cf74ecf624efd8b404f7b8d0322e0270e7b8ac6e450"
|
||||||
|
},
|
||||||
|
"service": "device-control-core",
|
||||||
|
"composeActivation": "preserve-active-v4-topology",
|
||||||
|
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||||
|
"tlsPurpose": "clientAuth",
|
||||||
|
"direction": "core-initiated",
|
||||||
|
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||||
|
"coreNetworks": [
|
||||||
|
"device-plane-private",
|
||||||
|
"device-plane-egress"
|
||||||
|
],
|
||||||
|
"publicIngress": "none-on-synology",
|
||||||
|
"edgeRegistrations": "preserved",
|
||||||
|
"commandTransport": "typed-service-ping-v1",
|
||||||
|
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||||
|
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||||
|
"telemetryTransport": "edge-channel-host-telemetry-observed-v1",
|
||||||
|
"telemetryContract": "nodedc.infrastructure.host-telemetry.v1",
|
||||||
|
"telemetryStorage": "device-control-core-postgres-seven-day-retention",
|
||||||
|
"ontologyProjection": "observation-observed-property-provenance-freshness-v1",
|
||||||
|
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
|
||||||
|
"recoveryPredecessor": "terminal-applied-046-exact-source-runtime-database",
|
||||||
|
"databasePreflight": "final-migration-016-validated-and-host-telemetry-table-absent",
|
||||||
|
"databaseRowMutation": "none-before-core-startup-migrations",
|
||||||
|
"databaseSchemaOutcome": "migration-017-host-telemetry-table-present",
|
||||||
|
"runtimePredecessor": "healthy-recovery-046-core-generation",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"preservedServices": [
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
"device-backhaul-target"
|
||||||
|
],
|
||||||
|
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
|
||||||
|
"rollback": "restore-preapply-source-and-core-runtime"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-manager-release.v10",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v8-20260822-039",
|
||||||
|
"artifactSha256": "30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
|
||||||
|
},
|
||||||
|
"controlCorePredecessor": {
|
||||||
|
"patchId": "device-control-core-release-v4-20260823-047",
|
||||||
|
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
},
|
||||||
|
"edgeChannelPredecessor": {
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
},
|
||||||
|
"service": "device-manager",
|
||||||
|
"publicIngress": "reverse-proxy-only",
|
||||||
|
"deviceCoreManagementApi": "file-token-authenticated",
|
||||||
|
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||||
|
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
|
||||||
|
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
|
||||||
|
"healthGate": "bounded-container-grace+core-contract+persistent-data",
|
||||||
|
"commandTransport": "typed-service-ping-v1",
|
||||||
|
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||||
|
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||||
|
"presentationPersistence": "runner-managed-host-data-bind",
|
||||||
|
"presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||||
|
"presentationDataContainerPath": "/var/lib/nodedc-device-manager",
|
||||||
|
"presentationDataOwnership": "uid-1000-gid-1000-mode-0750",
|
||||||
|
"presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback",
|
||||||
|
"presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json",
|
||||||
|
"mediaRoot": "/var/lib/nodedc-device-manager/media",
|
||||||
|
"defaultAccentHex": "#f5f5f5",
|
||||||
|
"overviewLayout": "mission-core-landing-stage-v1",
|
||||||
|
"faviconSet": "nodedc-adaptive-v1",
|
||||||
|
"commandFormLayout": "aligned-control-row-v1",
|
||||||
|
"secondaryEmptyTypography": "help-text-sm-v1",
|
||||||
|
"infrastructureHostProjection": "ontology-backed-host-runtime-v1",
|
||||||
|
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
|
||||||
|
"ontologyCatalogHash": "229c61c02a790906",
|
||||||
|
"assetBinding": "temporal-device-asset-binding-v1",
|
||||||
|
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
|
||||||
|
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
|
||||||
|
"telemetryWorkspace": "mission-core-compute-module-parity-v1",
|
||||||
|
"telemetryNavigation": "full-workspace-back-navigation-v1",
|
||||||
|
"telemetryPollInterval": "three-seconds",
|
||||||
|
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
|
||||||
|
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
|
||||||
|
"telemetryAgent": "telegraf-host-observer-v1",
|
||||||
|
"interactiveShell": "disabled-pending-managed-session-boundary",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-manager-release.v11",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v10-20260823-048",
|
||||||
|
"artifactSha256": "e6b983a314db4f8c27d89062dfedf5ed0523cc30421170799d181a19e2d85d4c"
|
||||||
|
},
|
||||||
|
"controlCorePredecessor": {
|
||||||
|
"patchId": "device-control-core-release-v4-20260823-047",
|
||||||
|
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
},
|
||||||
|
"edgeChannelPredecessor": {
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
},
|
||||||
|
"service": "device-manager",
|
||||||
|
"publicIngress": "reverse-proxy-only",
|
||||||
|
"deviceCoreManagementApi": "file-token-authenticated",
|
||||||
|
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||||
|
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
|
||||||
|
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
|
||||||
|
"healthGate": "bounded-container-grace+core-contract+persistent-data",
|
||||||
|
"commandTransport": "typed-service-ping-v1",
|
||||||
|
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||||
|
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||||
|
"presentationPersistence": "runner-managed-host-data-bind",
|
||||||
|
"presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||||
|
"presentationDataContainerPath": "/var/lib/nodedc-device-manager",
|
||||||
|
"presentationDataOwnership": "uid-1000-gid-1000-mode-0750",
|
||||||
|
"presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback",
|
||||||
|
"presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json",
|
||||||
|
"mediaRoot": "/var/lib/nodedc-device-manager/media",
|
||||||
|
"defaultAccentHex": "#f5f5f5",
|
||||||
|
"overviewLayout": "mission-core-landing-stage-v1",
|
||||||
|
"faviconSet": "nodedc-adaptive-v1",
|
||||||
|
"commandFormLayout": "aligned-control-row-v1",
|
||||||
|
"secondaryEmptyTypography": "help-text-sm-v1",
|
||||||
|
"infrastructureHostProjection": "ontology-backed-host-runtime-v1",
|
||||||
|
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
|
||||||
|
"ontologyCatalogHash": "229c61c02a790906",
|
||||||
|
"assetBinding": "temporal-device-asset-binding-v1",
|
||||||
|
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
|
||||||
|
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
|
||||||
|
"designSystem": "nodedc-canonical-components-and-tokens-v1",
|
||||||
|
"missionCoreReference": "compute-modules-workspace-71c8b04",
|
||||||
|
"infrastructureWorkspaceLayout": "mission-core-system-workspace-v1",
|
||||||
|
"hostInventoryComposition": "mission-core-compute-host-list-v1",
|
||||||
|
"telemetryWorkspace": "mission-core-compute-module-visual-parity-v2",
|
||||||
|
"telemetrySurface": "borderless-soft-surface-v1",
|
||||||
|
"telemetryStatus": "mission-core-dot-status-v1",
|
||||||
|
"telemetryNavigation": "full-workspace-back-navigation-v1",
|
||||||
|
"telemetryScroll": "reset-on-workspace-transition-v1",
|
||||||
|
"telemetryPollInterval": "three-seconds",
|
||||||
|
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
|
||||||
|
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
|
||||||
|
"telemetryAgent": "telegraf-host-observer-v1",
|
||||||
|
"interactiveShell": "disabled-pending-managed-session-boundary",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-manager-release.v12",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v11-20260823-049",
|
||||||
|
"artifactSha256": "c1e2056b50bfbb0d03d077461d0c27cc56cc52967c3f5620be14871c8a6d5cf0"
|
||||||
|
},
|
||||||
|
"controlCorePredecessor": {
|
||||||
|
"patchId": "device-control-core-release-v4-20260823-047",
|
||||||
|
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
},
|
||||||
|
"edgeChannelPredecessor": {
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
},
|
||||||
|
"service": "device-manager",
|
||||||
|
"publicIngress": "reverse-proxy-only",
|
||||||
|
"deviceCoreManagementApi": "file-token-authenticated",
|
||||||
|
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||||
|
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
|
||||||
|
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
|
||||||
|
"healthGate": "bounded-container-grace+core-contract+persistent-data",
|
||||||
|
"commandTransport": "typed-service-ping-v1",
|
||||||
|
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||||
|
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||||
|
"presentationPersistence": "runner-managed-host-data-bind",
|
||||||
|
"presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||||
|
"presentationDataContainerPath": "/var/lib/nodedc-device-manager",
|
||||||
|
"presentationDataOwnership": "uid-1000-gid-1000-mode-0750",
|
||||||
|
"presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback",
|
||||||
|
"presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json",
|
||||||
|
"mediaRoot": "/var/lib/nodedc-device-manager/media",
|
||||||
|
"defaultAccentHex": "#f5f5f5",
|
||||||
|
"overviewLayout": "mission-core-landing-stage-v1",
|
||||||
|
"faviconSet": "nodedc-adaptive-v1",
|
||||||
|
"commandFormLayout": "aligned-control-row-v1",
|
||||||
|
"secondaryEmptyTypography": "help-text-sm-v1",
|
||||||
|
"infrastructureHostProjection": "ontology-backed-host-runtime-v1",
|
||||||
|
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
|
||||||
|
"ontologyCatalogHash": "229c61c02a790906",
|
||||||
|
"assetBinding": "temporal-device-asset-binding-v1",
|
||||||
|
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
|
||||||
|
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
|
||||||
|
"designSystem": "nodedc-canonical-components-and-tokens-v1",
|
||||||
|
"missionCoreReference": "compute-modules-workspace-71c8b04",
|
||||||
|
"infrastructureWorkspaceLayout": "mission-core-system-workspace-v1",
|
||||||
|
"hostInventoryComposition": "mission-core-compute-host-list-v1",
|
||||||
|
"telemetryWorkspace": "mission-core-compute-module-adaptive-window-v3",
|
||||||
|
"telemetrySurface": "borderless-soft-surface-v1",
|
||||||
|
"telemetryStatus": "mission-core-dot-status-v1",
|
||||||
|
"telemetryNavigation": "full-workspace-back-navigation-v1",
|
||||||
|
"telemetryScroll": "reset-on-workspace-transition-v1",
|
||||||
|
"telemetryPollInterval": "three-seconds",
|
||||||
|
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
|
||||||
|
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
|
||||||
|
"telemetryAgent": "telegraf-host-observer-v1",
|
||||||
|
"telemetryGraphScale": "adaptive-observed-window-explicit-domain-v1",
|
||||||
|
"telemetryCpuMinimumSpan": "five-percentage-points",
|
||||||
|
"telemetryMemoryMinimumSpan": "four-percentage-points",
|
||||||
|
"telemetryNetworkMissingSemantics": "missing-counters-never-zero-v1",
|
||||||
|
"interactiveShell": "disabled-pending-managed-session-boundary",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-manager-release.v13",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v12-20260823-050",
|
||||||
|
"artifactSha256": "1a49839140e5f2e49763d78f24ee47d946e244bcfde15a9c38266e8bd14c0d49"
|
||||||
|
},
|
||||||
|
"controlCorePredecessor": {
|
||||||
|
"patchId": "device-control-core-release-v4-20260823-047",
|
||||||
|
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
},
|
||||||
|
"edgeChannelPredecessor": {
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
},
|
||||||
|
"service": "device-manager",
|
||||||
|
"publicIngress": "reverse-proxy-only",
|
||||||
|
"deviceCoreManagementApi": "file-token-authenticated",
|
||||||
|
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||||
|
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
|
||||||
|
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
|
||||||
|
"healthGate": "bounded-container-grace+core-contract+persistent-data",
|
||||||
|
"commandTransport": "typed-service-ping-v1",
|
||||||
|
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||||
|
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||||
|
"presentationPersistence": "runner-managed-host-data-bind",
|
||||||
|
"presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||||
|
"presentationDataContainerPath": "/var/lib/nodedc-device-manager",
|
||||||
|
"presentationDataOwnership": "uid-1000-gid-1000-mode-0750",
|
||||||
|
"presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback",
|
||||||
|
"presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json",
|
||||||
|
"mediaRoot": "/var/lib/nodedc-device-manager/media",
|
||||||
|
"defaultAccentHex": "#f5f5f5",
|
||||||
|
"overviewLayout": "mission-core-landing-stage-v1",
|
||||||
|
"faviconSet": "nodedc-adaptive-v1",
|
||||||
|
"commandFormLayout": "aligned-control-row-v1",
|
||||||
|
"secondaryEmptyTypography": "help-text-sm-v1",
|
||||||
|
"infrastructureHostProjection": "ontology-backed-host-runtime-v1",
|
||||||
|
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
|
||||||
|
"ontologyCatalogHash": "229c61c02a790906",
|
||||||
|
"assetBinding": "temporal-device-asset-binding-v1",
|
||||||
|
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
|
||||||
|
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
|
||||||
|
"designSystem": "nodedc-canonical-components-and-tokens-v1",
|
||||||
|
"missionCoreReference": "compute-modules-workspace-71c8b04",
|
||||||
|
"infrastructureWorkspaceLayout": "mission-core-system-workspace-v2",
|
||||||
|
"hostInventoryComposition": "mission-core-compute-host-accordion-v2",
|
||||||
|
"hostInventoryOverviewSurface": "separate-summary-soft-surface-v1",
|
||||||
|
"hostInventoryCollectionSurface": "separate-host-collection-soft-surface-v1",
|
||||||
|
"hostInventoryRow": "compact-centered-accordion-v1",
|
||||||
|
"hostInventoryFreshness": "dot-only-v1",
|
||||||
|
"hostInventoryRelations": "host-scoped-endpoint-deployment-service-v1",
|
||||||
|
"hostInventoryDefaultExpansion": "collapsed",
|
||||||
|
"hostInventoryScaleTarget": "five-hundred-collapsed-rows-v1",
|
||||||
|
"telemetryWorkspace": "mission-core-compute-module-adaptive-window-v3",
|
||||||
|
"telemetrySurface": "borderless-soft-surface-v1",
|
||||||
|
"telemetryStatus": "mission-core-dot-status-v1",
|
||||||
|
"telemetryNavigation": "full-workspace-back-navigation-v1",
|
||||||
|
"telemetryScroll": "reset-on-workspace-transition-v1",
|
||||||
|
"telemetryPollInterval": "three-seconds",
|
||||||
|
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
|
||||||
|
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
|
||||||
|
"telemetryAgent": "telegraf-host-observer-v1",
|
||||||
|
"telemetryGraphScale": "adaptive-observed-window-explicit-domain-v1",
|
||||||
|
"telemetryCpuMinimumSpan": "five-percentage-points",
|
||||||
|
"telemetryMemoryMinimumSpan": "four-percentage-points",
|
||||||
|
"telemetryNetworkMissingSemantics": "missing-counters-never-zero-v1",
|
||||||
|
"interactiveShell": "disabled-pending-managed-session-boundary",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
}
|
||||||
@@ -16,14 +16,16 @@ const [
|
|||||||
predecessorSha256,
|
predecessorSha256,
|
||||||
...extra
|
...extra
|
||||||
] = process.argv.slice(2);
|
] = process.argv.slice(2);
|
||||||
|
const isV4 = patchId.startsWith("device-control-core-release-v4-");
|
||||||
if (
|
if (
|
||||||
extra.length
|
extra.length
|
||||||
|| !/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(patchId)
|
|| !/^device-control-core-release(?:-v[234])?-[A-Za-z0-9._-]{1,67}$/.test(patchId)
|
||||||
|
|| (isV4 && patchId !== "device-control-core-release-v4-20260823-047")
|
||||||
|| ((predecessorPatchId === undefined) !== (predecessorSha256 === undefined))
|
|| ((predecessorPatchId === undefined) !== (predecessorSha256 === undefined))
|
||||||
|| (
|
|| (
|
||||||
predecessorPatchId !== undefined
|
predecessorPatchId !== undefined
|
||||||
&& (
|
&& (
|
||||||
!/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(predecessorPatchId)
|
!/^(?:device-control-core-release(?:-v[234])?-[A-Za-z0-9._-]{1,67}|device-control-core-migration-replay-checkpoint-recovery-20260822-046)$/.test(predecessorPatchId)
|
||||||
|| predecessorPatchId === patchId
|
|| predecessorPatchId === patchId
|
||||||
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
|
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
|
||||||
)
|
)
|
||||||
@@ -37,12 +39,13 @@ if (
|
|||||||
|
|
||||||
const isV3 = patchId.startsWith("device-control-core-release-v3-");
|
const isV3 = patchId.startsWith("device-control-core-release-v3-");
|
||||||
const isV2 = patchId.startsWith("device-control-core-release-v2-");
|
const isV2 = patchId.startsWith("device-control-core-release-v2-");
|
||||||
|
const includesTelemetry = isV3 || isV4;
|
||||||
const coreDockerfile = await readFile(
|
const coreDockerfile = await readFile(
|
||||||
resolve(devicePlaneRoot, "services/device-control-core/Dockerfile"),
|
resolve(devicePlaneRoot, "services/device-control-core/Dockerfile"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!isV3
|
!includesTelemetry
|
||||||
&& coreDockerfile.includes(
|
&& coreDockerfile.includes(
|
||||||
"COPY packages/infrastructure-telemetry-contract ./packages/infrastructure-telemetry-contract",
|
"COPY packages/infrastructure-telemetry-contract ./packages/infrastructure-telemetry-contract",
|
||||||
)
|
)
|
||||||
@@ -53,7 +56,23 @@ const expectedV2Predecessor = Object.freeze({
|
|||||||
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
|
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
|
||||||
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
|
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
|
||||||
});
|
});
|
||||||
const descriptorPath = isV3
|
const expectedV4Predecessor = Object.freeze({
|
||||||
|
patchId: "device-control-core-migration-replay-checkpoint-recovery-20260822-046",
|
||||||
|
artifactSha256: "46000c76977fb583fc7c9cf74ecf624efd8b404f7b8d0322e0270e7b8ac6e450",
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
isV4
|
||||||
|
&& predecessorPatchId !== undefined
|
||||||
|
&& (
|
||||||
|
predecessorPatchId !== expectedV4Predecessor.patchId
|
||||||
|
|| predecessorSha256 !== expectedV4Predecessor.artifactSha256
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("device_control_core_release_v4_predecessor_mismatch");
|
||||||
|
}
|
||||||
|
const descriptorPath = isV4
|
||||||
|
? "deployment/device-control-core-release-v4.json"
|
||||||
|
: isV3
|
||||||
? "deployment/device-control-core-release-v3.json"
|
? "deployment/device-control-core-release-v3.json"
|
||||||
: isV2
|
: isV2
|
||||||
? "deployment/device-control-core-release-v2.json"
|
? "deployment/device-control-core-release-v2.json"
|
||||||
@@ -64,7 +83,7 @@ const entries = [
|
|||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
"packages/device-edge-channel-contract",
|
"packages/device-edge-channel-contract",
|
||||||
...(isV3 ? ["packages/infrastructure-telemetry-contract"] : []),
|
...(includesTelemetry ? ["packages/infrastructure-telemetry-contract"] : []),
|
||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
descriptorPath,
|
descriptorPath,
|
||||||
];
|
];
|
||||||
@@ -83,7 +102,9 @@ try {
|
|||||||
descriptor.releaseId = patchId;
|
descriptor.releaseId = patchId;
|
||||||
if (predecessorPatchId !== undefined) {
|
if (predecessorPatchId !== undefined) {
|
||||||
descriptor.predecessor = {
|
descriptor.predecessor = {
|
||||||
kind: "release",
|
kind: isV4
|
||||||
|
? "migration-replay-checkpoint-recovery"
|
||||||
|
: "release",
|
||||||
patchId: predecessorPatchId,
|
patchId: predecessorPatchId,
|
||||||
artifactSha256: predecessorSha256,
|
artifactSha256: predecessorSha256,
|
||||||
};
|
};
|
||||||
@@ -104,7 +125,7 @@ try {
|
|||||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||||
"packages/device-protocol-contract/src/index.mjs",
|
"packages/device-protocol-contract/src/index.mjs",
|
||||||
"packages/device-edge-channel-contract/src/index.mjs",
|
"packages/device-edge-channel-contract/src/index.mjs",
|
||||||
...(isV3 ? ["packages/infrastructure-telemetry-contract/src/index.mjs"] : []),
|
...(includesTelemetry ? ["packages/infrastructure-telemetry-contract/src/index.mjs"] : []),
|
||||||
]) {
|
]) {
|
||||||
const imported = spawnSync(
|
const imported = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
@@ -118,7 +139,7 @@ try {
|
|||||||
|
|
||||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||||
if (
|
if (
|
||||||
descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV3 ? "v3" : isV2 ? "v2" : "v1"}`
|
descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV4 ? "v4" : isV3 ? "v3" : isV2 ? "v2" : "v1"}`
|
||||||
|| descriptor.releaseId !== patchId
|
|| descriptor.releaseId !== patchId
|
||||||
|| descriptor.action !== "upgrade"
|
|| descriptor.action !== "upgrade"
|
||||||
|| descriptor.service !== "device-control-core"
|
|| descriptor.service !== "device-control-core"
|
||||||
@@ -130,20 +151,20 @@ try {
|
|||||||
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|
||||||
|| descriptor.publicIngress !== "none-on-synology"
|
|| descriptor.publicIngress !== "none-on-synology"
|
||||||
|| descriptor.edgeRegistrations !== "preserved"
|
|| descriptor.edgeRegistrations !== "preserved"
|
||||||
|| descriptor.commandTransport !== ((isV2 || isV3) ? "typed-service-ping-v1" : "disabled")
|
|| descriptor.commandTransport !== ((isV2 || isV3 || isV4) ? "typed-service-ping-v1" : "disabled")
|
||||||
|| descriptor.gelios !== ((isV2 || isV3) ? "untouched-legacy-only" : "untouched")
|
|| descriptor.gelios !== ((isV2 || isV3 || isV4) ? "untouched-legacy-only" : "untouched")
|
||||||
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
||||||
|| (
|
|| (
|
||||||
(isV2 || isV3)
|
(isV2 || isV3 || isV4)
|
||||||
&& (
|
&& (
|
||||||
descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
||||||
|| descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned"
|
|| descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned"
|
||||||
|| descriptor.predecessor?.patchId !== expectedV2Predecessor.patchId
|
|| descriptor.predecessor?.patchId !== (isV4 ? expectedV4Predecessor : expectedV2Predecessor).patchId
|
||||||
|| descriptor.predecessor?.artifactSha256 !== expectedV2Predecessor.artifactSha256
|
|| descriptor.predecessor?.artifactSha256 !== (isV4 ? expectedV4Predecessor : expectedV2Predecessor).artifactSha256
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|| (
|
|| (
|
||||||
isV3
|
includesTelemetry
|
||||||
&& (
|
&& (
|
||||||
descriptor.telemetryTransport !== "edge-channel-host-telemetry-observed-v1"
|
descriptor.telemetryTransport !== "edge-channel-host-telemetry-observed-v1"
|
||||||
|| descriptor.telemetryContract !== "nodedc.infrastructure.host-telemetry.v1"
|
|| descriptor.telemetryContract !== "nodedc.infrastructure.host-telemetry.v1"
|
||||||
@@ -152,6 +173,17 @@ try {
|
|||||||
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|| (
|
||||||
|
isV4
|
||||||
|
&& (
|
||||||
|
descriptor.predecessor?.kind !== "migration-replay-checkpoint-recovery"
|
||||||
|
|| descriptor.recoveryPredecessor !== "terminal-applied-046-exact-source-runtime-database"
|
||||||
|
|| descriptor.databasePreflight !== "final-migration-016-validated-and-host-telemetry-table-absent"
|
||||||
|
|| descriptor.databaseRowMutation !== "none-before-core-startup-migrations"
|
||||||
|
|| descriptor.databaseSchemaOutcome !== "migration-017-host-telemetry-table-present"
|
||||||
|
|| descriptor.runtimePredecessor !== "healthy-recovery-046-core-generation"
|
||||||
|
)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error("device_control_core_release_contract_mismatch");
|
throw new Error("device_control_core_release_contract_mismatch");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,19 @@ const platformRoot = resolve(scriptDir, "../..");
|
|||||||
const devicePlaneRoot = platformRoot;
|
const devicePlaneRoot = platformRoot;
|
||||||
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
const [patchId = "device-manager-release-v9-20260822-041", ...extra] = process.argv.slice(2);
|
const [patchId = "device-manager-release-v13-20260823-051", ...extra] = process.argv.slice(2);
|
||||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]");
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]");
|
||||||
|
|
||||||
const descriptorPath = patchId.startsWith("device-manager-release-v9-")
|
const descriptorPath = patchId.startsWith("device-manager-release-v13-")
|
||||||
? "deployment/device-manager-release-v9.json"
|
? "deployment/device-manager-release-v13.json"
|
||||||
|
: patchId.startsWith("device-manager-release-v12-")
|
||||||
|
? "deployment/device-manager-release-v12.json"
|
||||||
|
: patchId.startsWith("device-manager-release-v11-")
|
||||||
|
? "deployment/device-manager-release-v11.json"
|
||||||
|
: patchId.startsWith("device-manager-release-v10-")
|
||||||
|
? "deployment/device-manager-release-v10.json"
|
||||||
|
: patchId.startsWith("device-manager-release-v9-")
|
||||||
|
? "deployment/device-manager-release-v9.json"
|
||||||
: patchId.startsWith("device-manager-release-v8-")
|
: patchId.startsWith("device-manager-release-v8-")
|
||||||
? "deployment/device-manager-release-v8.json"
|
? "deployment/device-manager-release-v8.json"
|
||||||
: patchId.startsWith("device-manager-release-v7-")
|
: patchId.startsWith("device-manager-release-v7-")
|
||||||
@@ -37,7 +45,11 @@ const isV6 = descriptorPath.endsWith("release-v6.json");
|
|||||||
const isV7 = descriptorPath.endsWith("release-v7.json");
|
const isV7 = descriptorPath.endsWith("release-v7.json");
|
||||||
const isV8 = descriptorPath.endsWith("release-v8.json");
|
const isV8 = descriptorPath.endsWith("release-v8.json");
|
||||||
const isV9 = descriptorPath.endsWith("release-v9.json");
|
const isV9 = descriptorPath.endsWith("release-v9.json");
|
||||||
const isPersistent = isV4 || isV5 || isV6 || isV7 || isV8 || isV9;
|
const isV10 = descriptorPath.endsWith("release-v10.json");
|
||||||
|
const isV11 = descriptorPath.endsWith("release-v11.json");
|
||||||
|
const isV12 = descriptorPath.endsWith("release-v12.json");
|
||||||
|
const isV13 = descriptorPath.endsWith("release-v13.json");
|
||||||
|
const isPersistent = isV4 || isV5 || isV6 || isV7 || isV8 || isV9 || isV10 || isV11 || isV12 || isV13;
|
||||||
const isManagerOnly = isV3 || isPersistent;
|
const isManagerOnly = isV3 || isPersistent;
|
||||||
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
||||||
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
||||||
@@ -176,7 +188,127 @@ try {
|
|||||||
: "restore-preapply-snapshot")
|
: "restore-preapply-snapshot")
|
||||||
);
|
);
|
||||||
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
||||||
if (descriptorPath.endsWith("release-v9.json")) {
|
if (descriptorPath.endsWith("release-v13.json")) {
|
||||||
|
if (
|
||||||
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v13"
|
||||||
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
|
|| descriptor.predecessor?.patchId !== "device-manager-release-v12-20260823-050"
|
||||||
|
|| descriptor.predecessor?.artifactSha256 !== "1a49839140e5f2e49763d78f24ee47d946e244bcfde15a9c38266e8bd14c0d49"
|
||||||
|
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|
||||||
|
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
|| descriptor.designSystem !== "nodedc-canonical-components-and-tokens-v1"
|
||||||
|
|| descriptor.missionCoreReference !== "compute-modules-workspace-71c8b04"
|
||||||
|
|| descriptor.infrastructureWorkspaceLayout !== "mission-core-system-workspace-v2"
|
||||||
|
|| descriptor.hostInventoryComposition !== "mission-core-compute-host-accordion-v2"
|
||||||
|
|| descriptor.hostInventoryOverviewSurface !== "separate-summary-soft-surface-v1"
|
||||||
|
|| descriptor.hostInventoryCollectionSurface !== "separate-host-collection-soft-surface-v1"
|
||||||
|
|| descriptor.hostInventoryRow !== "compact-centered-accordion-v1"
|
||||||
|
|| descriptor.hostInventoryFreshness !== "dot-only-v1"
|
||||||
|
|| descriptor.hostInventoryRelations !== "host-scoped-endpoint-deployment-service-v1"
|
||||||
|
|| descriptor.hostInventoryDefaultExpansion !== "collapsed"
|
||||||
|
|| descriptor.hostInventoryScaleTarget !== "five-hundred-collapsed-rows-v1"
|
||||||
|
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-adaptive-window-v3"
|
||||||
|
|| descriptor.telemetrySurface !== "borderless-soft-surface-v1"
|
||||||
|
|| descriptor.telemetryStatus !== "mission-core-dot-status-v1"
|
||||||
|
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|
||||||
|
|| descriptor.telemetryScroll !== "reset-on-workspace-transition-v1"
|
||||||
|
|| descriptor.telemetryPollInterval !== "three-seconds"
|
||||||
|
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|
||||||
|
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|
||||||
|
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|
||||||
|
|| descriptor.telemetryGraphScale !== "adaptive-observed-window-explicit-domain-v1"
|
||||||
|
|| descriptor.telemetryCpuMinimumSpan !== "five-percentage-points"
|
||||||
|
|| descriptor.telemetryMemoryMinimumSpan !== "four-percentage-points"
|
||||||
|
|| descriptor.telemetryNetworkMissingSemantics !== "missing-counters-never-zero-v1"
|
||||||
|
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|
||||||
|
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
|| descriptor.gelios !== "untouched-legacy-only"
|
||||||
|
) throw new Error("device_manager_v13_host_inventory_accordion_contract_mismatch");
|
||||||
|
await validateFaviconBundle(payload, "device_manager_v13");
|
||||||
|
} else if (descriptorPath.endsWith("release-v12.json")) {
|
||||||
|
if (
|
||||||
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v12"
|
||||||
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
|
|| descriptor.predecessor?.patchId !== "device-manager-release-v11-20260823-049"
|
||||||
|
|| descriptor.predecessor?.artifactSha256 !== "c1e2056b50bfbb0d03d077461d0c27cc56cc52967c3f5620be14871c8a6d5cf0"
|
||||||
|
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|
||||||
|
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
|| descriptor.designSystem !== "nodedc-canonical-components-and-tokens-v1"
|
||||||
|
|| descriptor.missionCoreReference !== "compute-modules-workspace-71c8b04"
|
||||||
|
|| descriptor.infrastructureWorkspaceLayout !== "mission-core-system-workspace-v1"
|
||||||
|
|| descriptor.hostInventoryComposition !== "mission-core-compute-host-list-v1"
|
||||||
|
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-adaptive-window-v3"
|
||||||
|
|| descriptor.telemetrySurface !== "borderless-soft-surface-v1"
|
||||||
|
|| descriptor.telemetryStatus !== "mission-core-dot-status-v1"
|
||||||
|
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|
||||||
|
|| descriptor.telemetryScroll !== "reset-on-workspace-transition-v1"
|
||||||
|
|| descriptor.telemetryPollInterval !== "three-seconds"
|
||||||
|
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|
||||||
|
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|
||||||
|
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|
||||||
|
|| descriptor.telemetryGraphScale !== "adaptive-observed-window-explicit-domain-v1"
|
||||||
|
|| descriptor.telemetryCpuMinimumSpan !== "five-percentage-points"
|
||||||
|
|| descriptor.telemetryMemoryMinimumSpan !== "four-percentage-points"
|
||||||
|
|| descriptor.telemetryNetworkMissingSemantics !== "missing-counters-never-zero-v1"
|
||||||
|
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|
||||||
|
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
|| descriptor.gelios !== "untouched-legacy-only"
|
||||||
|
) throw new Error("device_manager_v12_adaptive_telemetry_graph_contract_mismatch");
|
||||||
|
await validateFaviconBundle(payload, "device_manager_v12");
|
||||||
|
} else if (descriptorPath.endsWith("release-v11.json")) {
|
||||||
|
if (
|
||||||
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v11"
|
||||||
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
|
|| descriptor.predecessor?.patchId !== "device-manager-release-v10-20260823-048"
|
||||||
|
|| descriptor.predecessor?.artifactSha256 !== "e6b983a314db4f8c27d89062dfedf5ed0523cc30421170799d181a19e2d85d4c"
|
||||||
|
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|
||||||
|
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
|| descriptor.designSystem !== "nodedc-canonical-components-and-tokens-v1"
|
||||||
|
|| descriptor.missionCoreReference !== "compute-modules-workspace-71c8b04"
|
||||||
|
|| descriptor.infrastructureWorkspaceLayout !== "mission-core-system-workspace-v1"
|
||||||
|
|| descriptor.hostInventoryComposition !== "mission-core-compute-host-list-v1"
|
||||||
|
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-visual-parity-v2"
|
||||||
|
|| descriptor.telemetrySurface !== "borderless-soft-surface-v1"
|
||||||
|
|| descriptor.telemetryStatus !== "mission-core-dot-status-v1"
|
||||||
|
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|
||||||
|
|| descriptor.telemetryScroll !== "reset-on-workspace-transition-v1"
|
||||||
|
|| descriptor.telemetryPollInterval !== "three-seconds"
|
||||||
|
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|
||||||
|
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|
||||||
|
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|
||||||
|
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|
||||||
|
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
|| descriptor.gelios !== "untouched-legacy-only"
|
||||||
|
) throw new Error("device_manager_v11_mission_core_visual_parity_contract_mismatch");
|
||||||
|
await validateFaviconBundle(payload, "device_manager_v11");
|
||||||
|
} else if (descriptorPath.endsWith("release-v10.json")) {
|
||||||
|
if (
|
||||||
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v10"
|
||||||
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
|
|| descriptor.predecessor?.patchId !== "device-manager-release-v8-20260822-039"
|
||||||
|
|| descriptor.predecessor?.artifactSha256 !== "30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
|
||||||
|
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|
||||||
|
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|
||||||
|
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-parity-v1"
|
||||||
|
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|
||||||
|
|| descriptor.telemetryPollInterval !== "three-seconds"
|
||||||
|
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|
||||||
|
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|
||||||
|
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|
||||||
|
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|
||||||
|
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
|| descriptor.gelios !== "untouched-legacy-only"
|
||||||
|
) throw new Error("device_manager_v10_host_telemetry_workspace_contract_mismatch");
|
||||||
|
await validateFaviconBundle(payload, "device_manager_v10");
|
||||||
|
} else if (descriptorPath.endsWith("release-v9.json")) {
|
||||||
if (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v9"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v9"
|
||||||
|| descriptor.predecessor?.kind !== "release"
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
|
|||||||
@@ -1104,10 +1104,17 @@ def ensure_service_user(name=SERVICE_USER, home_dir="/var/lib/nodedc-b2-vps"):
|
|||||||
|
|
||||||
def extract_vendor_binary(archive: Path, member_name: str, target: Path, mode=0o755):
|
def extract_vendor_binary(archive: Path, member_name: str, target: Path, mode=0o755):
|
||||||
with tarfile.open(archive, "r:*") as package:
|
with tarfile.open(archive, "r:*") as package:
|
||||||
try:
|
accepted_names = {member_name, f"./{member_name}"}
|
||||||
member = package.getmember(member_name)
|
matches = [
|
||||||
except KeyError:
|
candidate
|
||||||
|
for candidate in package.getmembers()
|
||||||
|
if candidate.name in accepted_names
|
||||||
|
]
|
||||||
|
if not matches:
|
||||||
die(f"vendor binary member missing: {member_name}")
|
die(f"vendor binary member missing: {member_name}")
|
||||||
|
if len(matches) != 1:
|
||||||
|
die(f"vendor binary member ambiguous: {member_name}")
|
||||||
|
member = matches[0]
|
||||||
if not member.isfile() or member.issym() or member.islnk():
|
if not member.isfile() or member.issym() or member.islnk():
|
||||||
die(f"vendor binary member unsafe: {member_name}")
|
die(f"vendor binary member unsafe: {member_name}")
|
||||||
source = package.extractfile(member)
|
source = package.extractfile(member)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import importlib.machinery
|
import importlib.machinery
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
@@ -479,6 +480,52 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
"c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef",
|
"c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_vendor_binary_extractor_accepts_official_leading_dot_member(self):
|
||||||
|
member_name = "telegraf-1.38.4/usr/bin/telegraf"
|
||||||
|
binary = b"pinned-telegraf-binary"
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-vps-vendor-leading-dot-",
|
||||||
|
) as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
archive = root / "telegraf.tgz"
|
||||||
|
target = root / "runtime/telegraf"
|
||||||
|
with tarfile.open(archive, "w:gz") as package:
|
||||||
|
member = tarfile.TarInfo(f"./{member_name}")
|
||||||
|
member.size = len(binary)
|
||||||
|
package.addfile(member, io.BytesIO(binary))
|
||||||
|
with patch.object(RUNNER.os, "chown"):
|
||||||
|
RUNNER.extract_vendor_binary(
|
||||||
|
archive,
|
||||||
|
member_name,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
self.assertEqual(target.read_bytes(), binary)
|
||||||
|
self.assertEqual(target.stat().st_mode & 0o777, 0o755)
|
||||||
|
|
||||||
|
def test_vendor_binary_extractor_rejects_ambiguous_spelling(self):
|
||||||
|
member_name = "telegraf-1.38.4/usr/bin/telegraf"
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-vps-vendor-ambiguous-",
|
||||||
|
) as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
archive = root / "telegraf.tgz"
|
||||||
|
target = root / "runtime/telegraf"
|
||||||
|
with tarfile.open(archive, "w:gz") as package:
|
||||||
|
for name in (member_name, f"./{member_name}"):
|
||||||
|
member = tarfile.TarInfo(name)
|
||||||
|
member.size = 1
|
||||||
|
package.addfile(member, io.BytesIO(b"x"))
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RUNNER.DeployError,
|
||||||
|
"vendor binary member ambiguous",
|
||||||
|
):
|
||||||
|
RUNNER.extract_vendor_binary(
|
||||||
|
archive,
|
||||||
|
member_name,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
def test_publish_payload_preserves_unselected_executable_modes(self):
|
def test_publish_payload_preserves_unselected_executable_modes(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|||||||
@@ -723,6 +723,259 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_device_manager_release_v10_pins_successful_telemetry_core(self):
|
||||||
|
patch_id = "device-manager-release-v10-unit-001"
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-manager-control-plane-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V10_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(result["services"], ["device-manager"])
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
name.startswith("payload/services/device-control-core/")
|
||||||
|
for name in names
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(any(name.startswith("payload/packages/") for name in names))
|
||||||
|
template = json.loads(
|
||||||
|
(
|
||||||
|
DEVICE_CORE_ROOT
|
||||||
|
/ "deployment/device-manager-release-v10.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
descriptor = {**template, "releaseId": patch_id}
|
||||||
|
self.assertIs(
|
||||||
|
RUNNER.validate_device_plane_manager_release_descriptor(
|
||||||
|
descriptor,
|
||||||
|
schema_version=(
|
||||||
|
"nodedc.device-plane.device-manager-release.v10"
|
||||||
|
),
|
||||||
|
boundaries=(
|
||||||
|
RUNNER.expected_device_plane_manager_release_v10_boundaries()
|
||||||
|
),
|
||||||
|
expected_release_id=patch_id,
|
||||||
|
),
|
||||||
|
descriptor,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["predecessor"],
|
||||||
|
{
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v8-20260822-039",
|
||||||
|
"artifactSha256": (
|
||||||
|
"30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["controlCorePredecessor"],
|
||||||
|
{
|
||||||
|
"patchId": "device-control-core-release-v4-20260823-047",
|
||||||
|
"artifactSha256": (
|
||||||
|
"4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["telemetryWorkspace"],
|
||||||
|
"mission-core-compute-module-parity-v1",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
RUNNER.is_device_plane_manager_release_v10_slice(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_device_manager_release_v11_pins_v10_and_visual_parity(self):
|
||||||
|
patch_id = "device-manager-release-v11-unit-001"
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-manager-control-plane-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V11_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(result["services"], ["device-manager"])
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
name.startswith("payload/services/device-control-core/")
|
||||||
|
for name in names
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(any(name.startswith("payload/packages/") for name in names))
|
||||||
|
template = json.loads(
|
||||||
|
(
|
||||||
|
DEVICE_CORE_ROOT
|
||||||
|
/ "deployment/device-manager-release-v11.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
descriptor = {**template, "releaseId": patch_id}
|
||||||
|
self.assertIs(
|
||||||
|
RUNNER.validate_device_plane_manager_release_descriptor(
|
||||||
|
descriptor,
|
||||||
|
schema_version=(
|
||||||
|
"nodedc.device-plane.device-manager-release.v11"
|
||||||
|
),
|
||||||
|
boundaries=(
|
||||||
|
RUNNER.expected_device_plane_manager_release_v11_boundaries()
|
||||||
|
),
|
||||||
|
expected_release_id=patch_id,
|
||||||
|
),
|
||||||
|
descriptor,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["predecessor"],
|
||||||
|
{
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v10-20260823-048",
|
||||||
|
"artifactSha256": (
|
||||||
|
"e6b983a314db4f8c27d89062dfedf5ed0523cc30421170799d181a19e2d85d4c"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["telemetryWorkspace"],
|
||||||
|
"mission-core-compute-module-visual-parity-v2",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["telemetrySurface"],
|
||||||
|
"borderless-soft-surface-v1",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
RUNNER.is_device_plane_manager_release_v11_slice(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_device_manager_release_v12_pins_v11_and_adaptive_graphs(self):
|
||||||
|
patch_id = "device-manager-release-v12-unit-001"
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-manager-control-plane-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V12_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(result["services"], ["device-manager"])
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
name.startswith("payload/services/device-control-core/")
|
||||||
|
for name in names
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(any(name.startswith("payload/packages/") for name in names))
|
||||||
|
template = json.loads(
|
||||||
|
(
|
||||||
|
DEVICE_CORE_ROOT
|
||||||
|
/ "deployment/device-manager-release-v12.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
descriptor = {**template, "releaseId": patch_id}
|
||||||
|
self.assertIs(
|
||||||
|
RUNNER.validate_device_plane_manager_release_descriptor(
|
||||||
|
descriptor,
|
||||||
|
schema_version=(
|
||||||
|
"nodedc.device-plane.device-manager-release.v12"
|
||||||
|
),
|
||||||
|
boundaries=(
|
||||||
|
RUNNER.expected_device_plane_manager_release_v12_boundaries()
|
||||||
|
),
|
||||||
|
expected_release_id=patch_id,
|
||||||
|
),
|
||||||
|
descriptor,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["predecessor"],
|
||||||
|
{
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v11-20260823-049",
|
||||||
|
"artifactSha256": (
|
||||||
|
"c1e2056b50bfbb0d03d077461d0c27cc56cc52967c3f5620be14871c8a6d5cf0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["telemetryGraphScale"],
|
||||||
|
"adaptive-observed-window-explicit-domain-v1",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["telemetryNetworkMissingSemantics"],
|
||||||
|
"missing-counters-never-zero-v1",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
RUNNER.is_device_plane_manager_release_v12_slice(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_device_manager_release_v13_pins_v12_and_host_accordion(self):
|
||||||
|
patch_id = "device-manager-release-v13-unit-001"
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-manager-control-plane-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V13_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(result["services"], ["device-manager"])
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
name.startswith("payload/services/device-control-core/")
|
||||||
|
for name in names
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(any(name.startswith("payload/packages/") for name in names))
|
||||||
|
template = json.loads(
|
||||||
|
(
|
||||||
|
DEVICE_CORE_ROOT
|
||||||
|
/ "deployment/device-manager-release-v13.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
descriptor = {**template, "releaseId": patch_id}
|
||||||
|
self.assertIs(
|
||||||
|
RUNNER.validate_device_plane_manager_release_descriptor(
|
||||||
|
descriptor,
|
||||||
|
schema_version=(
|
||||||
|
"nodedc.device-plane.device-manager-release.v13"
|
||||||
|
),
|
||||||
|
boundaries=(
|
||||||
|
RUNNER.expected_device_plane_manager_release_v13_boundaries()
|
||||||
|
),
|
||||||
|
expected_release_id=patch_id,
|
||||||
|
),
|
||||||
|
descriptor,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["predecessor"],
|
||||||
|
{
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v12-20260823-050",
|
||||||
|
"artifactSha256": (
|
||||||
|
"1a49839140e5f2e49763d78f24ee47d946e244bcfde15a9c38266e8bd14c0d49"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["hostInventoryRow"],
|
||||||
|
"compact-centered-accordion-v1",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["hostInventoryRelations"],
|
||||||
|
"host-scoped-endpoint-deployment-service-v1",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["telemetryWorkspace"],
|
||||||
|
"mission-core-compute-module-adaptive-window-v3",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
RUNNER.is_device_plane_manager_release_v13_slice(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_historical_manager_builder_fails_closed_after_v4_compose(self):
|
def test_historical_manager_builder_fails_closed_after_v4_compose(self):
|
||||||
if self.historical_manager_compose_is_current():
|
if self.historical_manager_compose_is_current():
|
||||||
self.skipTest("historical Manager Compose is still current")
|
self.skipTest("historical Manager Compose is still current")
|
||||||
@@ -1107,6 +1360,93 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(descriptor["gelios"], "untouched-legacy-only")
|
self.assertEqual(descriptor["gelios"], "untouched-legacy-only")
|
||||||
|
|
||||||
|
def test_control_core_release_v4_is_recovery_pinned_telemetry_core_only(self):
|
||||||
|
patch_id = "device-control-core-release-v4-20260823-047"
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-control-core-release-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V4_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(result["services"], ["device-control-core"])
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_services("device-plane", entries),
|
||||||
|
("device-control-core",),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"payload/deployment/device-control-core-release-v4.json",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"payload/packages/infrastructure-telemetry-contract/src/index.mjs",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"payload/services/device-control-core/migrations/"
|
||||||
|
"017_infrastructure_host_telemetry.sql",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
self.assertFalse(any("docker-compose" in name for name in names))
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-control-core-v4-read-",
|
||||||
|
) as directory:
|
||||||
|
result = self.build(
|
||||||
|
"build-device-control-core-release-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
extracted = Path(directory) / "extracted"
|
||||||
|
extracted.mkdir()
|
||||||
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||||
|
Path(result["artifact"]),
|
||||||
|
extracted,
|
||||||
|
)
|
||||||
|
descriptor = json.loads(
|
||||||
|
(
|
||||||
|
payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V4_REL
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["predecessor"],
|
||||||
|
{
|
||||||
|
"kind": "migration-replay-checkpoint-recovery",
|
||||||
|
"patchId": (
|
||||||
|
"device-control-core-migration-replay-checkpoint-"
|
||||||
|
"recovery-20260822-046"
|
||||||
|
),
|
||||||
|
"artifactSha256": (
|
||||||
|
"46000c76977fb583fc7c9cf74ecf624efd8b404f7b8d0322e0270e7b8ac6e450"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["databaseSchemaOutcome"],
|
||||||
|
"migration-017-host-telemetry-table-present",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_control_core_release_v4_rejects_any_other_identity(self):
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-control-core-v4-wrong-id-",
|
||||||
|
) as directory:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(
|
||||||
|
SCRIPT_DIR
|
||||||
|
/ "build-device-control-core-release-artifact.mjs"
|
||||||
|
),
|
||||||
|
"device-control-core-release-v4-20260823-999",
|
||||||
|
],
|
||||||
|
cwd=DEVICE_CORE_ROOT,
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(completed.returncode, 0)
|
||||||
|
self.assertIn("usage:", completed.stderr)
|
||||||
|
|
||||||
def test_control_core_release_builder_supports_release_predecessor(self):
|
def test_control_core_release_builder_supports_release_predecessor(self):
|
||||||
if not self.historical_control_core_builders_are_current():
|
if not self.historical_control_core_builders_are_current():
|
||||||
self.skipTest("historical Core release v1 builder generation is frozen")
|
self.skipTest("historical Core release v1 builder generation is frozen")
|
||||||
|
|||||||
Reference in New Issue
Block a user