Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 11 additions & 4 deletions docs/design/PHASE3-DESCRIPTOR-PANELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 46 additions & 8 deletions docs/reference/data-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`. |
Expand All @@ -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. |
Expand Down Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions docs/reference/messaging-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions gateway/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
77 changes: 77 additions & 0 deletions gateway/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2221,6 +2221,17 @@ fn normalize_instance_status(value: &Value) -> Option<Value> {
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()));
}
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading