feat(manager): expose safe VPS edge health

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 13:08:04 +03:00
parent 07ffb22d38
commit 6ed4414a97
14 changed files with 464 additions and 42 deletions
@@ -439,6 +439,12 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
displayName: input.displayName,
deploymentRef: input.deploymentRef ?? null,
lifecycleState: input.lifecycleState ?? "provisioning",
channel: existing?.channel ?? {
lifecycleState: "disabled",
generationRef: null,
runtimeState: "disabled",
lastErrorCode: null,
},
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
@@ -716,6 +722,12 @@ function seedArusnaviB2Preview({
displayName: "Preview VPS edge",
deploymentRef: "deployment:preview",
lifecycleState: "active",
channel: {
lifecycleState: "active",
generationRef: "channel-generation:preview",
runtimeState: "accepted",
lastErrorCode: null,
},
createdAt: timestamp,
updatedAt: timestamp,
});
+55 -3
View File
@@ -38,6 +38,7 @@ import type {
export type ControlViewId =
| "catalog"
| "infrastructure"
| "hosts"
| "sessions"
| "bindings"
| "commands"
@@ -145,6 +146,13 @@ export function DeviceControlView({
}))}
/>
) : null}
{view === "hosts" ? (
<HostsView
workspace={workspace}
canManage={platformOwner}
onCreateEdge={() => setDialog("edge")}
/>
) : null}
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
{view === "bindings" ? (
<BindingsView
@@ -372,6 +380,50 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
);
}
function HostsView({ workspace, canManage, onCreateEdge }: {
workspace: ProjectWorkspace;
canManage: boolean;
onCreateEdge: () => void;
}) {
return (
<ControlStack>
<ControlToolbar
copy="VPS-хосты показаны через зарегистрированную роль Edge: состояние берётся из pinned mTLS Core↔Edge канала, а связи — из маршрутов проекта. Адреса, ключи и credentials в браузер не выдаются."
actions={canManage ? <Button size="compact" variant="primary" onClick={onCreateEdge}>Новый VPS Edge</Button> : null}
/>
<ControlSection title="VPS и Edge-хосты" count={workspace.edges.length}>
<ResourceGrid empty="VPS/Edge-хосты для проекта пока не зарегистрированы.">
{workspace.edges.map((edge) => {
const routes = workspace.routes.filter((route) => route.edgeRef === edge.edgeRef);
const runtimeState = edge.channel?.runtimeState ?? "unobserved";
return (
<ResourceCard
key={edge.edgeRef}
eyebrow="VPS / EDGE HOST"
title={edge.displayName}
description={edge.deploymentRef || edge.edgeKey}
status={runtimeState}
meta={[
`registration · ${edge.lifecycleState}`,
`channel · ${edge.channel?.lifecycleState ?? "disabled"}`,
`${routes.length} ${routes.length === 1 ? "маршрут" : "маршрутов"}`,
...(edge.channel?.generationRef ? [edge.channel.generationRef] : []),
...(edge.channel?.lastErrorCode ? [`error · ${edge.channel.lastErrorCode}`] : []),
]}
/>
);
})}
</ResourceGrid>
</ControlSection>
<GlassSurface padding="md" tone="soft">
<p className="device-manager-card-copy">
Общий реестр произвольных VPS, deployments, services и управляемая консоль требуют отдельного канонического ontology package. Текущий экран намеренно отображает только уже существующую проверяемую Edge-инфраструктуру.
</p>
</GlassSurface>
</ControlStack>
);
}
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
return (
<ControlStack>
@@ -931,9 +983,9 @@ function commaList(value: string) {
}
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
if (["active", "accepted", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
if (["absent", "failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
if (["connecting", "draft", "provisioning", "pending", "pending_external_approval", "unknown", "unobserved", "disabled"].includes(status)) return "warning";
return "neutral";
}
+4 -2
View File
@@ -98,6 +98,7 @@ const sectionNavigation: Record<PrimarySection, NavigationItem[]> = {
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
],
infrastructure: [
{ id: "hosts", label: "VPS и хосты", icon: "building", capability: "telemetry.observe" },
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
],
@@ -799,7 +800,7 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, canConfi
onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void;
}) {
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект</div>;
if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
if (["catalog", "hosts", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
return <DeviceControlView
view={view as ControlViewId}
workspace={workspace}
@@ -1455,6 +1456,7 @@ function viewTitle(view: ViewId) {
inventory: "Устройства",
collections: "Коллекции",
catalog: "Модели и адаптеры",
hosts: "VPS и хосты",
infrastructure: "Edges и маршруты",
sessions: "Gateway sessions",
bindings: "Data bindings",
@@ -1468,7 +1470,7 @@ function viewTitle(view: ViewId) {
function sectionForView(view: ViewId): PrimarySection {
if (view === "overview") return "overview";
if (view === "inventory" || view === "collections" || view === "sessions") return "devices";
if (view === "catalog" || view === "infrastructure") return "infrastructure";
if (view === "catalog" || view === "hosts" || view === "infrastructure") return "infrastructure";
if (view === "bindings" || view === "commands" || view === "settings") return "management";
return "administration";
}
+13 -1
View File
@@ -86,10 +86,15 @@
.device-control-command-form {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
align-items: start;
gap: 14px;
}
.device-control-command-form > .nodedc-select-anchor,
.device-control-command-form > .nodedc-button {
margin-top: 1.35rem;
}
.device-control-command-policy p {
margin-top: 5px;
color: var(--nodedc-text-secondary);
@@ -145,6 +150,11 @@
grid-template-columns: 1fr;
}
.device-control-command-form > .nodedc-select-anchor,
.device-control-command-form > .nodedc-button {
margin-top: 0;
}
.device-control-command-policy > .nodedc-status {
grid-column: 2;
justify-self: start;
@@ -526,6 +536,8 @@ body {
min-height: 220px;
place-items: center;
color: var(--nodedc-text-secondary);
font-size: 0.82rem;
line-height: 1.55;
text-align: center;
}
+6
View File
@@ -194,6 +194,12 @@ export interface EdgeView {
displayName: string;
deploymentRef: string | null;
lifecycleState: string;
channel: {
lifecycleState: string;
generationRef: string | null;
runtimeState: "accepted" | "connecting" | "absent" | "unobserved" | "disabled" | "revoked" | string;
lastErrorCode: string | null;
};
createdAt: string | null;
updatedAt: string | null;
}