From 36e3b23b4593acea5053fcc3686f830489e9e898 Mon Sep 17 00:00:00 2001 From: breis Date: Mon, 27 Jul 2026 17:13:28 -0400 Subject: [PATCH] Scoped-commands wave: scope-driven addressing UI, keepalive instance state Adopts the core 0.5.0 scoped-commands release (DESIGN-scoped-commands.md). Gateway - Builds against edgecommons 0.5.0; Cargo.lock records the bump. The gateway registers no command handlers of its own, so the breaking (verb, scope, handler) registration surface needed no call-site migration. - Descriptor normalization keeps command entries verbatim, so each verb's new `scope` reaches the browser; tests pin that for both the array and the {verbs: []} wrapper form. - The state keepalive's per-instance `state` token is forwarded through instance-status normalization (it was dropped before), blank/non-string tokens excluded. Protocol - CommandCapability.scope widens to component | instance | both. - InstanceStatus gains the optional `state`, plus instanceState() / isPausedInstance() for the CONNECTING/ONLINE/BACKOFF/PAUSED vocabulary; unknown tokens resolve to undefined so consumers fall back to `connected`. UI - The Panel tab derives its addressing from each verb's declared scope: `instance` mounts the selector and always sends the selected instance; `component` never involves the selector and never sends one; `both` adds an explicit "Whole component" option that sends no instance. A verb with no declared scope keeps today's widget-scope heuristics. "Whole component" is offered only when every instance-addressable widget in the view declares `both`, and is dropped when the operator opens a view that requires an instance. - The Instances tab renders the keepalive state with badge/tone mapping and marks PAUSED as expected quiet; the instance selector shows it too. - Pause-aware quiet handling: the console's miss-detection ladder keys on the component keepalive, never on instances, so a paused instance cannot trip it. The surface that did read a pause as a fault was the Health tab's aggregate connection state, which now excludes paused instances from the connected ratio and reports an all-paused component as Paused rather than Disconnected. - Absent/unknown state keeps the connectivity-only rendering everywhere. Docs: data-types (InstanceStatus state table, command-capability shape and the addressing it drives), messaging-interface (how an instance is addressed, state class), explanation (why a pause never trips staleness), user guide (Instances and Panel tabs), and the Phase-3 descriptor design. --- Cargo.lock | 2 +- docs/design/PHASE3-DESCRIPTOR-PANELS.md | 15 +- docs/explanation.md | 5 + docs/reference/data-types.md | 54 +++- docs/reference/messaging-interface.md | 9 +- docs/user-guide.md | 16 ++ gateway/src/command.rs | 35 +++ gateway/src/model.rs | 77 ++++++ protocol/src/index.ts | 64 ++++- protocol/test/protocol-ws.test.ts | 24 ++ ui/src/components/ComponentDetailView.tsx | 176 ++++++++----- ui/src/components/detail-selectors.ts | 69 ++++- ui/src/components/panel-descriptor.ts | 134 ++++++++++ ui/test/component-detail-view.test.tsx | 52 ++++ ui/test/detail-selectors.test.ts | 102 ++++++++ ui/test/panel-scope.test.tsx | 293 ++++++++++++++++++++++ 16 files changed, 1049 insertions(+), 78 deletions(-) create mode 100644 ui/test/panel-scope.test.tsx diff --git a/Cargo.lock b/Cargo.lock index 0d1457a..3edc7d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1509,7 +1509,7 @@ dependencies = [ [[package]] name = "edgecommons" -version = "0.3.0" +version = "0.5.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/docs/design/PHASE3-DESCRIPTOR-PANELS.md b/docs/design/PHASE3-DESCRIPTOR-PANELS.md index a4e6024..c28921e 100644 --- a/docs/design/PHASE3-DESCRIPTOR-PANELS.md +++ b/docs/design/PHASE3-DESCRIPTOR-PANELS.md @@ -50,11 +50,12 @@ Add a built-in `describe` command to every `CommandInbox`. }, "digest": "sha256:...", "commands": [ - { "verb": "ping", "builtIn": true }, - { "verb": "describe", "builtIn": true }, - { "verb": "get-configuration", "builtIn": true }, + { "verb": "ping", "builtIn": true, "scope": "both" }, + { "verb": "describe", "builtIn": true, "scope": "both" }, + { "verb": "get-configuration", "builtIn": true, "scope": "both" }, { "verb": "reload-config", "builtIn": true }, - { "verb": "sb/browse", "builtIn": false } + { "verb": "sb/browse", "builtIn": false, "scope": "instance" }, + { "verb": "sb/discover", "builtIn": false, "scope": "component" } ], "panels": { "schemaVersion": "edgecommons.panels.v2", @@ -72,6 +73,12 @@ lexicographically for deterministic discovery. The verb list is the source of ca the console. If a verb is absent, UI bound to that verb must render unavailable and must not invoke it. +Each entry carries the verb's declared `scope` — `component`, `instance`, or `both` (core +`DESIGN-scoped-commands.md` §2.2/§2.3) — and the Panel tab derives its addressing UI from it: the +instance selector mounts for `instance`, is never involved for `component`, and gains an explicit +"Whole component" choice for `both`. A verb that declares no scope (a pre-0.5.0 component) falls back +to the widget-level `scope` markers below. + The `digest` is computed over the command capability list and panel descriptor payload. It is not security-critical in this slice; it exists so the console can cache and refresh manifests without confusing stale views for current component truth. The digest is computed over deterministic JSON diff --git a/docs/explanation.md b/docs/explanation.md index 63a5271..2c70971 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -97,6 +97,11 @@ keepalive backbone. It lives in the console because a consumer is the only party counter ticks) — distinct from a silence gap. - **STOPPED is an explicit truth**, not staleness — so it doesn't decay. It holds until a RUNNING state returns. +- **A paused instance is expected quiet.** The ladder measures the component's `state` keepalive, which + keeps ticking while one of its connections is deliberately paused — so a pause never trips staleness. + The console reads the instance's own `PAUSED` state out of that keepalive's `instances[]` and keeps it + out of the component's connected-instance count, so an intentional pause reads as intentional + everywhere rather than as a connection fault. - **Whole-device UNREACHABLE.** When the bridge dies, the broker publishes its LWT and the console freezes that device's subtree: every component under it reports UNREACHABLE **by containment** ("the road is down, not the houses" — you get one containment note, not N offline alarms). It is terminal until the diff --git a/docs/reference/data-types.md b/docs/reference/data-types.md index 88b5f58..b07db28 100644 --- a/docs/reference/data-types.md +++ b/docs/reference/data-types.md @@ -116,16 +116,30 @@ Receipt times (`receivedAt` everywhere, and the point `at` in metric/signal seri the gateway's own monotonic timeline — non-decreasing per gateway, even when the host wall clock steps backward. -### `InstanceStatus` (per-connection reachability) +### `InstanceStatus` (per-connection status) ```ts -interface InstanceStatus { instance: string; connected: boolean; detail?: string; } +interface InstanceStatus { instance: string; connected: boolean; state?: string; detail?: string; } ``` A multi-connection component (OPC UA servers, Modbus slaves, file-replicator source dirs) reports each -configured instance's reachability in its `state.instances[]`, rather than minting a UNS instance per +configured instance's status in its `state.instances[]`, rather than minting a UNS instance per connection. +`state` carries the instance's condition in the shared vocabulary, from the same state model that +answers the component's `sb/status`: + +| `state` | Meaning | Console rendering | +|---------|---------|-------------------| +| `CONNECTING` | Establishing the southbound session. | `connecting` badge, blue. | +| `ONLINE` | Connected and polling/subscribed. | `online` badge, green. | +| `BACKOFF` | Down, retrying on the reconnect backoff. | `backoff` badge, red. | +| `PAUSED` | Deliberately stopped by an operator. | `paused` badge, gray, marked **expected quiet**. | + +`PAUSED` is expected quiet: the console excludes a paused instance from the Health tab's connection +ratio and reports it separately, so a deliberate pause never reads as a connection fault. A component +that reports no `state`, or a token outside the table, is rendered from `connected` alone. + ## Snapshot shapes ```ts @@ -143,7 +157,7 @@ interface DeviceSnapshot { device: string; unreachable: boolean; unreachableSinc | `liveness` | `Liveness` | Effective (device UNREACHABLE overlays the ladder). | | `status` | string? | Last reported `state.status` (`RUNNING`/`STOPPED`). | | `uptimeSecs` | number? | Last reported uptime (restart = a decrease). | -| `instances` | `InstanceStatus[]`? | Per-instance connectivity, when the state carried it. | +| `instances` | `InstanceStatus[]`? | Per-instance status, when the state carried it. | | `lastStateAt` | number? | Receipt time of the last `state` keepalive. | | `expectedIntervalSecs` | number | The interval driving miss-detection. | | `cadenceSource` | `CadenceSource` | `default` or `cfg`. | @@ -159,7 +173,7 @@ Every delta carries a monotonic `seq` and a model-clock `at`. The variants: |--------|-------------|---------| | `device-discovered` | `device` | First sight of a device. | | `component-discovered` | `key`, `path`, `hier` | First sight of a component (carries `hier` for dynamic grouping without a snapshot). | -| `instances-changed` | `key`, `instances` | The full new per-instance connectivity set (replace wholesale). | +| `instances-changed` | `key`, `instances` | The full new per-instance status set (replace wholesale). | | `value-updated` | `key`, `instance`, `cls`, `channel?` | A cached value changed (notification only — no body). | | `liveness-changed` | `key`, `from`, `to` | A ladder transition. | | `component-restarted` | `key`, `previousUptimeSecs`, `uptimeSecs` | An uptime reset. | @@ -323,9 +337,33 @@ console-synthesized `ConsoleCommandErrorCode`: | `MALFORMED_REPLY` | A reply arrived whose body was not the `{ok, result\|error}` shape. | | `UNAVAILABLE` | The gateway has no command seam wired. | -The three universal built-in verbs every component answers: **`BUILTIN_COMMAND_VERBS`** = -`["ping", "reload-config", "get-configuration"]`. The console does not discover a component's custom -verbs. +The universal built-in verbs every component answers: **`BUILTIN_COMMAND_VERBS`** = +`["ping", "describe", "reload-config", "get-configuration"]`. A component's custom verbs are +discovered through `describe`. + +### Command capabilities (`describe.commands[]`) + +Each entry advertises one verb the console may invoke: + +| Field | Type | Meaning | +|-------|------|---------| +| `verb` | string | The exact cmd verb remainder (`sb/browse`). The console never invents aliases. | +| `title` | string? | Display label. | +| `scope` | `"component"` \| `"instance"` \| `"both"`? | The verb's addressing (below). | +| `kind` | `"read"` \| `"write"` \| `"diagnostic"` \| `"control"`? | What the verb does. | +| `builtIn` | boolean? | Whether the library, not the component, answers it. | +| `danger` | `"none"` \| `"physical-write"`? | Drives the confirmation affordance. | +| `availability` | `{state, reason?}`? | `disabled`/`unsupported` disables every bound widget and shows the reason. | + +`scope` drives the Panel tab's addressing UI: + +- **`instance`** — the instance selector mounts, and every invocation of the verb names the selected + instance. +- **`component`** — no selector involvement, and no invocation ever carries `instance`. The component + rejects an instance-addressed delivery of a component-scoped verb. +- **`both`** — the selector mounts and offers an explicit **Whole component** choice, which sends no + `instance` at all. It is offered when every instance-addressable widget in the view declares `both`. +- **absent** — the console falls back to the panel widgets' own `scope` markers. ## Wire error codes diff --git a/docs/reference/messaging-interface.md b/docs/reference/messaging-interface.md index 3bf8c11..fb82c48 100644 --- a/docs/reference/messaging-interface.md +++ b/docs/reference/messaging-interface.md @@ -21,7 +21,7 @@ needs no per-component topic templates. | Class | Wildcards (component + instance scope) | What the console does with it | |-------|----------------------------------------|-------------------------------| -| `state` | `ecv1/+/+/state` · `ecv1/+/+/+/state` | Liveness backbone (miss-detection); `status`/`uptimeSecs`/`instances[]`; the **only** signal that clears a device's UNREACHABLE. Also delivers the bridge protobuf LWT (below). | +| `state` | `ecv1/+/+/state` · `ecv1/+/+/+/state` | Liveness backbone (miss-detection); `status`/`uptimeSecs`/`instances[]` (per-instance status incl. its `state` token); the **only** signal that clears a device's UNREACHABLE. Also delivers the bridge protobuf LWT (below). | | `cfg` | `ecv1/+/+/cfg` · `ecv1/+/+/+/cfg` | Effective, source-redacted config → the Configuration screen; the cadence source (`config.heartbeat.intervalSecs`). | | `evt` | `ecv1/+/+/evt/#` · `ecv1/+/+/+/evt/#` | Rolling event history + the console-side alarm tracker (raise/clear). | | `metric` | `ecv1/+/+/metric/#` · `ecv1/+/+/+/metric/#` | Metric latest/series + the runtime-attributes projection (`sys.*`, `southbound_health`). | @@ -143,8 +143,11 @@ sequenceDiagram ``` - **Topic**: `ecv1/{device}/{component}/cmd/{verb}`, built with `uns().topicFor(target, Cmd, verb)`. - The console targets the component scope (verbs register on the component's `cmd/#` inbox; per-instance - dispatch is by a body selector, not the topic). + The console targets the component scope (verbs register on the component's `cmd/#` inbox, which + receives both the component- and instance-addressed forms). When an invocation addresses one + instance, the instance rides in the request body as `args.instance`, chosen from the verb's declared + scope — see [data-types.md → Command capabilities](data-types.md#command-capabilities-describecommands). + A component-scoped verb is never sent an `instance`. - **Request**: `header.name` **must equal** the verb; the body is the `args` object (`{}` when omitted). - **`reply_to`** is rewritten transparently by the `uns-bridge`, so a site→device request/reply just works on the console's single connection. diff --git a/docs/user-guide.md b/docs/user-guide.md index 5b1db12..b2eaeff 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -112,6 +112,22 @@ announced it — the fastest way to confirm what a component is actually running device. ::: +### Instances + +The **Instances** tab lists every connection the component is configured with — one OPC UA server, one +Modbus slave, one replication source directory each — with its current state: **online**, +**connecting**, **backoff** (down, retrying), or **paused**. A paused instance is marked *expected +quiet*: an operator stopped it on purpose, so its silence is not a fault and it is left out of the +Health tab's connected-instance count. A component that reports no state per instance shows +**connected** / **disconnected** instead. + +### Panel + +The **Panel** tab renders the views the component itself advertises. Where those views act on one +connection, an **Instance** selector sits above them and every command you run there targets the +instance you picked. Views whose commands act on the whole component show no selector, and a view +whose commands accept either addressing adds a **Whole component** choice to the selector. + ## Signals — the live data plane Every telemetry and business signal flowing on the site bus, live. This is where you watch actual diff --git a/gateway/src/command.rs b/gateway/src/command.rs index 0983799..a179755 100644 --- a/gateway/src/command.rs +++ b/gateway/src/command.rs @@ -455,6 +455,41 @@ mod tests { assert_eq!(manifest["commands"][0]["verb"], "sb/browse"); } + /// Each `describe.commands[]` entry declares its addressing scope + /// (DESIGN-scoped-commands §2.3) — `component` / `instance` / `both`. Normalization keeps + /// entries verbatim, so the field (and every other member of the entry, known or not) + /// reaches the browser untouched; the console derives its addressing UI from it. + #[test] + fn command_entries_keep_their_declared_scope() { + let manifest = normalize_describe_manifest(&json!({ + "commands": [ + { "verb": "sb/browse", "scope": "instance" }, + { "verb": "sb/discover", "scope": "component" }, + { "verb": "sb/status", "scope": "both", "kind": "read" }, + { "verb": "legacy/verb" } + ] + })) + .unwrap(); + let commands = manifest["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 4); + assert_eq!(commands[0]["scope"], "instance"); + assert_eq!(commands[1]["scope"], "component"); + assert_eq!(commands[2]["scope"], "both"); + assert_eq!(commands[2]["kind"], "read"); + // A verb that declares no scope keeps none — the console falls back to widget scope. + assert!(commands[3].get("scope").is_none()); + } + + /// The same, through the `{"verbs": [...]}` wrapper form of the `commands` member. + #[test] + fn wrapped_command_entries_keep_their_declared_scope() { + let manifest = normalize_describe_manifest(&json!({ + "commands": { "verbs": [{ "verb": "sb/pause", "scope": "both" }] } + })) + .unwrap(); + assert_eq!(manifest["commands"][0]["scope"], "both"); + } + fn normalized_view(view: Value) -> Value { let panels = normalize_panels(Some(&json!({ "views": [view] }))).unwrap(); panels["views"][0].clone() diff --git a/gateway/src/model.rs b/gateway/src/model.rs index 3014b45..6325755 100644 --- a/gateway/src/model.rs +++ b/gateway/src/model.rs @@ -2221,6 +2221,17 @@ fn normalize_instance_status(value: &Value) -> Option { let mut out = Map::new(); out.insert("instance".to_string(), Value::String(instance.to_string())); out.insert("connected".to_string(), Value::Bool(connected)); + // The instance's own condition token (CONNECTING / ONLINE / BACKOFF / PAUSED, or a + // component-specific token) rides through verbatim: it is what lets the console tell a + // deliberate pause from a fault. Blank tokens are dropped, matching the library's element. + if let Some(state) = obj + .get("state") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.insert("state".to_string(), Value::String(state.to_string())); + } if let Some(detail) = obj.get("detail").and_then(Value::as_str) { out.insert("detail".to_string(), Value::String(detail.to_string())); } @@ -2443,6 +2454,72 @@ mod tests { } } + /// The keepalive's per-instance `state` token (D-SC-7) rides through normalization: it is + /// what lets the console tell a deliberately PAUSED instance from a fault. Blank tokens and + /// unknown types are dropped; `connected`/`detail` keep their existing behavior. + #[test] + fn instance_status_keeps_the_keepalive_state_token() { + let normalized = normalize_instance_status(&json!({ + "instance": "kep1", + "connected": false, + "state": "PAUSED", + "detail": "paused by operator" + })) + .unwrap(); + assert_eq!( + normalized, + json!({ + "instance": "kep1", + "connected": false, + "state": "PAUSED", + "detail": "paused by operator" + }) + ); + + // A component that reports no state (or a blank/non-string one) keeps the + // connectivity-only element — the field is additive on the existing shape. + for absent in [json!({ "instance": "kep1", "connected": true }), json!({ + "instance": "kep1", + "connected": true, + "state": " " + }), json!({ "instance": "kep1", "connected": true, "state": 7 })] { + let normalized = normalize_instance_status(&absent).unwrap(); + assert_eq!(normalized, json!({ "instance": "kep1", "connected": true })); + } + } + + /// The state keepalive's `instances[]` reaches the fleet delta with the state token intact. + #[test] + fn instances_changed_delta_carries_the_state_token() { + let mut model = Model::new(ConsoleConfig::default()); + let outcome = model.ingest(IngressEvent { + cls: "state".to_string(), + channel: None, + identity: identity(), + body: json!({ + "status": "RUNNING", + "uptimeSecs": 12, + "instances": [ + { "instance": "kep1", "connected": true, "state": "ONLINE" }, + { "instance": "kep2", "connected": false, "state": "PAUSED" } + ] + }), + tags: None, + received_at: 1_000, + source_timestamp: None, + }); + let deltas = deltas_of(&outcome); + let changed = deltas["deltas"] + .as_array() + .unwrap() + .iter() + .find(|d| d["type"] == "instances-changed") + .expect("expected an instances-changed delta"); + assert_eq!(changed["instances"][0]["state"], "ONLINE"); + assert_eq!(changed["instances"][1]["state"], "PAUSED"); + assert_eq!(changed["instances"][1]["connected"], false); + } + #[test] fn channel_from_topic_handles_both_d_u28_scopes() { // D-U28 component-scope: the class sits at index 3 (no instance token). diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 60ef16f..087a065 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -174,10 +174,56 @@ export interface InstanceStatus { instance: string; /** Whether that instance's southbound/source is currently reachable. */ connected: boolean; + /** + * The instance's condition in the shared keepalive vocabulary, when the component reports one: + * `CONNECTING` / `ONLINE` / `BACKOFF` / `PAUSED` (see {@link InstanceState}). It comes from the + * same single instance state model that answers `sb/status`, so push and pull agree. The field + * is optional and open — a component may report a token this console does not know, and a + * consumer must ignore anything it does not recognize and fall back to `connected` alone. + */ + state?: string; /** Optional human detail (endpoint, or the down reason). */ detail?: string; } +/** + * The shared instance-state vocabulary carried by {@link InstanceStatus.state}: + * + * - `CONNECTING` — establishing the southbound session (not yet usable); + * - `ONLINE` — connected and polling/subscribed; + * - `BACKOFF` — down and retrying on the reconnect backoff; + * - `PAUSED` — deliberately stopped by an operator (`sb/pause`) — **expected quiet**, not a fault. + * + * `PAUSED` is what separates a silence an operator asked for from one that means something is + * wrong: a paused instance publishes nothing, and the console must not read that silence as a + * fault. + */ +export type InstanceState = "CONNECTING" | "ONLINE" | "BACKOFF" | "PAUSED"; + +const INSTANCE_STATES: ReadonlySet = new Set([ + "CONNECTING", + "ONLINE", + "BACKOFF", + "PAUSED", +]); + +/** + * The known {@link InstanceState} an instance reports, or `undefined` when it reports none or + * reports a token this console does not know (the caller then renders connectivity only). + * Matching is case-insensitive and whitespace-tolerant — the token is component-supplied text. + */ +export function instanceState(inst: Pick): InstanceState | undefined { + const raw = inst.state; + if (typeof raw !== "string") return undefined; + const normalized = raw.trim().toUpperCase(); + return INSTANCE_STATES.has(normalized) ? (normalized as InstanceState) : undefined; +} + +/** Whether an instance is deliberately paused — expected quiet, never a staleness fault. */ +export function isPausedInstance(inst: Pick): boolean { + return instanceState(inst) === "PAUSED"; +} + /** A component's slice of a {@link FleetSnapshot}. */ export interface ComponentSnapshot { key: ComponentKey; @@ -1008,12 +1054,28 @@ export type BuiltinCommandVerb = (typeof BUILTIN_COMMAND_VERBS)[number]; * M10 / Phase 3 — descriptor-driven component panels. * --------------------------------------------------------------------------- */ +/** + * The addressing a command verb declares (DESIGN-scoped-commands §2.2/§2.3): + * + * - `component` — the verb acts on the whole component; an instance-addressed invocation is + * rejected by the library with `BAD_ARGS`, so the console never sends an `instance`; + * - `instance` — the verb acts on one instance; every invocation names the selected instance; + * - `both` — either addressing is meaningful; no instance means "the whole component". + */ +export type CommandScope = "component" | "instance" | "both"; + /** A command capability advertised by a component's `describe` response. */ export interface CommandCapability { /** Exact cmd verb remainder, e.g. `sb/browse`; UI must not invent aliases. */ verb: string; title?: string; - scope?: "component" | "instance"; + /** + * The verb's declared addressing ({@link CommandScope}). The Panel tab derives its addressing + * UI from it: an instance selector for `instance`, no selector involvement for `component`, and + * a selector plus an explicit "Whole component" choice for `both`. A verb that declares no + * scope (an older component) falls back to the widget-level `scope` markers. + */ + scope?: CommandScope; kind?: "read" | "write" | "diagnostic" | "control"; builtIn?: boolean; requestSchema?: unknown; diff --git a/protocol/test/protocol-ws.test.ts b/protocol/test/protocol-ws.test.ts index 8e39f4a..a02342b 100644 --- a/protocol/test/protocol-ws.test.ts +++ b/protocol/test/protocol-ws.test.ts @@ -9,7 +9,9 @@ import { PROTOCOL_VERSION, classifyEventSeverity, extractSignalSample, + instanceState, isAlarmingSeverity, + isPausedInstance, parseClientMessage, parseComponentKey, splitEventChannel, @@ -476,3 +478,25 @@ describe("parseComponentKey", () => { expect(parseComponentKey(value)).toBeUndefined(); }); }); + +describe("instanceState / isPausedInstance", () => { + it("recognizes the shared keepalive vocabulary, case- and whitespace-tolerantly", () => { + expect(instanceState({ state: "ONLINE" })).toBe("ONLINE"); + expect(instanceState({ state: "connecting" })).toBe("CONNECTING"); + expect(instanceState({ state: " backoff " })).toBe("BACKOFF"); + expect(instanceState({ state: "Paused" })).toBe("PAUSED"); + }); + + it("ignores an absent or unknown token — consumers fall back to `connected`", () => { + expect(instanceState({})).toBeUndefined(); + expect(instanceState({ state: "" })).toBeUndefined(); + expect(instanceState({ state: "HIBERNATING" })).toBeUndefined(); + expect(instanceState({ state: 7 as unknown as string })).toBeUndefined(); + }); + + it("isPausedInstance is true only for PAUSED", () => { + expect(isPausedInstance({ state: "PAUSED" })).toBe(true); + expect(isPausedInstance({ state: "BACKOFF" })).toBe(false); + expect(isPausedInstance({})).toBe(false); + }); +}); diff --git a/ui/src/components/ComponentDetailView.tsx b/ui/src/components/ComponentDetailView.tsx index ab023ec..cebaacc 100644 --- a/ui/src/components/ComponentDetailView.tsx +++ b/ui/src/components/ComponentDetailView.tsx @@ -48,7 +48,7 @@ import type { PanelViewDescriptor, PanelWidgetDescriptor, } from "@edgecommons/edge-console-protocol"; -import { LOG_LEVELS, componentKeyId } from "@edgecommons/edge-console-protocol"; +import { LOG_LEVELS, componentKeyId, instanceState } from "@edgecommons/edge-console-protocol"; import type { ClientState, FleetClient } from "../fleet/client"; import { commandSlot } from "../fleet/command-store"; import type { ConfigEntryView } from "../fleet/config-store"; @@ -69,6 +69,7 @@ import { clampEventLimit, classifyCommandFailure, commandAvailability, + effectiveWidgetScope, eventFamilyMatch, formatPanelValue, lifecycleGateEnabled, @@ -78,9 +79,11 @@ import { panelEventFilters, panelFields, panelRefresh, + panelViewAddressing, parseControls, resolvePath, sanitizeArgsObject, + scopeSendsInstance, unknownRequirementTokens, } from "./panel-descriptor"; import type { ComponentView } from "../fleet/store"; @@ -102,6 +105,7 @@ import { detailSubtitleParts, detailUptimeSecs, healthChecks, + instanceStatePresentation, } from "./detail-selectors"; import { findComponent } from "./components-tree"; @@ -213,6 +217,12 @@ function hasRealInstances(comp: ComponentView): boolean { return first !== undefined && first !== "main"; } +/** + * The `