feat(telemetry): add canonical VPS host monitoring
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
DeviceManagerSession,
|
||||
EdgeView,
|
||||
InfrastructureHostView,
|
||||
InfrastructureServiceInstanceView,
|
||||
ModelProfileView,
|
||||
ProjectWorkspace,
|
||||
} from "./types";
|
||||
@@ -79,12 +81,14 @@ export function DeviceControlView({
|
||||
workspace,
|
||||
session,
|
||||
onRefresh,
|
||||
onPoll,
|
||||
onError,
|
||||
}: {
|
||||
view: ControlViewId;
|
||||
workspace: ProjectWorkspace;
|
||||
session: DeviceManagerSession;
|
||||
onRefresh: () => Promise<void>;
|
||||
onPoll: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<DialogId>(null);
|
||||
@@ -181,6 +185,8 @@ export function DeviceControlView({
|
||||
assetBindingRef: binding.assetBindingRef,
|
||||
validTo: new Date().toISOString(),
|
||||
}))}
|
||||
onPoll={onPoll}
|
||||
onError={onError}
|
||||
/>
|
||||
) : null}
|
||||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||||
@@ -472,6 +478,8 @@ function HostsView({
|
||||
onCreateAsset,
|
||||
onCreateAssetBinding,
|
||||
onCloseAssetBinding,
|
||||
onPoll,
|
||||
onError,
|
||||
}: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManageInfrastructure: boolean;
|
||||
@@ -485,8 +493,25 @@ function HostsView({
|
||||
onCreateAsset: () => void;
|
||||
onCreateAssetBinding: () => void;
|
||||
onCloseAssetBinding: (binding: AssetBindingView) => void;
|
||||
onPoll: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [selectedHostRef, setSelectedHostRef] = useState<string | null>(null);
|
||||
const topology = workspace.ontology;
|
||||
const selectedHost = selectedHostRef
|
||||
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
|
||||
: null;
|
||||
if (selectedHost) {
|
||||
return (
|
||||
<HostTelemetryWorkspace
|
||||
host={selectedHost}
|
||||
services={topology.serviceInstances.filter((service) => service.hostRef === selectedHost.hostRef)}
|
||||
onBack={() => setSelectedHostRef(null)}
|
||||
onPoll={onPoll}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
@@ -517,9 +542,12 @@ function HostsView({
|
||||
`${hostEndpoints.length} endpoints · ${hostServices.length} services`,
|
||||
`management · ${host.managementCredentialConfigured ? "configured" : "unconfigured"}`,
|
||||
]}
|
||||
action={canManageInfrastructure ? (
|
||||
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
|
||||
) : null}
|
||||
action={<>
|
||||
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
|
||||
{canManageInfrastructure ? (
|
||||
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
|
||||
) : null}
|
||||
</>}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -624,6 +652,245 @@ function HostsView({
|
||||
);
|
||||
}
|
||||
|
||||
function HostTelemetryWorkspace({
|
||||
host,
|
||||
services,
|
||||
onBack,
|
||||
onPoll,
|
||||
onError,
|
||||
}: {
|
||||
host: InfrastructureHostView;
|
||||
services: InfrastructureServiceInstanceView[];
|
||||
onBack: () => void;
|
||||
onPoll: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const timer = window.setInterval(() => {
|
||||
onPoll().catch((reason) => active && onError(reason));
|
||||
}, 3_000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [onError, onPoll]);
|
||||
|
||||
const telemetry = host.telemetry;
|
||||
const current = telemetry.current;
|
||||
const networkRate = calculateNetworkRate(telemetry.history);
|
||||
const cpuHistory = telemetry.history.map((sample) => sample.cpuUsagePercent);
|
||||
const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent);
|
||||
const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received");
|
||||
const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent");
|
||||
const runtimeServices = current?.services ?? [];
|
||||
|
||||
return (
|
||||
<div className="host-telemetry-workspace">
|
||||
<header className="host-telemetry-header">
|
||||
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
|
||||
<Icon name="chevron-left" size={18} />
|
||||
</IconButton>
|
||||
<div className="host-telemetry-header__copy">
|
||||
<small>СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</small>
|
||||
<h2>{host.displayName}</h2>
|
||||
<p>Аппаратный и процессинговый срез VPS. Метрики снимает host-agent; Device Core хранит только канонические наблюдения.</p>
|
||||
</div>
|
||||
<div className="host-telemetry-header__actions">
|
||||
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : telemetry.freshness === "stale" ? "warning" : undefined}>
|
||||
{telemetry.freshness === "fresh" ? "Свежие данные" : telemetry.freshness === "stale" ? "Данные устарели" : "Нет данных"}
|
||||
</StatusBadge>
|
||||
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
|
||||
<Icon name="refresh" size={17} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="host-telemetry-metric-grid" aria-label="Ключевые метрики VPS">
|
||||
<TelemetryMetricCard label="CPU" value={formatPercent(current?.cpu.usagePercent)} points={cpuHistory} detail={formatLoad(current?.cpu)} />
|
||||
<TelemetryMetricCard label="RAM" value={formatPercent(current?.memory.usedPercent)} points={memoryHistory} detail={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
|
||||
<TelemetryMetricCard label="NETWORK RX" value={formatRate(networkRate.received)} points={receiveHistory} detail="входящий трафик" />
|
||||
<TelemetryMetricCard label="NETWORK TX" value={formatRate(networkRate.sent)} points={sendHistory} detail="исходящий трафик" />
|
||||
</section>
|
||||
|
||||
<section className="host-telemetry-section">
|
||||
<div className="host-telemetry-section__heading">
|
||||
<div><small>HARDWARE</small><h3>{current?.hardware.hostname ?? host.hostKey}</h3></div>
|
||||
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : "warning"}>{telemetry.state}</StatusBadge>
|
||||
</div>
|
||||
<div className="host-telemetry-facts">
|
||||
<TelemetryFact label="Процессор" value={current?.hardware.cpuModel} />
|
||||
<TelemetryFact label="Логические ядра" value={formatNullable(current?.hardware.logicalProcessors)} />
|
||||
<TelemetryFact label="Память занята" value={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
|
||||
<TelemetryFact label="Uptime" value={formatDuration(current?.system.uptimeSeconds)} />
|
||||
<TelemetryFact label="Платформа" value={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || null} />
|
||||
<TelemetryFact label="Kernel" value={current?.hardware.kernelRelease} />
|
||||
<TelemetryFact label="Процессы" value={formatNullable(current?.system.processes.total)} />
|
||||
<TelemetryFact label="Load average" value={formatLoad(current?.cpu)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="host-telemetry-split">
|
||||
<section className="host-telemetry-section">
|
||||
<div className="host-telemetry-section__heading"><div><small>STORAGE</small><h3>Файловые системы</h3></div><StatusBadge>{current?.disks.length ?? 0}</StatusBadge></div>
|
||||
<div className="host-telemetry-list">
|
||||
{(current?.disks ?? []).map((disk, index) => (
|
||||
<div className="host-telemetry-list__row" key={`${disk.device}:${disk.mount}:${index}`}>
|
||||
<span><strong>{disk.mount ?? disk.device ?? "Диск"}</strong><small>{[disk.device, disk.filesystem].filter(Boolean).join(" · ")}</small></span>
|
||||
<span><strong>{formatPercent(disk.usedPercent)}</strong><small>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</small></span>
|
||||
</div>
|
||||
))}
|
||||
{!current?.disks.length ? <div className="device-manager-panel-empty">Данные о дисках ещё не поступили.</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="host-telemetry-section">
|
||||
<div className="host-telemetry-section__heading"><div><small>NETWORK</small><h3>Сетевые интерфейсы</h3></div><StatusBadge>{current?.network.length ?? 0}</StatusBadge></div>
|
||||
<div className="host-telemetry-list">
|
||||
{(current?.network ?? []).filter((item) => item.interface !== "lo").map((item, index) => (
|
||||
<div className="host-telemetry-list__row" key={`${item.interface}:${index}`}>
|
||||
<span><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></span>
|
||||
<span><strong>↓ {formatMetricBytes(item.bytesReceived)}</strong><small>↑ {formatMetricBytes(item.bytesSent)}</small></span>
|
||||
</div>
|
||||
))}
|
||||
{!current?.network.filter((item) => item.interface !== "lo").length ? <div className="device-manager-panel-empty">Сетевые счётчики ещё не поступили.</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="host-telemetry-section">
|
||||
<div className="host-telemetry-section__heading">
|
||||
<div><small>PROCESSING RUNTIME</small><h3>Сервисы VPS</h3><p>Состояние systemd-юнитов и их ресурсный профиль.</p></div>
|
||||
<StatusBadge tone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : "success"}>{runtimeServices.length} units</StatusBadge>
|
||||
</div>
|
||||
<div className="host-telemetry-service-grid">
|
||||
{runtimeServices.map((service, index) => (
|
||||
<div className="host-telemetry-service" key={`${service.name}:${index}`}>
|
||||
<span><strong>{service.name ?? "systemd unit"}</strong><small>{service.subState ?? service.loadState ?? "—"}</small></span>
|
||||
<span><StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge><small>{formatMetricBytes(service.memoryBytes)}</small></span>
|
||||
</div>
|
||||
))}
|
||||
{!runtimeServices.length ? <div className="device-manager-panel-empty">Состояние сервисов ещё не поступило.</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="host-telemetry-section host-telemetry-evidence">
|
||||
<div><small>ONTOLOGY / OBSERVATION</small><strong>{telemetry.observation?.entityId ?? "observation.observation"}</strong></div>
|
||||
<div><small>Источник</small><strong>{current ? `${current.source.agent} ${current.source.agentVersion}` : "—"}</strong></div>
|
||||
<div><small>Последнее наблюдение</small><strong>{formatDate(telemetry.observedAt)}</strong></div>
|
||||
<div><small>Связанные сервисы</small><strong>{services.length}</strong></div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TelemetryMetricCard({ label, value, points, detail }: { label: string; value: string; points: Array<number | null>; detail: string }) {
|
||||
return (
|
||||
<div className="host-telemetry-metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<Sparkline values={points} />
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({ values }: { values: Array<number | null> }) {
|
||||
const normalized = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
if (normalized.length < 2) return <div className="host-telemetry-sparkline host-telemetry-sparkline--empty" />;
|
||||
const minimum = Math.min(...normalized);
|
||||
const maximum = Math.max(...normalized);
|
||||
const spread = Math.max(1, maximum - minimum);
|
||||
const points = normalized.map((value, index) => {
|
||||
const x = (index / (normalized.length - 1)) * 100;
|
||||
const y = 28 - ((value - minimum) / spread) * 24;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(" ");
|
||||
return <svg className="host-telemetry-sparkline" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true"><polyline points={points} /></svg>;
|
||||
}
|
||||
|
||||
function TelemetryFact({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return <div><small>{label}</small><strong>{value || "—"}</strong></div>;
|
||||
}
|
||||
|
||||
function calculateNetworkRate(history: InfrastructureHostView["telemetry"]["history"]) {
|
||||
if (history.length < 2) return { received: null, sent: null };
|
||||
const previous = history[history.length - 2];
|
||||
const latest = history[history.length - 1];
|
||||
const seconds = (new Date(latest.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000;
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return { received: null, sent: null };
|
||||
const previousTotals = networkTotals(previous.network);
|
||||
const latestTotals = networkTotals(latest.network);
|
||||
return {
|
||||
received: nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
|
||||
sent: nonNegativeRate(latestTotals.sent - previousTotals.sent, seconds),
|
||||
};
|
||||
}
|
||||
|
||||
function calculateNetworkRateHistory(history: InfrastructureHostView["telemetry"]["history"], direction: "received" | "sent") {
|
||||
return history.slice(1).map((sample, index) => {
|
||||
const previous = history[index];
|
||||
const seconds = (new Date(sample.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000;
|
||||
if (seconds <= 0) return null;
|
||||
const currentTotals = networkTotals(sample.network);
|
||||
const previousTotals = networkTotals(previous.network);
|
||||
return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds);
|
||||
});
|
||||
}
|
||||
|
||||
function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) {
|
||||
return network.filter((item) => item.interface !== "lo").reduce((total, item) => ({
|
||||
received: total.received + (item.bytesReceived ?? 0),
|
||||
sent: total.sent + (item.bytesSent ?? 0),
|
||||
}), { received: 0, sent: 0 });
|
||||
}
|
||||
|
||||
function nonNegativeRate(bytes: number, seconds: number) {
|
||||
const value = bytes / seconds;
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
return value == null ? "—" : `${value.toFixed(value >= 10 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null) {
|
||||
return value == null ? "—" : `${formatMetricBytes(value)}/s`;
|
||||
}
|
||||
|
||||
function formatMetricBytes(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return "—";
|
||||
if (value < 1024) return `${Math.round(value)} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MiB`;
|
||||
return `${(value / 1024 ** 3).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
function formatUsedTotal(used: number | null | undefined, total: number | null | undefined) {
|
||||
return used == null || total == null ? "—" : `${formatMetricBytes(used)} / ${formatMetricBytes(total)}`;
|
||||
}
|
||||
|
||||
function formatNullable(value: number | null | undefined) {
|
||||
return value == null ? "—" : new Intl.NumberFormat("ru-RU").format(value);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null | undefined) {
|
||||
if (seconds == null) return "—";
|
||||
const days = Math.floor(seconds / 86_400);
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
return [days ? `${days} д` : null, hours ? `${hours} ч` : null, `${minutes} мин`].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function formatLoad(cpu: { load1: number | null; load5: number | null; load15: number | null } | null | undefined) {
|
||||
if (!cpu || cpu.load1 == null) return "load average —";
|
||||
return `load ${[cpu.load1, cpu.load5, cpu.load15].map((value) => value?.toFixed(2) ?? "—").join(" / ")}`;
|
||||
}
|
||||
|
||||
function formatPackets(received: number | null | undefined, sent: number | null | undefined) {
|
||||
return `↓ ${formatNullable(received)} пакетов · ↑ ${formatNullable(sent)} пакетов`;
|
||||
}
|
||||
|
||||
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<ControlStack>
|
||||
|
||||
@@ -806,6 +806,7 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, canConfi
|
||||
workspace={workspace}
|
||||
session={session}
|
||||
onRefresh={onRefresh}
|
||||
onPoll={onPoll}
|
||||
onError={onError}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,231 @@
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.host-telemetry-workspace {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.host-telemetry-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.host-telemetry-header__copy {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.host-telemetry-header__copy small,
|
||||
.host-telemetry-section__heading small,
|
||||
.host-telemetry-evidence small {
|
||||
color: var(--nodedc-text-tertiary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.host-telemetry-header__copy h2,
|
||||
.host-telemetry-header__copy p,
|
||||
.host-telemetry-section__heading h3,
|
||||
.host-telemetry-section__heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.host-telemetry-header__copy h2 {
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.host-telemetry-header__copy p,
|
||||
.host-telemetry-section__heading p {
|
||||
color: var(--nodedc-text-tertiary);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.host-telemetry-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.host-telemetry-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.host-telemetry-metric,
|
||||
.host-telemetry-section {
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
border-radius: var(--nodedc-radius-lg);
|
||||
background: color-mix(in srgb, var(--nodedc-surface) 82%, transparent);
|
||||
}
|
||||
|
||||
.host-telemetry-metric {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 5px 12px;
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.host-telemetry-metric > span,
|
||||
.host-telemetry-metric > small,
|
||||
.host-telemetry-facts small,
|
||||
.host-telemetry-list__row small,
|
||||
.host-telemetry-service small {
|
||||
color: var(--nodedc-text-tertiary);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.host-telemetry-metric > strong {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.host-telemetry-metric > small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.host-telemetry-sparkline {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.host-telemetry-sparkline polyline {
|
||||
fill: none;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.host-telemetry-sparkline--empty {
|
||||
border-bottom: 1px solid var(--nodedc-glass-outline);
|
||||
}
|
||||
|
||||
.host-telemetry-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.host-telemetry-section__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.host-telemetry-section__heading > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.host-telemetry-section__heading h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.host-telemetry-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-md);
|
||||
background: var(--nodedc-glass-outline);
|
||||
}
|
||||
|
||||
.host-telemetry-facts > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: var(--nodedc-surface-soft);
|
||||
}
|
||||
|
||||
.host-telemetry-facts strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 590;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.host-telemetry-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.host-telemetry-list,
|
||||
.host-telemetry-service-grid {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.host-telemetry-list__row,
|
||||
.host-telemetry-service {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nodedc-radius-md);
|
||||
background: var(--nodedc-surface-soft);
|
||||
}
|
||||
|
||||
.host-telemetry-list__row > span,
|
||||
.host-telemetry-service > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.host-telemetry-list__row > span:last-child,
|
||||
.host-telemetry-service > span:last-child {
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.host-telemetry-list__row strong,
|
||||
.host-telemetry-service strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 590;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.host-telemetry-service-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.host-telemetry-evidence {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.host-telemetry-evidence > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.host-telemetry-evidence strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.device-control-resource-grid,
|
||||
.device-control-policy-grid {
|
||||
@@ -168,6 +393,23 @@
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.host-telemetry-header {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.host-telemetry-header__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.host-telemetry-metric-grid,
|
||||
.host-telemetry-facts,
|
||||
.host-telemetry-service-grid,
|
||||
.host-telemetry-evidence,
|
||||
.host-telemetry-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -358,9 +358,63 @@ export interface InfrastructureHostView {
|
||||
managementCredentialConfigured: boolean;
|
||||
lifecycleState: string;
|
||||
health: HealthProjectionView;
|
||||
telemetry: InfrastructureHostTelemetryView;
|
||||
ontology: OntologyRefView;
|
||||
}
|
||||
|
||||
export interface InfrastructureHostTelemetrySnapshot {
|
||||
schemaVersion: "nodedc.infrastructure.host-telemetry.v1";
|
||||
profile: "linux-host-telegraf-v1";
|
||||
hostKey: string;
|
||||
observedAt: string;
|
||||
source: { agent: string; agentVersion: string; collectorRef: string };
|
||||
hardware: {
|
||||
hostname: string | null;
|
||||
architecture: string | null;
|
||||
platform: string | null;
|
||||
kernelRelease: string | null;
|
||||
cpuModel: string | null;
|
||||
logicalProcessors: number | null;
|
||||
};
|
||||
cpu: { usagePercent: number | null; load1: number | null; load5: number | null; load15: number | null };
|
||||
memory: { totalBytes: number | null; availableBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null };
|
||||
swap: { totalBytes: number | null; availableBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null };
|
||||
system: {
|
||||
uptimeSeconds: number | null;
|
||||
users: number | null;
|
||||
processes: { total: number | null; running: number | null; sleeping: number | null; blocked: number | null; zombies: number | null };
|
||||
};
|
||||
disks: Array<{ device: string | null; mount: string | null; filesystem: string | null; totalBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null }>;
|
||||
network: Array<{ interface: string | null; bytesReceived: number | null; bytesSent: number | null; packetsReceived: number | null; packetsSent: number | null; errorsReceived: number | null; errorsSent: number | null; droppedReceived: number | null; droppedSent: number | null }>;
|
||||
services: Array<{ name: string | null; loadState: string | null; activeState: string | null; subState: string | null; memoryBytes: number | null; restarts: number | null; pid: number | null }>;
|
||||
}
|
||||
|
||||
export interface InfrastructureHostTelemetryView {
|
||||
state: string;
|
||||
freshness: "fresh" | "stale" | "missing";
|
||||
observedAt: string | null;
|
||||
receivedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
current: InfrastructureHostTelemetrySnapshot | null;
|
||||
history: Array<{
|
||||
observedAt: string;
|
||||
cpuUsagePercent: number | null;
|
||||
memoryUsedPercent: number | null;
|
||||
network: Array<{ interface: string | null; bytesReceived: number | null; bytesSent: number | null }>;
|
||||
}>;
|
||||
observation: null | {
|
||||
observationRef: string;
|
||||
entityId: string;
|
||||
catalogHash: string;
|
||||
targetRef: string;
|
||||
serviceInstanceRef: string;
|
||||
edgeRef: string;
|
||||
profileRef: string;
|
||||
source: { agent: string; agentVersion: string; collectorRef: string; provenanceRef: string };
|
||||
observedProperties: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface InfrastructureEndpointView {
|
||||
endpointRef: string;
|
||||
hostRef: string;
|
||||
|
||||
Reference in New Issue
Block a user