diff --git a/cmd/spinloop/dashboard_render.go b/cmd/spinloop/dashboard_render.go index b7e1cadc..a9565ef5 100644 --- a/cmd/spinloop/dashboard_render.go +++ b/cmd/spinloop/dashboard_render.go @@ -116,6 +116,7 @@ const ( dashHealthy dashHealthTier = iota dashAttention dashUnhealthy + dashNotServing dashUnknown ) @@ -128,11 +129,14 @@ const ( // outcome is unhealthy; then a running engine the daemon has explicitly // reported not ready is attention — the case this tier exists for, a cloud // node whose process is up but still loading weights; then an answer that -// carries no state at all is unknown; anything else, including a running -// engine the daemon reports no readiness for at all (an older daemon, or a -// runner with no known health check), is healthy, so this degrades to the -// pre-readiness behaviour rather than showing a tier the daemon cannot -// actually back. +// carries no state at all is unknown; then a node that answered with nothing +// serving is not serving, a faded dot rather than the green of a node that +// is up and serving — idle, the daemon with nothing started; stopped, a +// daemon engine that was stopped; undeployed, a remote environment with no +// instance at all; anything else, including a running engine the daemon +// reports no readiness for at all (an older daemon, or a runner with no +// known health check), is healthy, so this degrades to the pre-readiness +// behaviour rather than showing a tier the daemon cannot actually back. func dashHealthTierFor(r fleet.NodeResult, a dashAction) dashHealthTier { switch { case a.verb != "": @@ -145,6 +149,8 @@ func dashHealthTierFor(r fleet.NodeResult, a dashAction) dashHealthTier { return dashAttention case r.Metrics.State == "": return dashUnknown + case r.Metrics.State == "idle" || r.Metrics.State == "stopped" || r.Metrics.State == "undeployed": + return dashNotServing default: return dashHealthy } @@ -154,13 +160,18 @@ func dashHealthTierFor(r fleet.NodeResult, a dashAction) dashHealthTier { // panel's name line, in the same raw-ANSI style renderBar already uses for // the resource bars inside the tile — the tile body is one plain string // wrapped in a single lipgloss style at the border, so per-character colour -// here has to be ANSI, not lipgloss.Color. +// here has to be ANSI, not lipgloss.Color. Not serving shares unknown's +// faded shade and keeps the dot: the two greys are told apart by their mark, +// a filled dot for a known undeployed node against the ? for one that has +// not answered yet. func dashHealthGlyph(tier dashHealthTier) string { switch tier { case dashHealthy: return "\033[92m●\033[0m" case dashAttention: return "\033[33m●\033[0m" + case dashNotServing: + return "\033[90m●\033[0m" case dashUnknown: return "\033[90m?\033[0m" default: diff --git a/cmd/spinloop/fleet_dashboard_test.go b/cmd/spinloop/fleet_dashboard_test.go index f2d5cd21..871f85e7 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -330,12 +330,27 @@ func TestDashTileStoppedByteStable(t *testing.T) { Metrics: metrics.Stats{State: "idle"}, } want := dashTileExpected([]string{ - dashHealthGlyph(dashHealthy) + " idle idle", + dashHealthGlyph(dashNotServing) + " idle idle", "", "", "", "", "", "", "", "", "", "", "", }) if got := dashTile("idle", r, false, dashAction{}); got != want { t.Errorf("stopped tile mismatch:\n%q\nwant:\n%q", got, want) } + // A remote environment with no instance at all reports undeployed and + // keeps its deployment: the serving line rides on the same shape, and + // the dot is the faded one, not the green of a serving node. + u := fleet.NodeResult{ + Name: "dev-1", Outcome: fleet.OutcomeOK, + Metrics: metrics.Stats{State: "undeployed", Runner: "llamacpp", ModelID: "unsloth/Qwen3.8-27B-GGUF"}, + } + wantUndeployed := dashTileExpected([]string{ + dashHealthGlyph(dashNotServing) + " dev-1 undeployed", + "llamacpp unsloth/Qwen3.8-27B-GGUF", + "", "", "", "", "", "", "", "", "", "", + }) + if got := dashTile("dev-1", u, false, dashAction{}); got != wantUndeployed { + t.Errorf("undeployed tile mismatch:\ngot:\n%q\nwant:\n%q", got, wantUndeployed) + } } // A node with an action in flight and no report yet shows the verb and the @@ -441,8 +456,12 @@ func TestDashHealthTierFor(t *testing.T) { Metrics: metrics.Stats{State: "running", Ready: "not-ready"}}, dashAction{}, dashAttention}, {"running with no readiness signal degrades to healthy", fleet.NodeResult{Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "running"}}, dashAction{}, dashHealthy}, - {"idle is healthy", fleet.NodeResult{Outcome: fleet.OutcomeOK, - Metrics: metrics.Stats{State: "idle"}}, dashAction{}, dashHealthy}, + {"idle is not serving", fleet.NodeResult{Outcome: fleet.OutcomeOK, + Metrics: metrics.Stats{State: "idle"}}, dashAction{}, dashNotServing}, + {"stopped is not serving", fleet.NodeResult{Outcome: fleet.OutcomeOK, + Metrics: metrics.Stats{State: "stopped"}}, dashAction{}, dashNotServing}, + {"undeployed is not serving", fleet.NodeResult{Outcome: fleet.OutcomeOK, + Metrics: metrics.Stats{State: "undeployed"}}, dashAction{}, dashNotServing}, {"crashed is unhealthy", fleet.NodeResult{Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "crashed"}}, dashAction{}, dashUnhealthy}, {"unreachable is unhealthy", fleet.NodeResult{Outcome: fleet.OutcomeUnreachable}, dashAction{}, dashUnhealthy}, @@ -458,6 +477,9 @@ func TestDashHealthTierFor(t *testing.T) { {"action in flight over a crashed report is attention regardless", fleet.NodeResult{Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "crashed"}}, dashAction{verb: "stop"}, dashAttention}, + {"start in flight over an idle report is attention, never not serving", + fleet.NodeResult{Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "idle"}}, + dashAction{verb: "start"}, dashAttention}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/.openspec.yaml b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/.openspec.yaml new file mode 100644 index 00000000..032461ff --- /dev/null +++ b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/design.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/design.md new file mode 100644 index 00000000..7fc7c1c5 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/design.md @@ -0,0 +1,111 @@ +## Context + +`fleet dashboard` renders each node as a fixed tile whose name line is +prefixed by a status dot: `dashHealthGlyph(dashHealthTierFor(result, action))` +in `cmd/spinloop/dashboard_render.go`. Today the tier switch has four +outcomes — attention (action in flight), unknown (no refresh yet, or a +report with no state), unhealthy (failed outcome or `crashed`), and +everything else — and the not-serving reports (`idle`, `stopped`, +`undeployed`) fall into "everything else", drawing the green dot of a +serving node. See proposal.md for why that is wrong. The tile body already +prints the state word on its state line, so the text is never in doubt; +only the dot lies. + +Constraints: the glyph is raw ANSI in the same style as the resource bars +(the tile body is one plain string under a single lipgloss border style), +and the tile's content lines must stay byte-identical to what the `fleet +metrics` bar format prints for the node — the dot is tile-only, prepended +in `dashTileContent`, and is the one thing the byte-stable tile tests pin +beside the shared lines. + +## Goals / Non-Goals + +**Goals:** +- A settled report of `idle`, `stopped`, or `undeployed` with no action in + flight draws a faded grey dot instead of green. +- The new grey stays distinguishable from the `unknown` grey, which marks + "no answer yet" with a `?`. +- Every precedence rule the tier switch already enforces (action in flight + beats everything; failed outcome beats state; empty state reads unknown) + is preserved unchanged. + +**Non-Goals:** +- No change to any tile body line, to `fleet metrics`/`fleet status` + output, or to the detail view (none of them draw the dot). +- No new state vocabulary: `idle`, `stopped`, and `undeployed` are exactly + the daemon states and the remote control-plane states that mean "not + serving", so the tier keys on the state strings it already sees. +- No colour for states like a remote environment reporting `starting` + outside of a dashboard-initiated start — that path is covered by the + existing action-in-flight rule when the dashboard drove the start, and + untouched otherwise. + +## Decisions + +**1. A new tier, not a reuse of `dashUnknown`.** +Add `dashNotServing` to the `dashHealthTier` enum with its own case in +`dashHealthGlyph`. Reusing `dashUnknown` would render an undeployed node +with the `?` mark, erasing the difference between "the dashboard hasn't +heard from this node" and "it has, and the node is up with nothing +deployed" — the exact distinction the spec's "different marks" scenario +locks in. + +**2. The glyph is `\033[90m●\033[0m`: the existing faded shade, the dot +kept.** +SGR 90 (bright black) is the file's established "faded" code — +`dashUnknown` already uses it for its `?` — so the new tier introduces no +new colour and works on 16-colour terminals like the rest of the palette +(92/33/31/90). The mark tells the two greys apart: filled dot = known +state, `?` = no answer. Alternative considered: a darker 256-colour grey +matching the unselected border (`240`) — rejected, it needs the 256-colour +profile and would make the tile the only part of the view that breaks on a +16-colour terminal. + +**3. The new case sits in `dashHealthTierFor` after the empty-state case, +immediately before `default`.** +The switch's order is its precedence. The action-in-flight, no-outcome, +failed-outcome/`crashed`, not-ready, and empty-state cases all come first +and are untouched, so a start in flight over an `idle` report still reads +attention and a failed refresh still reads unhealthy regardless of state. +`idle`, `stopped`, and `undeployed` are non-empty states, so the new case +overlaps nothing; placing it just before `default` is the only position +that changes any behaviour, and it changes exactly the states named in the +spec. + +**4. All three off states are in scope: `idle`, `stopped`, `undeployed`.** +A daemon that stops its engine reports `stopped`; a remote environment +reports `stopped` while its instance is stopped and `undeployed` when it has +no instance at all (the control-plane states pass through +`statusFromRemote` unchanged). All are "node up, serving nothing" — the +same fact the dot exists to convey — and keying the tier on `idle` alone +would leave off states wearing different dots on one grid. The proposal +records this as a scoping decision. + +**5. Tile body untouched; tests move with the glyph.** +Only `dashHealthTierFor` and `dashHealthGlyph` change, so the +content-line invariant (tile lines = `fleet metrics` bar lines) is intact +by construction. In `fleet_dashboard_test.go`, the byte-stable idle tile +(`TestDashTileStoppedByteStable`) and the tier table's `idle is healthy` +entry change their expected glyph to the new tier, the byte-stable tiles +gain an `undeployed` case carrying its serving line, and the table gains +`stopped`/`undeployed` and idle-with-start-in-flight rows. + +## Risks / Trade-offs + +- [The byte-stable tile tests pin the glyph bytes, so a wrong escape + sequence ships as a test failure, not a surprise] → they are the check; + the ASCII-profile tests render the new dot as plain `●` like the others. +- [`unknown` and `not serving` now share a colour] → the spec requires + different marks (`?` vs filled dot), and the tile's state line repeats + the word, so a misread needs both the mark and the text to be missed. +- [A remote control plane reporting an off-state string not covered + (e.g. a future state name) would fall through to green] → same + limitation the current switch already has for any unexpected state; the + tier is deliberately keyed on the states the daemon and control plane + actually report today. + +## Migration Plan + +No migration: a visual change to one dot in one view, no data, no API, no +config. Rollback is reverting the change; nothing written to disk is +affected. diff --git a/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/proposal.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/proposal.md new file mode 100644 index 00000000..3ea004b3 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/proposal.md @@ -0,0 +1,55 @@ +## Why + +In `fleet dashboard`, a node with no engine deployed — a daemon reporting +`idle`, or a remote environment reporting `stopped` (its instance is +stopped) or `undeployed` (it has no instance at all) — shows the same green +status dot as a node that is running and ready. Green reads as "serving", so a grid of undeployed nodes looks +healthy at a glance when it is actually empty. The dot should carry the +node's serving status, not just its absence of failure. + +## What Changes + +- Add a fifth dashboard health tier for nodes that are up and answering but + not serving anything: engine state `idle`, `stopped`, or `undeployed`, + with no start or stop in flight on the node. Its status dot is grey — the same faded shade + the `unknown` tier uses, but a filled dot rather than the `?`, so "known + to be undeployed" stays distinct from "no answer yet". +- `dashHealthTierFor` routes settled `idle`/`stopped`/`undeployed` reports + to the new tier instead of falling through to `healthy`. Everything already ranked + above it is untouched: an action in flight still reads attention (a node + that is starting must never read undeployed), a failed outcome still + reads unhealthy, and a report with no state still reads unknown. +- The health-indicator requirement in the `fleet-client` spec moves from + four tiers to five, with scenarios for an undeployed node and for + undeployed-with-start-in-flight. +- The off states are covered as a set: `idle` (the daemon has started + nothing), `stopped` (a daemon engine that was stopped), and `undeployed` + (a remote environment with no instance at all) are the same fact — the + node is up but serving nothing — and off states wearing different dots + would read as inconsistent on the same grid. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `fleet-client`: the "Dashboard panels show a health indicator" requirement + gains a fifth tier — a node whose last refresh reports it not serving + (`idle`, `stopped`, or `undeployed`) and has no action in flight reads + grey, not green. + +## Impact + +- `cmd/spinloop/dashboard_render.go` — `dashHealthTier`, + `dashHealthTierFor`, `dashHealthGlyph`, and their doc comments. No other + surface draws the glyph: `fleet metrics`, `fleet status`, and the detail + view are unaffected. +- `cmd/spinloop/fleet_dashboard_test.go` — the byte-stable idle tile and + the tier table expect the old green for `idle`; both move to the new + tier, and `stopped`/`undeployed` cases join the table and the + byte-stable tiles. +- No API, config, or dependency changes; no behaviour change outside the + dashboard tile's status dot. diff --git a/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/specs/fleet-client/spec.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/specs/fleet-client/spec.md new file mode 100644 index 00000000..d145e3e8 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/specs/fleet-client/spec.md @@ -0,0 +1,128 @@ +## MODIFIED Requirements + +### Requirement: Dashboard panels show a health indicator + +Each panel SHALL show a coloured status glyph alongside its node's name, +distinct from the border colour that marks the selected panel, so a node's +health reads at a glance across a grid of many panels without reading each +panel's text. The glyph SHALL be shown in every panel shape: a settled +answer, an action in flight, a panel awaiting its first refresh, and a panel +showing a failed outcome. + +A node's health SHALL fall into exactly one of five tiers: healthy, +attention, unhealthy, not serving, and unknown. Healthy is coloured green, +attention yellow, and unhealthy red. Not serving and unknown are both +coloured grey and read as "nothing to watch" rather than "something is +wrong"; the two stay apart by their marks — not serving is the same filled +dot as the other tiers in a faded shade, and unknown keeps its `?` — so a +node known to be undeployed never reads as a node the dashboard has not +heard from yet. + +- **Healthy**: the node answered its last refresh, its engine is not + crashed, not `idle`, not `stopped`, and not `undeployed`, and — when the + daemon reports readiness for it — the engine is ready. A `running` node whose daemon + reports no readiness (an older daemon, or a runner with no known health + check) counts as healthy too, rather than reporting a health tier the + daemon cannot actually back. +- **Attention**: the node has a start or stop action in flight for it, or is + `running` with its daemon explicitly reporting the engine not yet ready. +- **Unhealthy**: the node's engine has crashed, or its last refresh's + outcome was a failure (`unreachable`, `unauthorized`, `config-error`, + `failed`, or `unsupported`). +- **Not serving**: the node answered its last refresh, no action is in + flight for it, and its engine is not serving — its state is `idle`, the + daemon has started nothing, `stopped`, a daemon engine that was stopped, + or `undeployed`, a remote environment with no instance at all. +- **Unknown**: no status can be determined for the node — it has not yet + answered any refresh, or its last refresh answered without reporting an + engine state. + +#### Scenario: A running, ready node reads healthy + +- **WHEN** a node's last completed refresh reports its engine `running` and + its daemon reports the engine ready +- **THEN** its panel's status glyph is green + +#### Scenario: A running node still loading reads attention + +- **WHEN** a node's last completed refresh reports its engine `running` and + its daemon reports the engine not yet ready +- **THEN** its panel's status glyph is yellow, even though its engine state + reads `running` + +#### Scenario: A running node with no readiness signal reads healthy + +- **WHEN** a node's last completed refresh reports its engine `running` and + its daemon reports no readiness for it +- **THEN** its panel's status glyph is green, the same as before this + daemon-side signal existed + +#### Scenario: A crashed node reads unhealthy + +- **WHEN** a node's last completed refresh reports its engine `crashed` +- **THEN** its panel's status glyph is red + +#### Scenario: An unreachable node reads unhealthy + +- **WHEN** a node's last refresh could not reach its daemon +- **THEN** its panel's status glyph is red, alongside the outcome and reason + already shown + +#### Scenario: A node awaiting its first refresh reads unknown + +- **WHEN** the dashboard opens and a node has not yet answered any refresh +- **THEN** its panel's status glyph is grey, until its first refresh lands + +#### Scenario: An answer without a state reads unknown + +- **WHEN** a node's last refresh answered but reported no engine state +- **THEN** its panel's status glyph is grey + +#### Scenario: An action in flight reads attention + +- **WHEN** the operator starts or stops a node and that action has not yet + finished +- **THEN** that node's panel's status glyph is yellow while the action is in + flight, whatever the node's last completed refresh reported + +#### Scenario: An undeployed node reads not serving + +- **WHEN** a node's last completed refresh reports its engine `idle` and no + start or stop is in flight for it +- **THEN** its panel's status glyph is the faded grey dot, not the green of + a serving node + +#### Scenario: A stopped node reads not serving + +- **WHEN** a node's last completed refresh reports its engine `stopped` — a + daemon engine that was stopped — and no start or stop is in flight for it +- **THEN** its panel's status glyph is the faded grey dot, not the green of + a serving node + +#### Scenario: An undeployed remote environment reads not serving + +- **WHEN** a remote environment's last completed refresh reports it + `undeployed` — it has no instance at all — and no start or stop is in + flight for it +- **THEN** its panel's status glyph is the faded grey dot, not the green of + a serving node + +#### Scenario: An undeployed node with a start in flight reads attention + +- **WHEN** a node's last completed refresh reports its engine `idle` or + `stopped` and a start for it has not yet finished +- **THEN** its panel's status glyph is yellow while the start is in flight, + never the grey of not serving + +#### Scenario: Not serving and unknown keep different marks + +- **WHEN** a grid holds both a node that has answered with `idle` and a node + that has not yet answered any refresh +- **THEN** the first shows the faded grey filled dot and the second the grey + `?`, so the two greys are told apart by their mark + +#### Scenario: The glyph is distinct from the selection border + +- **WHEN** the operator moves the selection onto a panel +- **THEN** the selected panel's border colour changes as it does today, and + every panel's status glyph colour is unaffected by which panel is selected diff --git a/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/tasks.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/tasks.md new file mode 100644 index 00000000..11b0b754 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/tasks.md @@ -0,0 +1,38 @@ +## 1. The tier and its glyph (cmd/spinloop/dashboard_render.go) + +- [x] 1.1 Add a `dashNotServing` tier to the `dashHealthTier` enum, and + extend the doc comments on the type, `dashHealthTierFor`, and + `dashHealthGlyph` to place it: a settled report of `idle`, `stopped`, or + `undeployed` with no action in flight, between the unknown cases and + healthy. +- [x] 1.2 In `dashHealthTierFor`, return `dashNotServing` when the node + answered, no action is in flight, the outcome is not a failure, the state + is not `crashed`/empty, and the state is `idle`, `stopped`, or + `undeployed` — a case after the empty-state unknown case and immediately + before `default`, so no existing tier's precedence changes. +- [x] 1.3 In `dashHealthGlyph`, render `dashNotServing` as the faded grey + dot `\033[90m●\033[0m` — the shade `dashUnknown` already uses for its + `?`, so the two greys differ by mark, not colour. + +## 2. Tests (cmd/spinloop/fleet_dashboard_test.go) + +- [x] 2.1 Update `TestDashTileStoppedByteStable` to expect + `dashHealthGlyph(dashNotServing)` on the `idle` node's name line. +- [x] 2.2 In the `dashHealthTierFor` table test, move the `idle` row from + `dashHealthy` to `dashNotServing`, and add rows: a settled `stopped` or + `undeployed` report is `dashNotServing`; an `idle` report with a start in + flight is `dashAttention` (never grey while starting). The byte-stable + tiles gain an `undeployed` case carrying its serving line. +- [x] 2.3 Confirm the remaining table rows are untouched: running+ready and + running-without-readiness stay `dashHealthy`, `crashed` and the failed + outcomes stay `dashUnhealthy`, no-refresh and no-state stay + `dashUnknown`, action-in-flight stays `dashAttention`, and the + selection-independent glyph test still passes. + +## 3. Verify + +- [x] 3.1 `go test ./...` passes, `go vet ./...` is clean, `gofmt -l ./...` + prints nothing, and `go test ./... -cover` keeps total coverage >= 80%. +- [x] 3.2 Check no other surface drew the green for these states: + `fleet metrics`, `fleet status`, and the detail view render unchanged + (they draw no glyph; the byte-stable tests pin their lines). diff --git a/openspec/specs/fleet-client/spec.md b/openspec/specs/fleet-client/spec.md index 4ea6c905..9ebe7730 100644 --- a/openspec/specs/fleet-client/spec.md +++ b/openspec/specs/fleet-client/spec.md @@ -748,19 +748,30 @@ panel's text. The glyph SHALL be shown in every panel shape: a settled answer, an action in flight, a panel awaiting its first refresh, and a panel showing a failed outcome. -A node's health SHALL fall into exactly one of four tiers, coloured green, -yellow, red, and grey respectively: +A node's health SHALL fall into exactly one of five tiers: healthy, +attention, unhealthy, not serving, and unknown. Healthy is coloured green, +attention yellow, and unhealthy red. Not serving and unknown are both +coloured grey and read as "nothing to watch" rather than "something is +wrong"; the two stay apart by their marks — not serving is the same filled +dot as the other tiers in a faded shade, and unknown keeps its `?` — so a +node known to be undeployed never reads as a node the dashboard has not +heard from yet. - **Healthy**: the node answered its last refresh, its engine is not - crashed, and — when the daemon reports readiness for it — the engine is - ready. A `running` node whose daemon reports no readiness (an older - daemon, or a runner with no known health check) counts as healthy too, - rather than reporting a health tier the daemon cannot actually back. + crashed, not `idle`, not `stopped`, and not `undeployed`, and — when the + daemon reports readiness for it — the engine is ready. A `running` node whose daemon + reports no readiness (an older daemon, or a runner with no known health + check) counts as healthy too, rather than reporting a health tier the + daemon cannot actually back. - **Attention**: the node has a start or stop action in flight for it, or is `running` with its daemon explicitly reporting the engine not yet ready. - **Unhealthy**: the node's engine has crashed, or its last refresh's outcome was a failure (`unreachable`, `unauthorized`, `config-error`, `failed`, or `unsupported`). +- **Not serving**: the node answered its last refresh, no action is in + flight for it, and its engine is not serving — its state is `idle`, the + daemon has started nothing, `stopped`, a daemon engine that was stopped, + or `undeployed`, a remote environment with no instance at all. - **Unknown**: no status can be determined for the node — it has not yet answered any refresh, or its last refresh answered without reporting an engine state. @@ -813,6 +824,42 @@ yellow, red, and grey respectively: - **THEN** that node's panel's status glyph is yellow while the action is in flight, whatever the node's last completed refresh reported +#### Scenario: An undeployed node reads not serving + +- **WHEN** a node's last completed refresh reports its engine `idle` and no + start or stop is in flight for it +- **THEN** its panel's status glyph is the faded grey dot, not the green of + a serving node + +#### Scenario: A stopped node reads not serving + +- **WHEN** a node's last completed refresh reports its engine `stopped` — a + daemon engine that was stopped — and no start or stop is in flight for it +- **THEN** its panel's status glyph is the faded grey dot, not the green of + a serving node + +#### Scenario: An undeployed remote environment reads not serving + +- **WHEN** a remote environment's last completed refresh reports it + `undeployed` — it has no instance at all — and no start or stop is in + flight for it +- **THEN** its panel's status glyph is the faded grey dot, not the green of + a serving node + +#### Scenario: An undeployed node with a start in flight reads attention + +- **WHEN** a node's last completed refresh reports its engine `idle` or + `stopped` and a start for it has not yet finished +- **THEN** its panel's status glyph is yellow while the start is in flight, + never the grey of not serving + +#### Scenario: Not serving and unknown keep different marks + +- **WHEN** a grid holds both a node that has answered with `idle` and a node + that has not yet answered any refresh +- **THEN** the first shows the faded grey filled dot and the second the grey + `?`, so the two greys are told apart by their mark + #### Scenario: The glyph is distinct from the selection border - **WHEN** the operator moves the selection onto a panel