From eef5ea1edabd927cbf0081b32b1ff5aef206f0fd Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Wed, 2 Sep 2026 01:51:57 +0100 Subject: [PATCH 1/4] docs(openspec): propose a grey dashboard dot for undeployed nodes --- .../dash-undeployed-idle-grey/.openspec.yaml | 2 + .../dash-undeployed-idle-grey/design.md | 108 ++++++++++++++++ .../dash-undeployed-idle-grey/proposal.md | 52 ++++++++ .../specs/fleet-client/spec.md | 121 ++++++++++++++++++ .../dash-undeployed-idle-grey/tasks.md | 36 ++++++ 5 files changed, 319 insertions(+) create mode 100644 openspec/changes/dash-undeployed-idle-grey/.openspec.yaml create mode 100644 openspec/changes/dash-undeployed-idle-grey/design.md create mode 100644 openspec/changes/dash-undeployed-idle-grey/proposal.md create mode 100644 openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md create mode 100644 openspec/changes/dash-undeployed-idle-grey/tasks.md diff --git a/openspec/changes/dash-undeployed-idle-grey/.openspec.yaml b/openspec/changes/dash-undeployed-idle-grey/.openspec.yaml new file mode 100644 index 00000000..032461ff --- /dev/null +++ b/openspec/changes/dash-undeployed-idle-grey/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/dash-undeployed-idle-grey/design.md b/openspec/changes/dash-undeployed-idle-grey/design.md new file mode 100644 index 00000000..98c83584 --- /dev/null +++ b/openspec/changes/dash-undeployed-idle-grey/design.md @@ -0,0 +1,108 @@ +## 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 `idle`/`stopped` reports 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 ("idle", "stopped") 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` or `stopped` 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` are exactly the daemon states + and the remote control-plane state 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` and `stopped` 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 two states named in the +spec. + +**4. `stopped` is in scope alongside `idle`.** +A daemon that stops its engine reports `stopped`; a remote environment at +scale-to-zero reports `stopped` (the control-plane state passes through +`statusFromRemote` unchanged). Both are "node up, serving nothing" — the +same fact the dot exists to convey — and keying the tier on `idle` alone +would leave two off states wearing two 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, and the table gains +`stopped` 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/dash-undeployed-idle-grey/proposal.md b/openspec/changes/dash-undeployed-idle-grey/proposal.md new file mode 100644 index 00000000..fa31ca48 --- /dev/null +++ b/openspec/changes/dash-undeployed-idle-grey/proposal.md @@ -0,0 +1,52 @@ +## Why + +In `fleet dashboard`, a node with no engine deployed — a daemon reporting +`idle`, or a remote environment asleep at scale-to-zero reporting +`stopped` — 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` or `stopped`, 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` 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. +- `stopped` is covered as well as `idle`: a daemon whose engine was stopped + and a remote environment that has scaled to zero are the same fact — the + node is up but serving nothing — and two "off" states wearing two + 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` or `stopped`) 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` cases join the table. +- No API, config, or dependency changes; no behaviour change outside the + dashboard tile's status dot. diff --git a/openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md b/openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md new file mode 100644 index 00000000..7adae2bb --- /dev/null +++ b/openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md @@ -0,0 +1,121 @@ +## 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`, and not `stopped`, 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`, + nothing has been started, or `stopped`, the state of a stopped daemon + engine and of a remote environment that has scaled to zero. +- **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, or a remote environment that has scaled to + zero — 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/dash-undeployed-idle-grey/tasks.md b/openspec/changes/dash-undeployed-idle-grey/tasks.md new file mode 100644 index 00000000..30cd6b13 --- /dev/null +++ b/openspec/changes/dash-undeployed-idle-grey/tasks.md @@ -0,0 +1,36 @@ +## 1. The tier and its glyph (cmd/spinloop/dashboard_render.go) + +- [ ] 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` or `stopped` + with no action in flight, between the unknown cases and healthy. +- [ ] 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` or `stopped` — a case + after the empty-state unknown case and immediately before `default`, so + no existing tier's precedence changes. +- [ ] 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) + +- [ ] 2.1 Update `TestDashTileStoppedByteStable` to expect + `dashHealthGlyph(dashNotServing)` on the `idle` node's name line. +- [ ] 2.2 In the `dashHealthTierFor` table test, move the `idle` row from + `dashHealthy` to `dashNotServing`, and add rows: a settled `stopped` + report is `dashNotServing`; an `idle` report with a start in flight is + `dashAttention` (never grey while starting). +- [ ] 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 + +- [ ] 3.1 `go test ./...` passes, `go vet ./...` is clean, `gofmt -l ./...` + prints nothing, and `go test ./... -cover` keeps total coverage >= 80%. +- [ ] 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). From 5789602c6155bfd7f6844e3edb78cd03daeb3151 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Wed, 2 Sep 2026 01:56:58 +0100 Subject: [PATCH 2/4] feat: grey the dashboard dot for undeployed nodes --- cmd/spinloop/dashboard_render.go | 23 ++++++++++++++----- cmd/spinloop/fleet_dashboard_test.go | 11 ++++++--- .../dash-undeployed-idle-grey/tasks.md | 16 ++++++------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/cmd/spinloop/dashboard_render.go b/cmd/spinloop/dashboard_render.go index b7e1cadc..dffc42fd 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 — idle, or stopped, the state of a stopped daemon engine and of a +// remote environment at scale to zero — is not serving, a faded dot rather +// than the green of a node that is up and serving; 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": + 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..9b1e2f5d 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -330,7 +330,7 @@ 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 { @@ -441,8 +441,10 @@ 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}, {"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 +460,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/dash-undeployed-idle-grey/tasks.md b/openspec/changes/dash-undeployed-idle-grey/tasks.md index 30cd6b13..6db2ec78 100644 --- a/openspec/changes/dash-undeployed-idle-grey/tasks.md +++ b/openspec/changes/dash-undeployed-idle-grey/tasks.md @@ -1,27 +1,27 @@ ## 1. The tier and its glyph (cmd/spinloop/dashboard_render.go) -- [ ] 1.1 Add a `dashNotServing` tier to the `dashHealthTier` enum, and +- [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` or `stopped` with no action in flight, between the unknown cases and healthy. -- [ ] 1.2 In `dashHealthTierFor`, return `dashNotServing` when the node +- [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` or `stopped` — a case after the empty-state unknown case and immediately before `default`, so no existing tier's precedence changes. -- [ ] 1.3 In `dashHealthGlyph`, render `dashNotServing` as the faded grey +- [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) -- [ ] 2.1 Update `TestDashTileStoppedByteStable` to expect +- [x] 2.1 Update `TestDashTileStoppedByteStable` to expect `dashHealthGlyph(dashNotServing)` on the `idle` node's name line. -- [ ] 2.2 In the `dashHealthTierFor` table test, move the `idle` row from +- [x] 2.2 In the `dashHealthTierFor` table test, move the `idle` row from `dashHealthy` to `dashNotServing`, and add rows: a settled `stopped` report is `dashNotServing`; an `idle` report with a start in flight is `dashAttention` (never grey while starting). -- [ ] 2.3 Confirm the remaining table rows are untouched: running+ready and +- [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 @@ -29,8 +29,8 @@ ## 3. Verify -- [ ] 3.1 `go test ./...` passes, `go vet ./...` is clean, `gofmt -l ./...` +- [x] 3.1 `go test ./...` passes, `go vet ./...` is clean, `gofmt -l ./...` prints nothing, and `go test ./... -cover` keeps total coverage >= 80%. -- [ ] 3.2 Check no other surface drew the green for these states: +- [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). From a960fd5b4d903633e9300f3336d58b1107e28a50 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Wed, 2 Sep 2026 02:27:36 +0100 Subject: [PATCH 3/4] fix: read the undeployed remote state as not serving A remote environment with no instance at all reports undeployed, a third not-serving state beside the daemon's idle and stopped; it fell through the tier to green, so the dashboard showed a serving dot for an empty environment. --- cmd/spinloop/dashboard_render.go | 16 +++---- cmd/spinloop/fleet_dashboard_test.go | 17 ++++++++ .../dash-undeployed-idle-grey/design.md | 43 ++++++++++--------- .../dash-undeployed-idle-grey/proposal.md | 29 +++++++------ .../specs/fleet-client/spec.md | 21 ++++++--- .../dash-undeployed-idle-grey/tasks.md | 18 ++++---- 6 files changed, 88 insertions(+), 56 deletions(-) diff --git a/cmd/spinloop/dashboard_render.go b/cmd/spinloop/dashboard_render.go index dffc42fd..a9565ef5 100644 --- a/cmd/spinloop/dashboard_render.go +++ b/cmd/spinloop/dashboard_render.go @@ -130,13 +130,13 @@ const ( // 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; then a node that answered with nothing -// serving — idle, or stopped, the state of a stopped daemon engine and of a -// remote environment at scale to zero — is not serving, a faded dot rather -// than the green of a node that is up and serving; 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. +// 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 != "": @@ -149,7 +149,7 @@ 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": + case r.Metrics.State == "idle" || r.Metrics.State == "stopped" || r.Metrics.State == "undeployed": return dashNotServing default: return dashHealthy diff --git a/cmd/spinloop/fleet_dashboard_test.go b/cmd/spinloop/fleet_dashboard_test.go index 9b1e2f5d..871f85e7 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -336,6 +336,21 @@ func TestDashTileStoppedByteStable(t *testing.T) { 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 @@ -445,6 +460,8 @@ func TestDashHealthTierFor(t *testing.T) { 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}, diff --git a/openspec/changes/dash-undeployed-idle-grey/design.md b/openspec/changes/dash-undeployed-idle-grey/design.md index 98c83584..7fc7c1c5 100644 --- a/openspec/changes/dash-undeployed-idle-grey/design.md +++ b/openspec/changes/dash-undeployed-idle-grey/design.md @@ -5,10 +5,11 @@ 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 `idle`/`stopped` reports 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 ("idle", "stopped") on -its state line, so the text is never in doubt; only the dot lies. +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), @@ -20,8 +21,8 @@ beside the shared lines. ## Goals / Non-Goals **Goals:** -- A settled report of `idle` or `stopped` with no action in flight draws a - faded grey dot instead of green. +- 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 @@ -31,9 +32,9 @@ beside the shared lines. **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` are exactly the daemon states - and the remote control-plane state that mean "not serving", so the tier - keys on the state strings it already sees. +- 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 @@ -66,26 +67,28 @@ 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` and `stopped` 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 two states named in the +`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. `stopped` is in scope alongside `idle`.** -A daemon that stops its engine reports `stopped`; a remote environment at -scale-to-zero reports `stopped` (the control-plane state passes through -`statusFromRemote` unchanged). Both are "node up, serving nothing" — the +**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 two off states wearing two different dots on one grid. The -proposal records this as a scoping decision. +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, and the table gains -`stopped` and idle-with-start-in-flight rows. +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 diff --git a/openspec/changes/dash-undeployed-idle-grey/proposal.md b/openspec/changes/dash-undeployed-idle-grey/proposal.md index fa31ca48..3ea004b3 100644 --- a/openspec/changes/dash-undeployed-idle-grey/proposal.md +++ b/openspec/changes/dash-undeployed-idle-grey/proposal.md @@ -1,31 +1,32 @@ ## Why In `fleet dashboard`, a node with no engine deployed — a daemon reporting -`idle`, or a remote environment asleep at scale-to-zero reporting -`stopped` — 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 +`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` or `stopped`, with no start or - stop in flight on the node. Its status dot is grey — the same faded shade + 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` reports to the new - tier instead of falling through to `healthy`. Everything already ranked +- `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. -- `stopped` is covered as well as `idle`: a daemon whose engine was stopped - and a remote environment that has scaled to zero are the same fact — the - node is up but serving nothing — and two "off" states wearing two - different dots would read as inconsistent on the same grid. +- 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 @@ -37,7 +38,8 @@ node's serving status, not just its absence of failure. - `fleet-client`: the "Dashboard panels show a health indicator" requirement gains a fifth tier — a node whose last refresh reports it not serving - (`idle` or `stopped`) and has no action in flight reads grey, not green. + (`idle`, `stopped`, or `undeployed`) and has no action in flight reads + grey, not green. ## Impact @@ -47,6 +49,7 @@ node's serving status, not just its absence of failure. 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` cases join the table. + 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/dash-undeployed-idle-grey/specs/fleet-client/spec.md b/openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md index 7adae2bb..d145e3e8 100644 --- a/openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md +++ b/openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md @@ -19,8 +19,8 @@ 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`, and not `stopped`, and — when the daemon reports - readiness for it — the engine is ready. A `running` node whose daemon + 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. @@ -30,9 +30,9 @@ heard from yet. 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`, - nothing has been started, or `stopped`, the state of a stopped daemon - engine and of a remote environment that has scaled to zero. + 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. @@ -95,8 +95,15 @@ heard from yet. #### Scenario: A stopped node reads not serving - **WHEN** a node's last completed refresh reports its engine `stopped` — a - daemon engine that was stopped, or a remote environment that has scaled to - zero — and no start or stop is in flight for it + 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 diff --git a/openspec/changes/dash-undeployed-idle-grey/tasks.md b/openspec/changes/dash-undeployed-idle-grey/tasks.md index 6db2ec78..11b0b754 100644 --- a/openspec/changes/dash-undeployed-idle-grey/tasks.md +++ b/openspec/changes/dash-undeployed-idle-grey/tasks.md @@ -2,13 +2,14 @@ - [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` or `stopped` - with no action in flight, between the unknown cases and healthy. + `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` or `stopped` — a case - after the empty-state unknown case and immediately before `default`, so - no existing tier's precedence changes. + 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. @@ -18,9 +19,10 @@ - [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` - report is `dashNotServing`; an `idle` report with a start in flight is - `dashAttention` (never grey while starting). + `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 From 52100911f6d89a2df87a2ccc9a053d37c2ed8e5b Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Wed, 2 Sep 2026 02:40:15 +0100 Subject: [PATCH 4/4] chore(openspec): archive dash-undeployed-idle-grey and sync its spec --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/fleet-client/spec.md | 0 .../tasks.md | 0 openspec/specs/fleet-client/spec.md | 59 +++++++++++++++++-- 6 files changed, 53 insertions(+), 6 deletions(-) rename openspec/changes/{dash-undeployed-idle-grey => archive/2026-09-02-dash-undeployed-idle-grey}/.openspec.yaml (100%) rename openspec/changes/{dash-undeployed-idle-grey => archive/2026-09-02-dash-undeployed-idle-grey}/design.md (100%) rename openspec/changes/{dash-undeployed-idle-grey => archive/2026-09-02-dash-undeployed-idle-grey}/proposal.md (100%) rename openspec/changes/{dash-undeployed-idle-grey => archive/2026-09-02-dash-undeployed-idle-grey}/specs/fleet-client/spec.md (100%) rename openspec/changes/{dash-undeployed-idle-grey => archive/2026-09-02-dash-undeployed-idle-grey}/tasks.md (100%) diff --git a/openspec/changes/dash-undeployed-idle-grey/.openspec.yaml b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/.openspec.yaml similarity index 100% rename from openspec/changes/dash-undeployed-idle-grey/.openspec.yaml rename to openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/.openspec.yaml diff --git a/openspec/changes/dash-undeployed-idle-grey/design.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/design.md similarity index 100% rename from openspec/changes/dash-undeployed-idle-grey/design.md rename to openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/design.md diff --git a/openspec/changes/dash-undeployed-idle-grey/proposal.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/proposal.md similarity index 100% rename from openspec/changes/dash-undeployed-idle-grey/proposal.md rename to openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/proposal.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/dash-undeployed-idle-grey/specs/fleet-client/spec.md rename to openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/specs/fleet-client/spec.md diff --git a/openspec/changes/dash-undeployed-idle-grey/tasks.md b/openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/tasks.md similarity index 100% rename from openspec/changes/dash-undeployed-idle-grey/tasks.md rename to openspec/changes/archive/2026-09-02-dash-undeployed-idle-grey/tasks.md 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