Commit Graph

944 Commits

Author SHA1 Message Date
Anso 9f480e133b chore(whats-new): replace the feed with the 0.98.0 entries (#2170)
A new release now opens on a fresh What's New list instead of appending to
the previous one. The feed holds the 13 entries that shipped in 0.98, with
screenshots for the seven most visible changes, and drops the 0.97 entries,
the RBAC entry from 0.97.0, and three smaller 0.98 items that their feature
pages already cover.

Entry ids are prefixed with the release (v0-98-...), and an authoring check
fails when ids from more than one release share the file. That keeps a
leftover entry from shipping and guarantees the new last id differs from any
id a user has already seen, so everyone gets the unseen indicator once while
fresh installs stay quiet.

The eight screenshots used by removed entries are deleted. The new ones are
element crops with host names, node names and repository paths replaced.
2026-10-22 20:12:48 -04:00
Anso 5b41c0512f docs: reorganize GitOps documentation into one section (#2167)
* docs: reorganize GitOps documentation into one section

Add a GitOps overview that defines the model once (Direct sources versus
Blueprints, the chain from commit to container, the three authority
steps, the policies, source states, executable identity). Split the
oversized Git Sources and Blueprints pages into focused pages for Git
authentication, provider and CI webhooks, repository secrets, and
Git-managed Blueprints, and trim the remaining pages to their own slice.

Group the GitOps pages in the navigation and split the old "Deploy and
automate" group into "Deploy and recover" and "Automate". Existing page
slugs are unchanged. Correct the provider hook labels to match the app,
and repoint What's New links to the new Git-managed Blueprints page.

* docs: add current GitOps screenshots and correct UI labels

Retake the Git Sources, Blueprints, and GitOps portfolio screenshots on
the integration image, and add screenshots to the new GitOps pages
(answer, path, and evidence block; SSH deploy key form; provider hooks;
Secrets tab; Convert to Git, placement review, and rollout controls).

Correct labels to match the app: Deploy key (SSH), the provider hook
event scopes, and the encrypted source policy buttons. The change plan
dialog shows the stack name, not the commit.

* docs: map the GitOps policy names once in the overview

Evidence, the policy controls, and the Git source form name the same
settings differently. State the mapping in one place.
2026-10-22 19:20:47 -04:00
Anso 77026fc84f fix(deps): bump dompurify to 3.4.17 (#2168)
Two advisories (GHSA-cg2g-xh64-v4q7, critical, and GHSA-mrv2-3pqr-j5jr,
high) affect dompurify up to 3.4.16 and fail the dependency audit gate.
dompurify reaches the frontend only through monaco-editor, which pins
3.4.15, so raise the existing override to ^3.4.17 and refresh the
lockfile.
2026-10-22 18:48:54 -04:00
Anso 935158b5e6 feat(resources): bring the Resources page to the design system (#2156)
* fix(resources): load the Resources page and volume prune without the slow image df

On a host that uses the containerd image store, the image section of
`docker system df` takes about 30 seconds, and the Resources page waited
on it for its footprint figures. Pruning volumes repeated that full df
once per volume, and the prune preview ran into its 12 second timeout.

- The disk-usage figures now come from calls that answer in milliseconds:
  a shared-size image listing plus the volume and container sections of
  df. The reclaimable image figure is the bytes unused images do not
  share with another image; the exact df is used only when the daemon
  does not report shared sizes.
- External and managed volume bytes were always zero because the volume
  listing carries no usage data. They now come from the volume section.
- Prune planning and execution read only the volume section for volume
  usage, ask for container sizes only when containers are planned, take
  shared image sizes from the listing, and inspect idle networks in
  parallel. When volume usage cannot be read the plan fails with an
  error instead of reading as nothing to prune, and execution skips the
  volume with a clear reason.
- The Resources page loads the tables and the disk usage separately, so
  the tables no longer wait on the figures. Refreshing after an action
  keeps the rows on screen, and a failed load shows an error with Retry
  instead of an empty table.

On a node with 200 volumes and 85 images, the disk-usage request went
from about 31 seconds to 0.2 seconds, prune previews from a 12 second
timeout to under 0.4 seconds, and pruning 200 volumes now takes about
1.5 seconds.

* feat(resources): bring the Resources page to the design system

The Resources page had a reclaim banner behind an opt-in setting, a disk
footprint card, four equal-weight Quick Clean tiles, and per-node chrome
of its own. It now follows the same shape as the other primary pages.

- A hero masthead gives the one answer: how much a cleanup would free,
  Tidy when nothing is left, Measuring disk while usage loads, and Disk
  usage unknown when the node could not report it (never Tidy). Managed
  and external bytes sit beside it.
- Reclaim and a cleanup menu (prune unused networks, prune stopped
  containers) sit in the tab strip. Images and Volumes each have their
  own Prune unused. Every prune opens an itemized preview with a scope
  switch (Sencho-managed by default, or All Docker) that rebuilds the
  plan when it changes.
- The reclaim banner and its opt-in setting are removed, along with the
  footprint card and the Quick Clean tiles.
- Filters use the shared segmented control, scans use the shared scan
  flow, and confirm buttons show progress through the shared busy label.
- The Unmanaged tab is named External. Its rows use checkboxes and its
  project headers are quiet rows. A Rollback protected badge on a
  stack-held image opens the Rollback tab.
- The docs page, the pages that mention the old tiles, and the
  screenshots are updated.
2026-10-20 09:51:36 -04:00
Anso 880e6c6489 fix(resources): load the Resources page and volume prune without the slow image df (#2155)
On a host that uses the containerd image store, the image section of
`docker system df` takes about 30 seconds, and the Resources page waited
on it for its footprint figures. Pruning volumes repeated that full df
once per volume, and the prune preview ran into its 12 second timeout.

- The disk-usage figures now come from calls that answer in milliseconds:
  a shared-size image listing plus the volume and container sections of
  df. The reclaimable image figure is the bytes unused images do not
  share with another image; the exact df is used only when the daemon
  does not report shared sizes.
- External and managed volume bytes were always zero because the volume
  listing carries no usage data. They now come from the volume section.
- Prune planning and execution read only the volume section for volume
  usage, ask for container sizes only when containers are planned, take
  shared image sizes from the listing, and inspect idle networks in
  parallel. When volume usage cannot be read the plan fails with an
  error instead of reading as nothing to prune, and execution skips the
  volume with a clear reason.
- The Resources page loads the tables and the disk usage separately, so
  the tables no longer wait on the figures. Refreshing after an action
  keeps the rows on screen, and a failed load shows an error with Retry
  instead of an empty table.

On a node with 200 volumes and 85 images, the disk-usage request went
from about 31 seconds to 0.2 seconds, prune previews from a 12 second
timeout to under 0.4 seconds, and pruning 200 volumes now takes about
1.5 seconds.
2026-10-20 09:17:17 -04:00
Anso 48b8d698b3 feat(gitops): observe remote rollout targets over the node proxy (#2149)
* feat(gitops): observe remote rollout targets over the node proxy

A health rollout policy could not gate a rollout target on another node. The
gate runs on the instance that owns the rollout, a remote node's Docker daemon
is not reachable from there, so every observation of a remote target ended
unknown and a policy other than observe was refused before dispatch.

The node that hosts the stack now serves its own runtime facts at
GET /api/stacks/:stackName/health-observation, with the declared Compose
restart intent when asked for it, and the gate reads them over the node proxy.
Local nodes keep the in-process Docker read, and the gate keeps one observation
engine, one run row and one attribution path.

health-gate-observation-v1 is advertised by every build that serves the route,
and the placeholder gitops-health-rollout-policy is never advertised, so a hub
that predates the protocol still refuses a remote target before dispatch.

Failures stay fail-closed: a node that does not report the facts a verdict
needs ends the observation unknown at the first poll, any other unreadable poll
keeps the three-strike budget, and a clean exit whose declared intent could not
be read ends unknown rather than failed, so a rollback policy cannot restore a
workload that may have completed exactly as declared. A node row this instance
does not have is unreadable rather than local.

The node-proxy JSON read is extracted into one helper, and the leaf fetches in
the artifact-resolve path use it.

* chore(security): make the compose path containment checks analyzer-visible

The CodeQL javascript-typescript check reports js/path-injection at two sinks
that a rollout health observation reaches with a request's stack name:
FileSystemService.hasComposeFile (the fsPromises.access call) and
ComposeService.renderConfig (the spawn cwd).

Both are guarded already, but not in a form the analyzer credits: the first
called the wrapped assertWithinBase helper, and the second relied on a check
scoped to the branch that assigned the working directory rather than one that
dominates the sink. Both now carry the inline barrier the codebase already uses
elsewhere for this rule, in the same scope as the sink.

No behavior change. assertWithinBase and the inline check use the same rule, and
every caller of hasComposeFile already passes a stack directory under the
compose base. renderConfig re-checks the same root its branch check validated,
immediately above the spawn.

* chore(security): keep the hasComposeFile guard in the shape the analyzer reads

The first pass combined the prefix check with an equality disjunct, which the
path-injection query does not model as a guard. The single startsWith check is
the shape the codebase already uses at its other sinks, and it is what clears
the alert. The compose base itself is not a stack, so refusing it is the
contract every caller already relies on; the test pins that too.

* fix(health-gate): decide definite failures before an ambiguous clean exit

An exit with code 0 whose declared Compose restart intent cannot be read is
undecidable on its own, but it was ending the observation immediately. A peer
that was unhealthy or had exited non-zero later in the same poll was never
inspected, so the run ended unknown and a retry, stop, or rollback policy was
replaced with a pause.

Hold the ambiguity until the rest of the poll is inspected, in both the stack
and the service scope, and decide it only where it is the sole unsatisfied
evidence. At the window end definite failures are judged first: a missing
expected container or replica, an unhealthy container, and a replica count the
gate could not satisfy all outrank an ambiguous peer.

A service replica count can now no longer be satisfied by a replica the gate
never watched while a tracked replica exited unexplained, and an unhealthy
replica of the operated service that appears after the gate armed fails on the
poll where it is seen, since a later recovery would erase the evidence. Scale-0
services keep their previous accounting: a failed leftover is ignored, while a
running container or a declared clean one-shot is still count drift.

Adds regression coverage for both container orders, both scopes, unhealthy and
non-zero-exited peers, a missing replica at the window end, untracked
replacements, the unhealthy-recovery case, and the scale-0 boundary.
2026-10-19 21:16:13 -04:00
Anso 1a52b5052e fix: group the GitOps portfolio filters into one popover (#2154)
The portfolio filter row was five flat dropdowns that wrapped across the
toolbar. It is now one Filters popover with labelled sections (Attention,
Target, Node, Drift, Evidence) and a count badge, the same shape as the
Fleet toolbar. Stack and Blueprint scopes set by other surfaces still show
as removable chips, and Clear filters still resets everything, including
filters only a deep link can set.

Option groups expose their section label and pressed state to assistive
technology, and the Node and Drift dropdowns are tied to their labels.

The Fleet filters popover now lays out its Critical Only button in the same
row as the other sections.
2026-10-19 14:34:47 -04:00
Anso d8891f3b5e fix: keep outline buttons and filter selects flush with the card in the Dim theme (#2153)
Outline buttons and four filter selects (Fleet toolbar, GitOps portfolio,
Readiness findings, Security images) painted with the page background, which in
Dim is darker than a card, so they read as black holes on cards. They now use a
control fill that is card-level in Dim. OLED and Light keep the page background
exactly as before.
2026-10-19 13:51:00 -04:00
Anso 495bac574a fix: polish notification actions, topology icon, minimized progress, security sheet and timeline lanes (#2151)
- Notification rows reserve room for every visible action, so the mute menu
  no longer sits on top of long messages.
- The Networking topology tab and the "Show in topology" row action use the
  graph-star icon instead of the Git branch icon.
- A minimized deploy progress pill now closes itself once the task succeeds,
  even if the pointer was over the dialog when it was minimized.
- The scan comparison sheet no longer refetches and blanks every time its
  parent re-renders.
- The Security masthead drops the review action that the "Why Action needed"
  card already offers.
- Timeline pills sit centered in their own lane instead of straddling the lane
  above, and compose file backups now appear in the Backups lane.
2026-10-19 13:32:57 -04:00
Anso e9f824266b feat: open a published port at its configured service URL (#2124)
* feat: open a published port at its configured service URL

Store the address as the compose label sencho.service.url so a port click follows that host, and keep the generated link when the label is absent.

* fix: reject an escaping compose path before reading it

The compose filename scan checks the path stays inside the compose directory before it stats the file, so a rejected path is not treated as a missing file.

* fix: keep inherited labels and unsaved edits when a service URL is saved

A URL edit copies inherited labels onto that service, a changed compose filename keeps the draft unless the operator reloads, and a missing URL list still opens the generated link.

* fix: keep a service URL edit on the service that was opened

A shared label or service anchor is copied before the edit, clearing a merged URL does not restore the inherited address, and a running container opens only its own saved URL.

* fix: keep empty labels and dynamic ports on a service URL

A URL edit dropped empty labels and failed when a nested anchor was still used. A Docker-assigned port now opens that service's own URL, and the pencil stays unavailable when the service is not in the open compose file.

* fix: keep a service URL on the service that was edited

A shared URL value no longer changes another service. Open App does not follow a different service's dynamic address, a named port on its own bind address stays clickable, and a repeated list label takes the new address.

* fix: keep a service URL dialog on the stack it was opened for

The dialog now records the node and stack it was opened on, closes when that selection changes, and refuses the save before it can write a different stack. Clearing or replacing a list alias updates only the edited service. A URL supplied by another compose file stays on that file, and reloading the compose source refreshes the cached addresses.

* fix: edit a service URL without rewriting the rest of the compose file

A save splices the label into the original text, so octal modes, line endings, and unrelated keys stay as written. A node with no host keeps the plain port, a refresh keeps the last URL, and Open App opens its tab before the lookup.

* fix: keep service URL edits and compose saves inside the edited stack

- Refuse a compose file, or a stack directory, that resolves outside
  <compose root>/<stack>, so a read or save of one stack cannot reach a
  sibling stack's compose file. Covers saveStackContent too.
- Edit flow-style labels and services in place, copy anchored flow
  nodes without their defining key, keep source tokens such as 0440 and
  022 when labels are copied, append after the last real label (before
  trailing comments, and at an end of file with no newline), and keep
  the separator when an empty URL value is set or cleared.
- Check every patch before it can be saved: the file is read with YAML
  types and as untyped text, and anything that changes outside the URL
  label, drops a comment inside brackets, or touches a file with a
  custom YAML tag is refused with a clear message.
- Return the cached service URLs only for the node and stack they were
  read for, so a port never links to the previous selection.
- Log stacks the networking summary skips instead of dropping them
  silently.

* test: assert the networking summary skips a sibling-linked stack

A stack whose compose file resolves outside its own directory is skipped
with a warning. Assert the linked stack stays out of every bucket, the
healthy stack is still summarized, and the skip is announced.

* fix: keep service URL edits effective and prototype-safe

The effective-anatomy response now reports, per service, whether the open
main compose file controls the sencho.service.url label (serviceUrlEdit).
That is established from the authored chain: the label key presence is read
in the files merged after the open one (deploy spec order, or the resolved
main file plus the auto-discovered override). A later file that sets the key
blocks the edit whatever its value (invalid, interpolated, or equal); a later
file that uses include, extends, a custom YAML tag, or COMPOSE_FILE, or that
cannot be read, refuses the edit as unverifiable. The pencil lock, the save
guard, and a fresh re-read immediately before the write consume this
metadata, so the editor no longer infers ownership from the label value or
its absence. Post-save verification stays.

Service-keyed maps are built on null prototypes and read through own-property
lookups across the backend extractor, the response parser and resolvers, and
the local anatomy facts. Services named constructor, toString, hasOwnProperty,
or __proto__ now resolve the generated link when they have no label and keep
an own override when they do.

* fix: refuse a service URL edit the compose chain would shadow or reveal

A later bare `sencho.service.url` list item (`- sencho.service.url`) sets
the key to an empty value and shadows the open file, so the source
authority now counts it as a key contribution like the `key=value` form.

Clearing is not the mirror of setting: removing the open file's direct
label can surface an earlier chain file, the open file's own include, a
service's extends target, or its label_file. The anatomy response now
carries a per-service clear authority alongside the set authority, the
editor refuses an empty save the chain would keep winning or reveal, and
the patch helper refuses the same layouts before writing. Setting a URL
is unaffected.

The patch helper also treats a bare list item as the key when setting or
clearing, so a set no longer leaves a trailing empty entry that renders
as an empty value.

Operator docs describe the set and clear limits accurately.

* fix: refuse interpolated label keys and honor explicit dynamic ports

Compose interpolates a list label's key, so `- "${URL_LABEL}=https://app"`
sets `sencho.service.url` even though the authored source never names it.
A later file using that shape reported the open file as editable and the
write did not take effect; an earlier or later one failed to block a clear.
Such a key now marks the service unverifiable, so both guards refuse before
the save. A literal key with an interpolated value stays a known set.

`published: 0` (from `"0:80"` or `{ target: 80, published: 0 }`) asks
Docker to assign the host port, but the dynamic-owner predicate treated it
as a fixed host, so a container-port click and Open App fell back to the
generated node address. It now counts as automatic allocation, leaving UDP
binding, named-service isolation, and ambiguity refusal unchanged.

* fix: keep a saved service URL effective and a port link on its owner

- Read the open compose file's own source in the service URL set
  authority: a labels list whose key is interpolated (`- "${NAME}=..."`)
  makes that service unverifiable instead of editable, including when the
  file's other facts cannot be traced (a top-level `include` or a custom
  tag) and when the file cannot be read at all.
- Refuse a set and a clear in the editor buffer when the target service's
  label list carries an interpolated key, so an unsaved draft is caught
  before any write.
- Open a named container's own automatic binding URL on its card when
  Docker assigned it the host port, even when another service's saved
  file declares the same number, and refuse a saved-only single fixed
  declaration when another service's automatic binding could hold it.
- Carry the runtime main-port owner (`com.docker.compose.service`) from
  the bulk stack status through to Open App so it opens the address of
  the running service that owns the port.

* fix: keep a runtime container card on its own service URL

- Treat a named runtime container as direct ownership evidence: when a
  card carries both the compose service and the running container's own
  port, that service's configured URL applies even after the saved port
  declarations changed or were removed without a redeploy.
- Keep saved-model inference and its ambiguity refusals for callers
  without runtime port data, such as the anatomy footer.
- Cover the changed-port, removed-binding, hostless-remote, and rendered
  anchor cases in resolver and component tests.
- State in the stack management docs that the configured label applies
  first and the generated address is the fallback.

* fix: keep merged label text and a runtime card's generated link

Clearing a service URL from a labels map that also merges a YAML anchor
materialized the whole map from parsed values, which deleted operator
comments inside the map and respelled unrelated scalar tokens. The clear
now copies inherited entries from their own source text, leaves direct
entries untouched, and refuses layouts whose text cannot be kept.

A running container whose compose service has no URL override fell
through to saved-model inference and could turn ambiguous when other
saved services claimed the same host port. Runtime ownership now answers
with the service's own URL or the ordinary generated host and port.

Regression tests cover the preserved comments and tokens (block, flow,
CRLF, merge lists, nested merges, shared anchors) and the generated
anchor plus copy target for a runtime owner without an override.

* fix: wait for the first service URL lookup before opening a port

A first effective-anatomy lookup was read as "no override", so a
published-port link and Open App could open the generated host and port
while a configured service URL was still being fetched. The in-flight
entry now stays pending: the port renders as plain text until the outcome
is known, and Open App opens the tab in the click and navigates once the
lookup resolves. Completed outcomes (no label, a failed load, an older
node) keep the generated fallback, and a refresh that still holds the
prior model keeps rendering it.

* fix: keep a long service URL on the card and a refused path honest

A long host now truncates so the copy button and pencil stay clickable, and every port link keeps its open arrow. Clearing the only service URL removes the labels key, including when the address has a fragment. Opening a stack the server refuses shows that reason. Effective anatomy refuses a compose file or discovered override that links outside the stack. The editor shows a new URL only after the save is accepted.

* fix: keep the port link visible and drop a sole URL list

The port mapping stays on the card when the host is long, and on a phone the identity line wraps above the action buttons. Clearing a labels list that only holds the service URL removes the labels key, while a comment still keeps an empty value.

* fix: keep a cleared service URL renderable and the port controls reachable

A clear that left the labels map with no entries wrote `labels:` with no
value, which Compose rejects ("must be a mapping"), so the stack stopped
rendering and every pencil in it disappeared. The key now goes with its
last label, and a comment the removal would drop keeps the entries as an
empty map or list under the key instead. That covers a clear through a `<<`
merge, a sole label with a trailing comment, a comment line closing the
block, and a shared anchor whose other consumer keeps its address. A comment
between the removed entries is still refused before any write.

The container list now clamps to its pane, so a long host truncates and the
port link's copy button and pencil stay reachable at 1440px and below
instead of sitting past the pane edge. The copy button also gets the 44px
phone target the pencil already had.

A refused compose path answers with the condition instead of "path is not
allowed", and the parser no longer warns about the custom tags those edits
refuse.

Every layout the clear can produce renders under `docker compose config`.

* fix: keep the port line readable in a narrow container pane

The pane clamp left the action icons at full size, so the name and port link collapsed. The card now wraps those icons once the name column would drop below 12rem. A refused URL edit stays in the dialog until the address or the compose buffer changes.

* fix: keep the service URL editable after a failed list load

A failed first load keeps the generated link, explains that the list is unavailable, and offers Retry. A later failed refresh keeps the last address and the pencil. The pencil tooltip matches the copy control.
2026-10-17 18:33:13 -04:00
dependabot[bot] 7df1514ff1 chore(deps): bump the all-npm-frontend group across 1 directory with 25 updates (#2146)
Bumps the all-npm-frontend group with 25 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.23` | `1.2.1` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.11` | `1.4.1` |
| [@radix-ui/react-context-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/context-menu) | `2.3.7` | `2.4.1` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.23` | `1.3.1` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.24` | `2.2.1` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/hover-card) | `1.1.23` | `1.2.1` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.15` | `2.1.17` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.23` | `1.3.1` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.18` | `1.4.0` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.7` | `2.4.1` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.15` | `1.2.0` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.7` | `1.6.1` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.3.3` | `1.5.0` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.21` | `1.2.1` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.16` | `1.4.1` |
| [cronstrue](https://github.com/bradymholt/cRonstrue) | `3.27.0` | `3.32.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.52.0` | `1.57.0` |
| [motion](https://github.com/motiondivision/motion) | `14.0.0` | `14.2.0` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.7` | `1.8.1` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.7` | `14.6.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.4` | `26.6.5` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.1` | `6.1.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.0` | `8.71.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.2` | `8.3.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |



Updates `@radix-ui/react-alert-dialog` from 1.1.23 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-checkbox` from 1.3.11 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-context-menu` from 2.3.7 to 2.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/context-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/context-menu)

Updates `@radix-ui/react-dialog` from 1.1.23 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.24 to 2.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-hover-card` from 1.1.23 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/hover-card/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/hover-card)

Updates `@radix-ui/react-label` from 2.1.15 to 2.1.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.23 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-scroll-area` from 1.2.18 to 1.4.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.3.7 to 2.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.15 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slider` from 1.4.7 to 1.6.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-slot` from 1.3.3 to 1.5.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-tabs` from 1.1.21 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-tooltip` from 1.2.16 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `cronstrue` from 3.27.0 to 3.32.0
- [Release notes](https://github.com/bradymholt/cRonstrue/releases)
- [Changelog](https://github.com/bradymholt/cRonstrue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bradymholt/cRonstrue/compare/v3.27.0...v3.32.0)

Updates `lucide-react` from 1.52.0 to 1.57.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.57.0/packages/lucide-react)

Updates `motion` from 14.0.0 to 14.2.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v14.0.0...v14.2.0)

Updates `radix-ui` from 1.6.7 to 1.8.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `@testing-library/user-event` from 14.6.7 to 14.6.8
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.7...v14.6.8)

Updates `@types/node` from 26.6.4 to 26.6.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 6.1.1 to 6.1.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.2/packages/plugin-react)

Updates `typescript-eslint` from 8.71.0 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

Updates `vite` from 8.3.2 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-context-menu"
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-hover-card"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: cronstrue
  dependency-version: 3.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: lucide-react
  dependency-version: 1.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: motion
  dependency-version: 14.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: radix-ui
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: "@types/node"
  dependency-version: 26.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 13:47:46 -04:00
Anso 903419814c fix: serve the UI typefaces from the instance (#2132)
* fix: serve the UI typefaces from the instance

The page loaded its faces from a third-party host on every visit, including sign-in. Bundle those faces and stop allowing remote font and style sources.

* fix: keep the full subset coverage of the served typefaces

Bundling the faces left only the latin and latin-ext subsets declared, so
Cyrillic, Cyrillic Extended, Greek, Greek Extended, Vietnamese and the
symbols2 block rendered in a system face instead of the selected one.

Declare every subset the installed packages publish for the faces the
Appearance preference offers, with the range the package subsets each face
to, and hold the declarations to those ranges and to representative
characters per script in the font test.
2026-10-17 12:16:39 -04:00
Anso 6da06ce76e fix: keep readiness finding verbs inside the pane (#2127)
The findings table was wider than the fleet pane, so the resolving verb sat past a horizontal scroll. Size the columns as a share of the pane until there is room for stack and domain.
2026-10-16 08:35:11 -04:00
Anso 712c96ac6b fix(editor): keep the compose toolbar inside the pane at 1280 (#2125)
The save cluster was laid out past the viewport, so Save & Pull Images
was unreachable without widening the window.
2026-10-15 20:10:35 -04:00
Anso 7a6682ed3f fix: pull the next commit on a manual Git-managed Blueprint (#2130)
* fix: pull the next commit on a manual Git-managed Blueprint

A claimed manual source had no operator fetch: polling skips it, and stack Pull now stays refused. Pull source stages a candidate and leaves acceptance to Accept source, except an automatic policy, which accepts the same way a poll does.

* test: supply a node list to the GitOps sheet mocks

The authority row reads the default node from the node context. These mocks omitted nodes, so the sheet threw before it rendered.

* test: type the Git source permission mock without unused parameters

The default can implementation always returns false. Naming the permission arguments made lint fail.

* fix: send the Blueprint id and report what Pull source did

The claimed sheet posted the internal application id, so the pull never reached the Blueprint. An automatic pull could accept and start a rollout while the result only said the commit was fetched.

* fix: report a continuing automatic pull instead of a failed one

Acceptance can commit before preparation and rollout finish. Answer once that wait reaches its deadline, keep the source locked until the work finishes, and do not describe the deadline as a failed pull.

* fix: report why an automatic pull cannot prepare a commit

A completed preparation reason was replaced by the generic unresolved-artifact skip. Keep that reason, and describe the claimed sheet by the source actions it still offers.

* fix: refuse a pull that would replace an interrupted apply's candidate

Apply recovery stays eligible only while the interrupted generation is still the current candidate. Pull source waits in that case. An interrupted fetch still pulls, and a pull stays available once the candidate has moved on.

* refactor: share the bounded follow-up after a Git-managed acceptance

Accept source and an operator pull each had their own copy of the deadline, the continuation notes, and the slot hold. One follow-up now answers both.

* fix: declare the post-accept deadline timer as const

The deadline id is assigned once. A later assignment made the lint fail.

* fix: confirm Accept source before it can start a rollout

Accept source recorded the fetched commit immediately, including when stored rollout authorization can deploy it, and those actions were not on the sheet Overview. The confirmation names the commit, the approved targets, and whether a rollout can start, and confirm posts the generation that was on screen.

* fix: read placement approval only on a Blueprint projection

The accept preview read approvals on every projection, and the one with nothing to show has no approvals. The Blueprint check comes first so the build can read the placement ref.

* fix: name the approved rollout scope and report settled failures

Two confirmations could describe a destination the action would not use, and
one result reported work as starting when it had already failed.

Accept source named every observed target row as approved placement. Those rows
are where an application runs, not where a rollout goes, and they disagree
whenever placement moved since the last rollout ran: an approved node has no row
until the rollout that places one, and a withdrawn node keeps one. A projection
now carries the scope the placement approval froze, read from the rollout
candidate and accepted only once the approval resolves for that set, which is
the evidence the dispatch boundary already uses. Both confirmations read it
through one reader and name the approved nodes, or say the scope cannot be
read rather than naming a set they did not.

The bounded follow-up used its continuing-work answer for a deadline and for a
rejection alike, so a dispatch that had already failed was reported as
starting, with nothing pending. The three outcomes are now distinct: only a
deadline holds work still running, and a rejection reports the rollout as held
with the failure named.

* fix: bind a source confirmation to the request that carries it

Pull source and Accept source asked the operator to confirm a destination and
then sent nothing that named it. Fetch posted an empty body and acceptance
posted only a generation, so execution read whatever the placement and the
rollout policy happened to say at the moment it dispatched. An operator could
confirm a placement for one node and deploy to another, and a dialog promising
a waiting rollout could start one after another session armed the policy.

Both bodies now carry what the dialog read: the placement approval the scope was
resolved from, and whether the confirmation said a rollout could start. The
description and the claim are frozen together when the overlay opens, so the
words the operator agreed to and the state the server checks are one read of one
projection.

The claim is checked at every consequential boundary rather than only at the
route, because each one is preceded by asynchronous work: at the route and at
the controller entry, again before an automatic acceptance after the reconcile,
and again in the handoff after preparation. A refusal that lands after the
acceptance committed is held with the reason instead of answered, since the
reconciler re-drives any accepted generation with nothing to check it against
and a refusal that recorded nothing would be undone by the next tick. The hold
carries an operator origin, so only a person clears it, and the recovery is the
existing one: review the placement, then resume the rollout.

The destination is compared exactly, because the dialog named specific nodes.
The rollout promise is compared one way: a confirmation that allowed a rollout
and finds the policy waiting for an operator does less than it said, and the
dispatch gate already reports that, while the reverse would start something the
operator was told would wait.

A manual or review pull only fetches, so it asks for nothing, and the poll and
resume paths send no confirmation at all because a cadence is configured rather
than confirmed.

* fix: keep the confirmation hold inside the mode that can clear it

Three ways the refusal did not survive contact with the rest of the model.

The hold was recorded below the rollout authorization policy gate, so a manual
source paused the rollout over a confirmation the handoff could never have acted
on. A manual policy never dispatches from here, and its Authorize is already the
operator's own decision against the placement as it stands, so all that bought
was an extra resume step before an authorize the pause also blocks. The gate is
back first and the confirmation is checked under it, so only an automatic policy,
the one that would have dispatched on its own, is held.

The hold outlived the Blueprint. Every resume route resolves a Git-managed
Blueprint and answers NOT_GIT_MANAGED for the row afterwards, so a pause carried
through Retire or Detach read as a paused rollout nothing could clear. Both
demotion paths now clear it, which is what the Blueprint demotion already does
with every other column it invalidates.

And the module claimed the check ran at every consequential boundary, which is
only true of the boundaries an operator action reaches. A cadence carries no
claim to check, so the poll and the reconciler's re-drive are not among them, and
the hold is what keeps them from undoing a refusal.

Each correction is covered by a test that fails without it: a manual-policy
acceptance whose placement moves during preparation is not held, an operator hold
survives a later acceptance on the policy's own behalf, and retiring a Blueprint
releases a held rollout.

* fix: make a confirmed acceptance keep its placement past the request

A confirmation carried only in a request body stops existing when the request
answers, and two paths outlive it while both still dispatch. Preparation can run
past its deadline and hand the handoff to the reconciler, and a restart re-drives
any accepted generation with no request in the picture. The guard therefore
protected the fast path and nothing else, which is worse than none: the operator
was told to review a change that then deployed on its own.

The acceptance now records the precondition on its own approval row, in the
column that already existed there and was unused for this kind. A null recording
is a real answer rather than missing evidence: an acceptance made by the poll
records none, because a cadence is configured rather than confirmed.

It is enforced in ensureRolloutAuthorization, the single funnel every mint
passes and the only place an unattended dispatch reaches, so it covers the
reconciler's re-drive and the startup preflight backfill as well as the request
that carried the claim. Only a policy mint is bound: an operator mint is the
decision the confirmation deferred, taken against the placement as it stands. A
stale precondition holds rather than merely refuses, because a refusal with
nothing behind it is re-attempted every reconcile and leaves a rollout queued
with no reason attached. The acceptance itself stands, so the content is
recorded and only its destination waits for a person. An application-scoped
resume releases the recording; a target-scoped one does not.

The same function now re-resolves ingredients after the registry preflight
await and refuses when the placement approval or the frozen node set moved. The
mint re-reads ingredients inside its write transaction, so a placement
re-approved mid-preflight produced a rollout naming the replacement nodes with
readiness evidence that described the old ones. That gap predates this work, but
a request-local guard cannot see past an await, and a rollout generation is
immutable once written.

* fix: compare the whole ingredient record, and let an operator mint decide

Two gaps in the durable guard, both found by reading it against the paths that
reach it.

The post-await comparison watched the placement approval and the frozen node set
and nothing else. A source acceptance landing during the same preflight window
changes the generation, the artifact set and the acceptance without moving
either of those, so the check passed and the mint named the new generation with
readiness evidence evaluated for the old one, which is the same defect the
comparison was added to close. Every field the mint copies onto an immutable
rollout generation is compared now.

An operator mint is exempt from the guard, because asking to authorize the
rollout is the decision the confirmation deferred. Nothing moved the recording
on that path, so once the authorization lapsed the automatic policy held a
rollout asking the operator to resume a placement they had deliberately
authorized. An operator mint now re-points the recording at what was decided,
inside the mint's own transaction, which also keeps the guard working for the
next replacement.

* fix: keep the whole source confirmation after the request ends

A stored placement pointer let a later re-drive start a rollout the operator was told would wait, or deploy to a destination the confirmation never named. The acceptance now stores both facts, and unattended authorization reads that record rather than the request that already finished.

* fix: show source confirmations and start a ready automatic resume

Operators were asked to confirm a sentence they could not read, and Resume reported that an automatic policy would start a rollout it never dispatched.
2026-10-15 18:35:56 -04:00
Anso d48daa08ae feat: raise the Compact launcher quick-link cap to 10 (#2120)
Operators can pin ten destinations on the bar. The recommended starting set stays at six, and the server still accepts up to sixteen so a ten-pin list keeps syncing.
2026-10-12 00:35:33 -04:00
Anso 7c99a65085 feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails (#2119)
* feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails

Fresh installs now start with the Signature chart palette, per-service
log chip colors, inline deploy progress, one retained rollback
generation per stack, and the missing-required-env deploy guard on.
Existing stored values are unchanged.

* fix(settings): make appearance reset match the fresh-install chart default

Reset to default was still applying the Calm preset, which put the muted
chart palette back. It now restores upright headings, reduced effects, and
the Signature chart palette, and the appearance screenshots match that look.
2026-10-12 00:32:36 -04:00
Anso 23f87354c4 feat(stacks): rescan from the scan banner; recheck Blueprint name conflicts (#2118)
The post-deploy scan banner in the stack editor gains a Rescan verb. It
starts the same scan on the node that owns the stack, follows it to the new
result, and says so when the rescan finishes clean, since the banner then
goes away. A second click while a scan is running starts nothing, and the
scan-status read now reports whether one is running. Dismiss stays separate
and still lifts when a new attempt lands.

A Blueprint deployment stuck in Name conflict now says what to do and offers
Open conflicting stack and Check again. Check again only re-evaluates the
conflict: when the name is free the row returns to Pending and the next
reconcile deploys it, with the usual state review for stateful Blueprints.
It never deploys by itself, Git-managed Blueprints are left to their
rollout, and an ownership check that cannot tell keeps the row in conflict.

The two banner dismiss hooks are merged into one that keeps the existing
storage keys and events.
2026-10-11 23:05:39 -04:00
Anso 63220a7d92 feat(notifications): mark alerts read and mute them where they appear (#2117)
* feat(notifications): mark alerts read and mute them where they appear

Add POST /api/notifications/:id/read, a node-scoped single-row read
that answers 404 for an id that is not on that node. Recent alerts and
the bell now carry a per-row Mark read, and Recent alerts gains the same
admin-only Mute menu the bell has. Both run in place: neither navigates,
and the bell stays open.

* fix(notifications): keep a confirmed read from being undone and mute on the hub

Refetch the feed after a confirmed mark-read so an older in-flight response
cannot restore the unread row. Send mute-rule creation to the hub regardless
of the active node, so Mute from Home works while a remote node is selected.
Cover the Home wiring, the shared mute menu, and both behaviors with tests.

* docs(notifications): describe mark read and mute on Recent alerts and the bell
2026-10-11 22:57:22 -04:00
Anso 87db3f121e feat(drift): say how to resolve drift and dismiss it (#2116)
* feat(drift): let a team dismiss a drift finding on the ledger

Drift findings can now be dismissed until they change, for a set number of
days, or permanently. The dismissal lives on the finding's own row in the
drift ledger on the node that owns the stack, so the ledger's detected to
resolved lifecycle retires it: once the finding clears its row is closed, and
a recurrence is a new finding with no dismissal.

Each live finding is published with a stable id, a fingerprint of its
structured facts (kind, service, compose value, running value, never the
message), a count, and a per-kind policy. The Git managed-path conflict is
not dismissable. Dismissing and restoring need exact deploy authority on the
stack, the dismiss route re-reads the stack and refuses a finding that
changed or cleared since it was seen, and the drift status and finding count
keep counting dismissed findings.

Image mismatch findings also carry whether the running image was built after
the one compose names, read from the images' build times. Anything that
cannot be proven reads as unknown.

* feat(drift): resolve and dismiss drift findings from the Drift tab

Each drift finding now names its fix and carries the verb that runs it:
redeploy to match compose (confirmed first, through the editor's own deploy
handler), start a missing service, or open the stack editor or Git source
where the change is a decision about compose rather than the runtime. When the
running image is provably newer than the one compose names, the editor leads
and the redeploy confirm says it moves the service back to an older image.
Verbs the account cannot run are dropped instead of left to answer 403.

Findings can be dismissed until they change, for 7 days, or permanently, and
restored from the dismissed list. The status keeps its verdict and counts the
dismissed findings. A report from an older remote node, which cannot record a
dismissal, offers no Dismiss.

* docs(drift): document resolving and dismissing drift findings

Describe the verb each finding kind offers, the redeploy confirm, dismissing
and restoring, and who can run what. Rework the drift tutorial around the
redeploy button on the finding, with fresh screenshots of the finding, the
overflow menu, the confirm, the resolved state, and the Activity timeline.

* fix(drift): scope dismissals and late answers to the stack they belong to

Dismissing a finding now writes only that finding's own ledger row instead of
reconciling the whole stack from a snapshot that may be behind a concurrent
check, so it can no longer resolve or insert sibling findings or stamp the
check time. The Drift tab ignores a re-check, dismissal, or restore that
finishes after the operator moved to another stack or node, and closes an open
redeploy confirm on a switch.

Open stack editor needs permission to edit the stack, so a deployer is not
shown a button that does nothing. The dismissal hook takes either a hub surface
or a node endpoint, a restore that finds nothing re-reads the list, a dismissal's
mode and expiry are one type, and the drift dismiss route bounds the count like
the other dismissal routes. Adds tests for the dismissal lifecycle, the race,
the failure paths, and the handler wiring.

* docs(drift): say which buttons need permission to edit or deploy
2026-10-11 22:50:30 -04:00
Anso 184f2ceacb feat(security): act on posture findings from the overview (#2113)
* feat(security): give posture reasons a key and fingerprint, and a dismissal surface

Each posture reason now carries a stable key, a fingerprint of its structured
targets, and a policy for how a team may set it aside. Blockers cannot be
dismissed; evidence that is missing or old can only be dismissed for a set
time. A confirmed image update names the stack and service behind it so it can
be applied from the overview.

* feat(security): act on posture reasons from the overview and dismiss review items

Each reason in the review queue carries the verb that resolves it: review and
apply a confirmed image update per stack, set exposure intent per service,
check update availability again (and refetch), rescan the node, open the scan
on its secrets or misconfigurations tab, or go to Triage or History. The
masthead action runs the same verb. Review and informational reasons can be
dismissed until they change; blockers never can.

* test(security): isolate the SecurityView refresh test from the new reason hooks

* docs(security): describe in-place verbs and dismissing review items

* fix(security): tighten posture actions from review

Scan failures are reported and only a finished scan refreshes the overview,
an overview read for another node never drives actions on this one, a service
behind several findings is listed once, and the per-key severity is one
exhaustive record.

* fix(security): match verbs to route guards and keep tab shortcuts

Rescan node follows the node scan route's global permission, secrets and
Compose risks keep their tab shortcut instead of opening an arbitrary scan,
a verb that runs in place keeps the tab shortcut in a menu, accounts that
cannot read dismissals skip the read, and failed scans are dismissed for a
set time only.

* fix(security): keep refreshing the overview after a failed node scan

* fix(security): keep the overview overflow menu after Dismiss

The review queue built its shortcut menu inside the verb slot, so Dismiss
landed after the overflow icon. Pass that shortcut through the shared
finding-row menu, which already keeps the menu at the trailing edge.
2026-10-11 22:41:47 -04:00
Anso f935fa7b1d feat(networking): resolve and dismiss networking findings in place (#2106)
* feat(networking): give networking findings a structural key and dismissals

Networking finding ids were hashes of the message, so rewording a message
moved every id. They are now `kind|stack|service|network|subject`, and each
finding carries a fingerprint of its severity and structured targets, a
target count, and the dismissal policy of its kind.

Adds the networking dismissal surface: a per-kind policy record, a key
parser that derives node, stack and kind, and routes to dismiss, list and
restore. Doctor-only findings stay with Compose Doctor: acknowledged ones are
kept and marked instead of dropped, and left out of the counts.

* feat(networking): resolve and dismiss networking findings in place

Each networking finding now carries its own verb and Dismiss, in the Findings
tab and in the Overview "Operator attention" block alike. Exposure intent is
set in a popover over the row, a Doctor-only finding is acknowledged through a
confirm that says it changes update readiness, copy actions move to an
overflow menu, and Compose Doctor's remediation line is finally shown.
Dismissed and Doctor-acknowledged findings sit under a quiet "N dismissed"
row with Restore, and the posture word still counts them.

* docs(networking): describe in-place verbs and dismissing findings

* fix(networking): tighten dismissals and Doctor acknowledgement from review

Setting an exposure intent now drops the networking memo so the finding is
re-checked at once. Acknowledge in Doctor is offered only when every open
occurrence names a service, because a service-less rule is acknowledged for
the whole stack. Service-less Doctor findings (such as port conflicts) stay
separate cards, finding keys are length-bounded, a failed dismissals read
keeps the last list and says so, and a partial acknowledge or clear reports
how far it got and re-reads the list. Adds tests for the dialog and the hook.

* fix(ui): order finding row actions as verb, Dismiss, overflow

Enforce outline primary and split Dismiss chrome, move networking overflow
into a trailing slot, and drop the mobile-only dismiss kebab.

* fix(ui): match finding row comments to the controls

The verb class does not set outline, and the overflow slot only renders what the caller passes.

* revert: restore the original finding row action layout

The outline split and trailing overflow order read worse than the previous row.

* Revert "fix(ui): order finding row actions as verb, Dismiss, overflow"

This reverts commit 6948a084960ab00cb639b3e1c6cf6d5d1e50dfb5.

* fix(ui): keep finding row actions in one order with the overflow menu last

Every finding row now lays out its controls as the resolving verb, then
Dismiss, then the overflow menu at the trailing edge, whichever of them a
row lacks. Networking used to place its overflow menu between the verb and
Dismiss, so the order changed from row to row and Dismiss shifted whenever
a row had secondary verbs.

FindingRowActions takes the secondary verbs as `more` and owns the menu. A
surface that has an overflow menu keeps its slot reserved on rows with
nothing to put in it, so Dismiss lines up across rows. Fleet Readiness has
no overflow menu and is unchanged.

Below the md breakpoint the secondary verbs and the Dismiss options fold
into one trailing menu instead of two adjacent ones. A dismiss in flight
locks only the Dismiss items there, as it does on desktop.
2026-10-11 22:33:34 -04:00
Anso 2fedeafbf7 feat(readiness): resolve readiness findings in place (#2101)
* feat(readiness): resolve readiness findings in place

Each Readiness finding now carries the verb that fixes it where it is
listed, instead of a link to another page:

- Test connection, Start stack or stopped services, Check again,
  Capture recovery point, Take fleet snapshot and Scan node run on one
  click against the finding's own node.
- Review update, Install scanner and Re-anchor go through an overlay
  first. The update itself runs through the same request the editor
  uses, so the policy gate and the progress panel still apply.
- A verb is offered only when the account passes the route's own check;
  otherwise the row keeps its page link. Findings from a peer that
  predates the new fields fall back to the page link too.

Update and recovery findings now carry the signal behind their reason
and whether an update is known, and both feed the finding fingerprint
so a new image or a changed blocker brings a dismissed finding back.
Capturing a recovery point drops the cached rollup so the finding it
clears leaves the list at once.

The stack update request and its error parsing move out of the editor
hook into one shared module used by the editor, Auto-updates Apply and
the new verb.

* fix(readiness): report update failures and verb errors accurately

- A stack update that never got an answer (dropped connection) now
  toasts the error instead of leaving only the deploy panel.
- A failed update says when the stack was rolled back and shows the
  cause and next step; a recheck warning replaces the success line
  rather than stacking under it.
- A failed connection test reports its own reason instead of "could not
  reach"; a node scan with failures warns instead of reading as clean.
- The re-anchor confirm picks the paused sync row, ignores a late
  lookup for another finding, and says when the anchor could not be
  read.
- A refused or failed update still re-reads the check.
- Reason facts of the wrong type from a peer are ignored.
- Add tests for the shared update request, its hook, the lifecycle
  helpers and the verb failure paths; correct stale comments.

* refactor(readiness): flatten the verb confirm description

* fix(readiness): label the partial-stack verb for what it does

The start route starts every container of the stack, so both the stopped
and the partly-down finding now offer "Start stack".
2026-10-11 22:25:10 -04:00
Anso 11312d84d3 feat(readiness): dismiss readiness findings until they change (#2100)
Findings on Fleet > Readiness can now be set aside for the whole team. One
click on Dismiss moves a finding out of the list with an Undo, and it comes
back by itself if the finding gets worse or changes. A chevron offers a
7-day or permanent hold. Each row also says in one line what to do about it.

Dismissed findings are never hidden: the status line counts them and a quiet
"N dismissed" row lists who dismissed each one, when, and for how long, with
Restore. Dismissing never changes the matrix cells or the fleet state.

Dismissals are held on the hub. The finding id names the node and stack, and
the server derives permissions from it (deploy on the stack, manage on the
node, admin for Control). The server reads the finding itself and refuses a
dismissal if it changed after the operator saw it. A dismissal is retired
only when a complete live read shows the finding gone, so an outage never
drops one. Findings that restate the Security posture cannot be dismissed
here, and findings about evidence that could not be read can only be
dismissed for a set time.
2026-10-11 22:14:36 -04:00
Anso ca90588e4e fix(gitops): bound GitOps history with a retention window (#2105)
* fix(gitops): bound GitOps history with a retention window

`gitops_history` is insert-only and the reconcile tick appends to it
continuously, so it grew for the life of an installation. A new
`gitops_history_retention_days` setting (default 30, range 1 to 365)
seeds with the other GitOps settings and prunes history from the
monitor's cleanup pass. Drained outbox rows are pruned with the history
they announced; an undrained row gets a grace period past the window
because the boot repair can still drain it from its payload, and a row
still undrained after the grace is counted, logged, and dropped so it
cannot accumulate for ever. The window is instance-local, like the
artifact retry interval beside it: the history being pruned is the one
this instance holds, and the monitor that prunes it runs here.

* fix(gitops): keep each live application's newest history row through the prune

`cleanupOldGitOpsHistory` deleted `gitops_history` purely by age, but
`latestTransitionByApplication` reads `MAX(created_at)` per application and
feeds the "last activity" fact the portfolio and application sheet render. An
application with no history inside the window, because polling is off, a source
is suspended, a node is detached, or a Blueprint is idle, lost every row and
its activity column silently went empty. The prune now exempts each live
application's newest row, so the claim the JSDoc, the PR body and the settings
helper text already make is true.

Scoped to live applications rather than to every application_id: a tombstone in
`gitops_applications` is permanent, so an unscoped exemption would retain one
history row per stack ever retired and leave exactly the growth this setting
exists to stop. A retired application's rows are now pruned in full.

Also folds in three follow-ups from the audit:

- The prune is chunked to GITOPS_PRUNE_CHUNK_ROWS rows per table per pass, so
  a first prune on a long-lived install clears its backlog over several janitor
  ticks instead of holding SQLite's single writer for the whole delete. The
  method reports historyRemaining and outboxRemaining so the debug log says
  whether a pass is still catching up.
- The outbox OR-delete is two statements, so the undrained branch uses
  idx_gitops_settled_outbox_undrained instead of scanning for the drained one.
- The retention tests assert exact counts and exact surviving row ids rather
  than toBeGreaterThanOrEqual over a shared database.

New tests pin each guard: a quiet application's newest row survives every
window, its older rows are pruned, a tombstoned application clears entirely,
an unsettled attempt is still recoverable after a prune both inside the window
and outside it, and a backlog clears in bounded passes with the remainder
reported.

Removing the newest-row exemption turns 10 of the 11 tests red; removing only
the liveness clause turns the tombstone test red.

Docs: the GitOps portfolio page describes the window and its setting, and now
states that the newest entry per application is kept, which is what the code
does.

* test(gitops): drop the unused db handles from the recovery assertions

* fix(gitops): keep a settled history row ahead of its started row

A reconcile attempt stamps both rows with the same time, and the prune broke that tie on a random id, so it could delete the settlement and leave startup recovery to reopen the attempt. Recency now follows insertion order, in the exemption and in each chunk.

Remaining counts run only while developer mode is on, drained outbox deletes use an age index, and the settings copy says the kept row is the newest one for each live application.

* fix(gitops): log the undrained rows a history prune actually deletes

A pass is capped, so the warning was reporting the whole backlog. The
history-window tests now fail if the permission, the page save, the monitor
window, or the getter fallback stops matching the code.

* fix(gitops): prune a superseded unsettled attempt past the window

An unsettled reservation survives past the retention window only while it is that live application's newest row. Creating applications keep that row; detached applications do not.

* fix(gitops): skip the history prune when the retention setting cannot be read

A failed settings read used to fall back to 30 days and delete rows the operator had chosen to keep. The getter now throws, and that pass deletes nothing. A missing or unusable stored value still uses 30.

* fix(gitops): restore the history window when the retention spec fails

The retention spec restored the seeded 30 days as its last statement, not
in a finally, so a failed assertion left the shared instance on 14. The
CI retry then read 14 and failed the fresh-install assertion, turning one
failure into a persistent red.

Everything after the first save now runs inside a try whose finally puts
the window back, so the spec leaves the instance as it found it whether it
passes or fails.

* docs(settings): list the GitOps history window in the retention reference

The Data Retention table listed every other retention window and not the
GitOps one, so an operator checking retention windows did not find it. The
portfolio page described the setting in a paragraph, which is not where
retention windows are looked up.

Adds the row with its default and range, and a line under the table saying
the window is set per Sencho instance and where the control lives.

* fix(gitops): report an unmeasured prune remainder as unknown

The prune reported a remainder of 0 whenever developer mode was off, so
the diagnostic log read "none left" for a table it had never counted. It
now reports null, and the log line says the remainder was not measured.

Three smaller corrections from the same review:

- The diagnostics allowlist omitted the history window, so a support
  bundle could not explain a timeline that had already been pruned.
- The GitOps registry entry described every control as belonging to this
  node, while the retry interval and the history window are instance
  local. The sentence now names no scope it does not have.

* fix(gitops): correct what the history window is documented to govern

The copy said the window governs notifications. It does not: the prune
deletes history rows and their outbox rows, and the bell entries those
outbox rows announce age out under the Notification log window instead. An
operator setting 7 days would have expected their bell to follow.

It also promised to keep the database small, which nothing delivers. The
prune stops old rows from being written; it never reclaims the file.

- The helper now names the record behind the GitOps timeline, points bell
  entries at the Notification log, and says the window stops history from
  growing.
- The settings reference gains the GitOps section it never had, with the
  window under it rather than under a per-node table that ends in a Save
  button the control does not use.
- The portfolio page stops claiming a shorter window never changes what an
  application is doing: a webhook redelivery reads this same record, which
  the same paragraph already said.
- A fraction below one day now clamps to a day instead of flooring to zero,
  which as a cutoff is now and would delete everything not exempt. The only
  caller passes an integer, so this is a guard, not a reachable fix.

* fix(settings): snap a typed number chip to its own step on commit

The chip clamped to min and max but never rounded, so typing 1.5 wrote
"1.5" and every integer setting rejected it at the route with a bare
"Validation failed" toast. Nothing was saved, so no data was at risk, but
the control accepted input it could not store.

Rounding to the control's step fixes that and leaves a genuinely
fractional chip alone: the Docker janitor asks for 0.5 GiB and keeps it.
Clamping still runs after rounding, so a fraction below the floor cannot
round down under it.

The settings reference also listed GitOps last under Infrastructure
while the sidebar renders it third.

* docs(gitops): replace the unsourced window figure with what the code does

The helper and both docs said a window under about 3 days can let a Git
host retry a webhook. Nothing in the code picks three days, and the
dedupe the sentence describes only applies to the direct webhook path.

The text now states the condition instead of a number: a redelivery can
run the work again once the record of its first delivery is pruned. A
provider endpoint keeps its own delivery record, pruned to a fixed depth,
so the window does not affect it, and the sentence says so.

* docs(settings): say where a remote instance's window is set

The reference explained that the window belongs to an instance rather
than a node, but not how to change the window of a remote one, which is
on that instance.
2026-10-11 14:02:03 -04:00
Anso 0ce9754766 fix(gitops): make automatic placement approvals reach execution (#2103)
* fix(gitops): make automatic placement approvals reach execution

Two defects on the bounded automatic placement path.

A removal was never auto-approved: the decision read
`gitops_targets.connectivity`, a column seeded to null that no producer ever
wrote, so a withdrawal resolved to `unknown_connectivity` even though the
target projection already derived reachability from the recorded observation.
The decision now reads the projection's own `targetConnectivity`, requires the
node registry to have the node online (the same gate an addition passes), and
carries an observation that cannot be decoded as a limitation, so the refusal
names malformed evidence instead of sending the operator to check a node that
answered.

An Inline Blueprint's policy placement approval was recorded and then never
read: an operational edit clears the combined approval, and the reconciler
returned at the legacy gate before consulting the policy's approval. The
reconciler now executes a plan a current policy placement approval covers,
under seven conditions: the application is inline, the placement policy is
still the one that could have written the approval, the approval is a policy's,
the rollout authorization policy is automatic, at least one node is already
placed at the current revision, every retained node runs the current revision,
and the approval covers every action in the plan. An uncovered plan waits for
the operator rather than partly executing, a first placement always waits for
Apply, and a retained node is observed but never repaired under this approval,
because the placement policy decided the node set and not the content.

A node-driven roster change no longer hides a policy approval behind a current
combined one: a label or cordon change moves the node set without moving the
combined approval's fingerprint, so the reconciler tries the policy approval
first and falls back to the combined approval when the policy cannot run the
whole plan. That fallback narrows the operator's blast to the placement's
frozen target set instead of invalidating it, so a removal a policy approved no
longer stops drift observation on the nodes that stay; the preview and the
lightweight list and detail evaluators read the same narrowing, so no surface
reports an approval the executor withholds or withholds one it will run. An
operator's approval is never discarded by a policy decision. Demoting an
application back to Inline takes the Git-era authority with it: the placement
pointer is cleared and the rollout authorization policy returns to operator
authorization, because neither was an operator's choice about Inline
placement. An Inline Blueprint can reach the automatic rollout authorization the
policy path requires from the control beside the placement policy.

* fix(gitops): say when automatic placement waits for Apply

A selector edit that moves one stateless node runs on the next tick, and Enforce holds a policy-approved plan until Apply. The docs described both as waiting, and the preview showed a pending plan with no reason.

* fix(gitops): keep a cleared pin from automatic placement

A pin clear left the new intent unpinned, so bounded auto could place the freed node without Confirm Apply. The decision now treats a clear as pin-driven when the standing approval was recorded on a pinned intent, or when a pin was written after an older approval and never confirmed.

* fix(gitops): keep a detached snapshot off automatic placement

Git-managed deploys stamp the Blueprint revision, and Git content never bumps it, so after Detach a retained node still looked current. Recording a new revision on Detach makes that stamp stale until Apply.

* fix(gitops): hold Enforce plans that drop a mid-deploy repair

A retained node that is correcting or deploying leaves the plan with no repair, so a policy approval could place an added node in Enforce. The mode itself now holds that plan until Apply, and the preview says so only when Enforce is the reason it is waiting.

* fix(gitops): keep a pin chain and a re-armed policy from placing on their own

Three authority gaps, all on the automatic placement path, found by the
pre-merge audit.

A pin that was set and cleared with no Apply between them read as no pin at
all once a third intent followed: the intent before the current one was the
clear, so the pin that moved the work was out of view and the freed node was
placed without Apply. With no decomposed placement approval to anchor on there
is no record of any confirmation, so the whole intent history now counts. A
Confirm Apply mints the approval the decision anchors on from, and the next
change is judged against that, so the wait ends at the next operator decision
rather than never.

Re-enabling a policy revived a decision the operator had taken back: execution
stopped the moment the policy went, but the approval the policy wrote stayed on
the row, so setting it back handed the next tick a decision made days earlier on
evidence nobody had read since. Arming either policy again now withdraws what
that policy decided, with the rollout authorization that only resolves through
it. An operator's approval is untouched, the approval rows stay as the record of
what was decided, the rollout generation stays so a fresh approval re-drives it,
and the history row records the pointers that went with the policy.

The refusals a withdrawal produces now read as what the backend checks: a node
that has not reported, or that is not a node this Sencho knows about, is a
different answer from a node that is not answering, and a withdrawal needs the
node to have reported the workload it was running.

* fix(gitops): clear the attention a policy placement left behind

A Blueprint the placement policy placed a node for sat at Needs attention
with a stale acknowledgement and a runtime drift, and neither Confirm Apply
nor an API apply cleared it: the retained nodes' plan is observation-only, so
nothing ever re-acknowledged them.

Two causes, both fixed. The Inline freeze is now keyed on the compose digest
rather than the intent revision, so a roster change no longer drops the
accepted generation and re-points every placed target at a new generation id
for the content it already runs. And the projection's identity test yields when
both intent revisions resolve with the same compose digest and the current
candidate still requires that node: a node the candidate dropped keeps its
signal, because a withdrawal the plan is asking for is not a superseded
acknowledgement.

Two roster changes inside one tick also left both additions unplaced with no
refusal recorded. The reconciler runs one approval and refuses a plan it does
not cover whole, so the second approval named only its own addition while the
plan still wanted the first. The blast now names every node the fleet does not
hold, carrying the earlier decision's unlanded placement forward, while the
decision still reads the approved set for its size and safety reasons and the
evidence is read for every node the blast names. Removals stay on the approved
set, because a withdrawal is judged from the observation of the workload that
ran there.

The Confirm rollout dialog no longer claims the fleet cannot change until it is
confirmed when a configured policy has already approved the plan.

* fix(gitops): write the placement blast in canonical order and name a withdrawn node again

Two findings from the QA run of this PR, both now pinned by failing-first tests.

The first: a roster move that adds a higher node id while withdrawing a lower one left the approval unwritable and the operator looking in the wrong place. The blast effect was assembled places-then-removals, while the decoder requires the node ids strictly increasing, so the write was refused as out of canonical order. The refusal reason then reported that refusal as `conflicting_operation`, so the operator was told an operation was in flight when none was, the plan read `reapproval_required`, and nothing executed. The effect is now sorted by node id, which is the order the decoder wants and carries no meaning of its own: the reconciler reads the effect as a map keyed by node id and the transition re-encodes it to canonical form anyway. Blast decode failures are also recorded as `malformed_evidence` rather than falling through to the conflict default, because a blast that did not decode is a data fault.

The roster window that reaches it: the reconciler executes one approval per tick, so an addition approved but not yet landed is carried into the next approval's blast, which then names a placed node alongside the withdrawal.

The second: a withdrawn node that the selector wants again produced no row in the plan at all. The tombstone holds deliberately, since only an explicit deploy re-opens a severed placement, but the node stayed online and labelled with nothing to say about why it was skipped, so the plan read as nothing to do. A new informational `skip_withdrawn` row now names what the operator has to do. It is excluded from the confirmable set and from both executor groups, so it authorizes nothing and cannot be confirmed into a mutate, and it yields to the status-precedence pass so an in-flight or guard row for the same node wins rather than leaving two explanations stacked on one node.

Follow-up, not addressed here: the replay-guard refusal (`placement approval already recorded for this intent and candidate`) still maps to `conflicting_operation`, which is the same class of untruthful reason on the crash-retry path. It predates this change and is left for its own pass.

Tests: a new case drives the add-then-withdraw window end to end and asserts the approval lands with `stateless_removal` and no refusal recorded; a second drives a severed target the selector wants again and asserts the informational row; a third pins that a severed node carrying a `withdrawing` row reports once, as the in-flight withdrawal.

Backend: `npx tsc --noEmit`, `npm run lint` (0 errors), `npm test` (11013 passed; the single file-level failure is `git-transport-ssh.integration.test.ts`, which requires loopback port 22 free and fails identically on an unmodified tree because this host runs sshd on 22, freed in CI by `scripts/ci-free-loopback-ssh-port.sh`).
Frontend: `npx tsc -b --noEmit`, `npm run lint` (0 errors).
2026-10-09 22:28:41 -04:00
Hambrook a6eaea0f54 feat(editor): create the stack .env from the editor on first save (#2097)
* feat(composeEditor): create .env file on demand when saved instead of relying on doing so via Files

* fix(editor): scope .env creation to the editor and adopt the resolved path

- PUT /:stackName/env creates the default .env only when the request carries
  create=1 and the stack exists; every other caller keeps the handled 404, so a
  Fleet Secrets push to a stack with no env file never creates one.
- The create response returns the resolved path; the editor adopts it, so the
  next save targets the file directly instead of failing the allowed-file check.
- Deep links to /env open the env tab on a stack with no env file.
- Mobile always shows the compose/.env toggle and the creation notice.
- Backend and frontend tests pin the create path, the canonical second save,
  the no-flag 404, the deep-link behavior, and the notice; docs, the guardrails
  tutorial, and the mobile screenshot are updated.

* fix(editor): make the first env save exclusive and pin the confirmed overwrite

Audit follow-up on the create-on-first-save path:

- PUT /:stackName/env?create=1 now creates exclusively with open(..., 'wx').
  An existing target answers the same 412 conflict the optimistic-concurrency
  path uses, with its current content and path, so a stale editor that still
  believes no env file exists can never overwrite one it has not read.
- The editor adopts the conflict path, and its confirmed overwrite retries with
  create=1&force=1&file=<path>, pinned to the file the dialog named and
  re-validated against the allowlist.
- With project env files configured, the create targets the first configured
  file (the one Compose reads), derived from the shared env resolver instead of
  a second copy of the fallback; a configured path that resolves outside the
  stack directory is refused with 409 rather than written.
- The conflict read repeats the containment check and refuses to follow a link,
  so the conflict payload cannot be used to read outside the compose root.
- A directory target maps to 409 instead of an opaque 500.
- Tests cover the conflict instead of an overwrite, the forced retry, the
  pinned retry, the configured target with a stray .env untouched, the escaping
  config refused, force ignored on the non-create path, and the frontend
  conflict, decline, and fallback flows.
- Docs restore the compose row's "Always available." note, describe the
  creation target and the no-overwrite guarantee, and make the notices generic.

* fix(editor): guard the env create against older nodes and unknown state

Audit follow-up on the create-on-first-save path:

- The non-forced create request now carries If-Match: W/"0". This build's
  exclusive create ignores it; a node that predates create-on-save treats it as
  a normal conditional write, so an existing env file answers 412 with its
  content instead of being overwritten, and a missing file keeps the handled
  404.
- A failed stack load clears the previous stack's env state instead of leaving
  a stale "no env file" assertion that the next save could act on.
- A failed env inventory or content read is tracked as unknown, shows a
  failure notice, locks the buffer, and refuses the save; it is no longer
  presented as an empty file.
- Declining the create conflict on a node whose 412 names no path keeps the
  conflicted content and refreshes the inventory under the current load's
  signal; a failed refresh reports the failure instead of a false reload.
- Save & Deploy and Save & Pull on an untouched empty env tab no longer create
  an empty file; the deploy proceeds.
- The exclusive create opens with O_NOFOLLOW so a planted symlink cannot be
  followed, and a failed write's leftover is removed only when the path still
  names that same file (inode and size match).
- Tests cover the older-node precondition, concurrent creates, a directory
  target, escaping and dangling symlinks, the write-failure cleanups, the
  unknown-state refusal, the conflict-retry copy, and the deploy skip.

* fix(editor): leave create mode on older nodes and return 409 for path conflicts

Audit follow-up:

- A create-mode 200 without envPath is the normal response of a node that
  predates the envPath field. The editor now refreshes the env inventory in
  that case, so it leaves create mode and later saves are normal conditional
  writes with a fresh ETag instead of prompting for an overwrite every time.
- The recovery refresh uses the load signal captured before the PUT, so a
  stack switch during the request aborts it and the new load's state is not
  repainted.
- PUT /:stackName/env maps INVALID_PATH and SYMLINK_ESCAPE from the create
  path to 409, so a symlink at the target is a recoverable conflict instead of
  an opaque 500.
- Docs: deep-links no longer says the created file is always the default .env.
- Tests: the older-node create, confirm and overwrite sequence asserts the
  editor leaves create mode and the next save is a conditional write; a route
  test covers an in-root symlinked .env returning 409 without leaking content.
- Comment cleanups from the audit.

* fix(editor): do not paint a create response that lands after a stack switch

Audit follow-up:

- saveFile checks the load signal captured before the PUT (opLoadSignal) in
  the 412 and success paths: a response landing after the editor switched
  stacks now returns without adopting the old stack's envPath, ETag, or
  buffer state and without prompting, so the new stack's editor state stays
  intact.
- Tests: two hook tests switch stacks mid-PUT, for both the success and the
  conflict response, and assert no adoption and no prompt.
- Docs: node-compatibility no longer claims every older node detects the
  conflict (nodes before 0.87 cannot) and notes that the confirmed-overwrite
  prompt does not repeat.
- Comment: createTarget wording matches the resolver's containment filter.

* fix(editor): create missing parent directories for a configured env file

Audit follow-up:

- A configured project env file can point into a subdirectory that does not
  exist yet; the exclusive open failed with ENOENT and the route answered an
  opaque 500. The create now makes the missing parents inside the checked,
  stack-bounded path and retries the exclusive open once, so the first save
  creates the file Compose reads at its configured path.
- The retry re-runs the containment check and still loses to EEXIST if another
  actor created the file meanwhile.
- Tests: a service test for a nested missing-parent target and a route test
  that configures config/prod.env with no config directory, creates it, and
  saves again through the returned path.

* fix(editor): report a vanished stack on env create instead of resurrecting it

Audit follow-up:

- Drop openExclusiveCreatingParents and its recursive mkdir. The state it
  served (a configured project env file in a missing subdirectory) cannot be
  produced by the API: PUT /project-env-files rejects names with "/" or "\"
  and requires the file to exist, so no product path reaches that branch.
  The only reachable ENOENT is a stack directory deleted between
  requireStackExists and the exclusive create; recreating it left the deleted
  stack visible only as a hidden folder holding the env content and blocked
  the stack name.
- Restore the direct exclusive open and map ENOENT in the route catch to the
  handled 404 Stack not found, so the race is reported and nothing is written
  or recreated.
- Replace the two tests for the unreachable nested target with regression
  tests: the service refuses a target under a missing stack directory and
  creates nothing; the route answers 404 Stack not found when the directory
  vanishes before the create (real service ENOENT path) and does not recreate
  it.

* fix(editor): recover when the create conflict target is deleted mid-dialog

QA follow-up:

- The confirmed create retry pins the file the conflict named. When that file
  is deleted before the operator confirms, the route rejected the pinned path
  with a 400 because it was no longer a present env source, and the editor
  (which had already adopted the path) failed every later save the same way.
  The create branch now accepts the pinned path when it is the resolved create
  target, so the retry recreates the file; any other pinned path keeps the
  scoped rejection.
- Declining a create conflict now reloads the env state instead of trusting
  the 412 payload: if the file was deleted meanwhile, the editor returns to
  create mode instead of adopting a path that no longer exists.
- Save failures toast the server's error text (404 vanished stack, 409
  unusable path, 413 too large) instead of the raw JSON envelope.
- The save-refusal toast for an unreadable env state uses the same wording as
  the editor notice; the file list may have loaded while the content did not.
- A viewer with no env file sees "This stack has no environment file yet."
  instead of an unexplained empty read-only tab; editors keep the create
  promise.
- The route test for a vanished stack restores its spy in finally.

---------

Co-authored-by: Anso <dev@anso.codes>
2026-10-08 12:12:26 -04:00
dependabot[bot] 02f6fc4946 chore(deps): bump the all-npm-frontend group (#2094)
Bumps the all-npm-frontend group in /frontend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.48.0` | `1.52.0` |
| [motion](https://github.com/motiondivision/motion) | `13.4.4` | `14.0.0` |
| [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `10.0.1` | `10.0.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.3` | `26.6.4` |
| [eslint](https://github.com/eslint/eslint) | `10.11.0` | `10.12.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.12.0` | `17.13.0` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.1.1` | `30.1.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.70.1` | `8.71.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.1` | `8.3.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.2` | `5.0.3` |


Updates `lucide-react` from 1.48.0 to 1.52.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.52.0/packages/lucide-react)

Updates `motion` from 13.4.4 to 14.0.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v13.4.4...v14.0.0)

Updates `react-day-picker` from 10.0.1 to 10.0.2
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.2/packages/react-day-picker)

Updates `@types/node` from 26.6.3 to 26.6.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.11.0 to 10.12.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.11.0...v10.12.0)

Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

Updates `jsdom` from 30.1.1 to 30.1.2
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v30.1.1...v30.1.2)

Updates `typescript-eslint` from 8.70.1 to 8.71.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/typescript-eslint)

Updates `vite` from 8.3.1 to 8.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.2/packages/vite)

Updates `vitest` from 5.0.2 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: motion
  dependency-version: 14.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-npm-frontend
- dependency-name: react-day-picker
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: "@types/node"
  dependency-version: 26.6.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: eslint
  dependency-version: 10.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: jsdom
  dependency-version: 30.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: typescript-eslint
  dependency-version: 8.71.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: vite
  dependency-version: 8.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-07 21:16:40 -04:00
Anso a54ebcc7e2 feat(fleet): separate the Fleet tabs into two sections, with a layout setting (#2092)
* feat(fleet): group the Fleet tabs into Observe and Operate, with a layout setting

The Fleet tab strip was ten tabs in one row, wider than a 1280px window.

The strip now sets the tabs under two labelled groups on one strip: Observe
(Overview, Readiness, Map, Docker Labels) and Operate (Snapshots, Blueprints,
Routing, Federation, Actions, Secrets). It scrolls sideways when the window is
too narrow and keeps the tab you are on in view. Every tab still opens with one
click and every tab keeps its value, so deep links and URL sync are unchanged.

A new account preference, Settings, Appearance, Fleet, Fleet tab layout, picks
how the strip is laid out: Grouped (default), Flat (the previous single row), or
Compact (Overview, Readiness, Map, Blueprints and Actions, with the rest under a
More menu that names the active hidden tab). It is saved to the account and
synced like the other appearance preferences, defaults to Grouped for a document
that lacks it, and is cleared with the rest of the preference cache when another
account signs in.

* refactor(fleet): drop the Observe and Operate labels and the old flat strip

The group labels only lengthened the tab row, and the previous single-row
layout is no longer needed. The Fleet tab strip is now one row: Overview,
Readiness, Map and Docker Labels, a vertical separator, then Snapshots,
Blueprints, Routing, Federation, Actions and Secrets. It still scrolls sideways
when the window is too narrow and keeps the active tab in view.

The Fleet tab layout setting now offers Flat (the default, this row) and
Compact (five tabs with the rest under More). The retired grouped value reads
as Flat.

* test(fleet): raise the cross-tab storage event without the StorageEvent constructor

The code quality scan flagged the two-argument StorageEvent calls as
superfluous trailing arguments. The tests now build the event from a
plain Event carrying the key and the new value, which is all the hook
reads.
2026-10-07 09:14:25 -04:00
Anso d7e0ebc4e5 feat(fleet): draw the Map one node at a time as each answers (#2091)
* feat(fleet): draw the Map one node at a time as each answers

The Map tab asked for the whole fleet in one request, so every node waited on
the slowest one and a single unreachable node held the tab until its timeout.

GET /api/fleet/dependency-map now takes an optional nodeId (positive integer;
400 if malformed, 404 if unknown) and answers for that one node in the same
shape. The Map tab reads each node on its own, four at a time, and draws each
slice as soon as it arrives, with "n of N nodes" beside Refresh while the rest
are pending. A node that fails keeps its last slice and is named in the banner
with its reason, and Retry in the banner reloads only the failed nodes. A node
that never answers is given up on after 20 seconds so Refresh and Retry never
stay locked behind it, and an answer that is not a map is reported as a failure
instead of drawing nothing. When no node could be read the tab says so instead
of calling the fleet empty.

* test(fleet): compare the request origin, not a URL prefix, in the map filter tests

CodeQL flagged two startsWith checks on a base URL as incomplete URL
substring sanitization. The assertions now parse the request URL and
compare its whole origin.
2026-10-07 08:56:14 -04:00
Anso fd5824c142 feat(fleet): add Cordon and Delete to the node details sheet (#2090)
* refactor(fleet): give each node one state and one action, and redesign the node details sheet

The Fleet node card carried up to six chips (version, update, critical,
cordoned, GitOps, integration image) and said "Node unreachable" three ways with
no reason and no action. One model now reads a node's state for both surfaces:
the loudest condition is the Answer, the rest are counted.

The card shows the version as quiet text, one state chip with a +N for the
others, and at most one action button, shown only when the session may run it.
An offline node says why in one line (Pilot disconnected, last seen 12m ago, or
Proxy unreachable, last contact 1h ago) with a Test connection button that runs
in place, instead of a dimmed card. Stale update, GitOps and networking
readings are no longer shown for an offline node, but a failed or running
update stays visible with its Retry and Dismiss.

The details sheet opens on an Overview that answers first, with a quiet row of
the stages that apply and a collapsed Details section, plus Resources and
Settings tabs. Its toolbar holds the object-level actions (Edit, Cordon or
Uncordon, Delete) and the action for every other state the node is in, so no
action is lost when a louder state is the Answer. Cordon confirmation and the
connection test are shared by the card and the sheet.

* refactor(fleet): keep the original node card and one-page sheet, add Cordon and Delete to the sheet

The one-state-chip card and the tabbed details sheet are withdrawn. The node
card is back to its original layout, where the only action button is the node
update, and the details sheet is back to its single page.

What stays: the details sheet toolbar now carries Edit node, Cordon node or
Uncordon node, and Delete node (with View networking when the node has a
networking signal), under the same permission rules as the card menu. The
cordon confirmation is one shared component used by the card and the sheet, so
the reason field, busy lock and failure handling are the same in both. The
phone screen is unchanged.
2026-10-07 08:33:33 -04:00
Anso c925c7b887 refactor(fleet): put the Fleet masthead on the shared pattern and list nodes attention first (#2089)
The Fleet masthead was hand-built: its state word always read "The fleet", it
showed a healthy tone with zero nodes while loading, and its "alerts" tile
restated the critical-node count. It is now a PageMasthead hero with a FLEET
kicker and a real verdict (Healthy, Degraded, Critical, plus Checking before
the first overview, Unavailable if that first overview failed, and No nodes
for an empty fleet). One detail line names the node count, offline and
critical counts and sync age, and CPU, memory and containers sit in the
metadata. The peak-CPU sub-line and the alerts tile are gone.

Fleet nodes can now be sorted by Attention (offline, then critical, then
name), which is the default for new browsers. The Local node stays pinned
first in the grid.
2026-10-07 08:33:12 -04:00
Anso f38d4886d5 fix(fleet): report a gone Pilot as offline and bound the Fleet map reads (#2088)
A Pilot whose agent stopped kept reading "online" on Fleet forever with no
stats, because pilot_last_seen was written only when a tunnel connected. The
tunnel close now stamps it, and the overview treats a Pilot with no tunnel as
online only for a 30s reconnect grace window.

The dependency map and networking summary waited up to 15s per remote, so a
half-open Pilot tunnel (agent frozen, not yet reaped, up to about 90s) held the
Map tab for that long. Both now share an 8s per-remote budget, say "Timed out
after 8s" instead of the runtime's abort text, and tell a body-read timeout
apart from a malformed payload. A failed tunnel-down write no longer blocks the
tunnel-down event.

On the Fleet screen, an aborted overview request no longer clears the loading
state of the request that replaced it (which flashed "No nodes configured"),
the networking and GitOps requests have their own controllers so a poll does
not cancel them, and polling pauses while the tab is hidden. The Overview
skeleton waits for the busy delay, and the Map tab keeps its last result,
revalidates when reopened, shows a layout-matched skeleton, names each
unreachable node with its reason, and offers Retry.
2026-10-07 08:32:50 -04:00
Anso 612c25a670 feat(gitops): complete the Git-managed Blueprint rollout path (#2084)
* fix(gitops): materialize frozen rollout targets on dispatch

A Git-managed Blueprint rollout dispatched against its authorized frozen
target set failed on every node except the source stack's own node with
"Could not open deploy for node N: target not found". Only the Inline
producer created a target row on first contact, and the Git adapter sent
to nodes that had never received a Direct deploy.

Create an empty target row for each frozen node that has none before the
deploy queue runs, mirroring the Inline producer's deploy_start write. A
node that no longer exists is skipped and still refused by name. An
existing row is left untouched, so a tombstone, fence, or spent retry
budget survives row creation.

* feat(gitops): materialize and freeze the accepted generation for Git-managed rollouts

A converted source keeps polling, but the Direct pipeline is refused for a
claimed source, so nothing promoted a new generation into its applied
directory and nothing resolved its artifact set. The rollout dispatch reads
the applied materialization and authorization refuses an unresolved set,
so a second commit could never be authorized.

Promote the accepted generation's candidate into its applied directory on
the hub (no writes to the live source stack) and resolve the artifact set
from that materialization through the shared approved-content parser. The
reconciler's artifact retry now re-runs both halves for a Git-managed
Blueprint, so a transient failure heals. A generation that materializes
more than one compose file is refused rather than resolved from a subset,
and candidate and applied paths get the same shape validation the Direct
promotion uses.

* fix(gitops): rebind the rollout candidate to a newly accepted generation

After a source-only change, sourceAccepted cleared the rollout
authorization but kept the placement approval, while the candidate still
named the previous generation and artifact. authorizationIngredients
refuses a candidate whose binding disagrees with the application, so a
second commit could never be authorized.

Move the candidate's generation binding to the newly accepted generation
and clear its artifact binding in the same transaction. The artifact
binding is cleared rather than pointed at the acceptance's seed set
because the freeze advances the application's set to the resolved one;
authorization re-stamps both from that resolved set. Both acceptance
entry points share the rebind, and the store method checks ownership and
is idempotent.

* fix(gitops): hold an authorized rollout whose dispatch is refused

A refused dispatch after a successful authorization returned only a note
on a 200 and left the projection reading rollout_queued with nothing an
operator could act on. Hold the rollout through the same application pause
the health executor uses, so the Answer carries the reason and Resume is
the resolving verb.

The hold is a no-op when no authorization is live and never overwrites an
existing hold. It is placed by the authorize route, the automatic
acceptance path, the resume path when the retry is refused again, and the
startup reconstruction, with a fresh operation id each time so a repeated
hold still writes history and announces itself. A hold that cannot be
recorded is logged, never thrown into a dispatch that already answered.

* test(gitops): cover a second source commit end to end

Drive the audit's repro at the service level: a Git-managed Blueprint
rollout to two frozen nodes, a second source commit accepted and prepared
through the real materialize and freeze producers, re-authorization
through the configured policy, and a second dispatch that deploys the new
compose to both nodes. The test asserts the promoted materialization, the
resolved artifact set, the minted authorization, the rebound candidate,
the deployed compose bytes, and the per-node applied generation.

* docs: document the Git-managed source update path

Describe what a new commit does on a Git-managed Blueprint: acceptance
materializes the generation and attempts to resolve its artifact identity
(the reconciler retries an incomplete resolve), placement stays approved
across a content-only change, and authorization starts the sequential
rollout again, on its own under the automatic policy or held with the
reason when preflight or artifact evidence is not ready. The Resume
bullet records that a refused retry re-holds. The What's New blurb now
describes the working chain instead of a blocked state.

* fix(gitops): retry Git-managed preparation and classify dispatch holds

A newly accepted generation whose preparation failed (a registry outage,
or a crash between the acceptance commit and the materialize call) was
stranded: re-accepting is refused, and the per-target artifact retry only
looks at the generation a target already holds. The reconciler now runs
an application-driven retry for every live Git-managed application,
re-materializing and re-resolving the accepted generation and completing
the automatic handoff once it resolves. A compose shape the authored-text
parser cannot model is refused and remembered, and its reason is
persisted as an evidence limitation the projection can show.

Blocked dispatches now carry whether they are holdable. Lock contention,
an already-recorded pause, a per-target pause, a superseded generation,
and a suspended source are not: pausing on them stopped a healthy rollout
another dispatch was advancing, and the per-target case needed two
Resumes to undo one decision. The health rollout executor shares the
classification. A converged Git-managed Blueprint no longer projects the
Direct manifest cache as managed-project drift.

* fix(gitops): clear system holds on acceptance and record their origin

A system hold (a refused dispatch, a health fence) belonged to the rollout
it was holding, but acceptance never cleared it: the next generation
dispatched into "The rollout is paused." with a reason describing the
superseded one, and before this change the next commit dispatched on its
own. Holds now carry an origin. A system hold is cleared when a source
acceptance supersedes its rollout, for every target mode, with a
rollout_unpaused history row; an operator pause survives until the
operator resumes. Health-executor and fence holds record the system
origin too, so the same rule covers them.

The docs and the accept-route note now describe the retry, the automatic
handoff, and the unmodellable-compose refusal the way the code behaves.

* fix(gitops): close the re-audit findings on holds and the retry

The health executor now forwards the pause-already-on-record marker and the
adapter marks the paused refusals with it, so a per-target pause is not
escalated to an application hold on that path. Every blocked result
declares whether it is holdable, and the Direct pipeline's exact-shape
tests follow. The reconciler's preparation pass runs before the enabled
check for a disabled-only fleet, uses the same durable retry gate as the
per-target path, and is detached from the tick; the tests prove the
isolation, the permanent-failure gate, and the non-blocking tick. The
accept route's dispatch wait is bounded, the resolved-artifact gate lives
in the one handoff, and the unmodellable-compose reason now reaches the
operator in the caveat and the accept note.

* test(gitops): use the fixture in the non-blocking tick test

The test declared a fixture without reading it, which failed the lint gate
on CI. The fixture is now authorized and the test asserts that the
released durable refusal held the application, which also proves the
detached preparation pass reached the dispatch.

* fix(gitops): re-drive the automatic handoff and keep hold origins honest

The reconciler's content pass now has a handoff arm: a resolved generation
under the automatic policy with no live authorization (a slow accept, a
re-enabled Blueprint) or with unacked targets (a transiently refused
dispatch) is dispatched on the next pass. The warranted predicate keeps it
off a settled, progressing, withdrawn, or not-yet-placed rollout, and an
in-memory floor bounds a failure that records no evidence row to the
artifact retry interval.

The pause origin now comes from the refusal's kind: executor dispatch
refusals are system holds that a new commit clears, and only real health
decisions (a stop, pause, or rollback) carry the health origin. The accept
route gates its dispatch half on stack:deploy while the acceptance stays
stack:create, the inventory and the UI match, the unmodellable-compose copy
no longer prescribes a fix for the wrong refusal, and its cause is capped.

* fix(gitops): tighten the redrive guards and report both accept halves

The warranted predicate now leaves a disabled Blueprint, a target with an
operation in flight, a fenced target, and a stale open run under an observe
policy alone, so the reconciler does not race a progressing rollout or
re-drive a no-op. A refusal that did not hold the rollout is diagnostic
rather than a per-tick warning. The accept route reports the preparation
note and the withheld-deploy note together, the UI fails closed when the
rollout policy is unreadable, and the rollback-fence clear is scoped to
the live rollout generation. Per-application memory is pruned.

* fix(gitops): detach a superseded rollout on acceptance and bound the redrive

A source acceptance now always detaches the named rollout generation,
marking it superseded only when it was not already, so an earlier Supersede
or Rollback cannot leave the frozen strategy reading as moved on and disable
the automatic handoff for every later commit. The warranted predicate is
generation-aware and skips per-target pauses, and the handoff arm has an
in-memory floor at the artifact retry interval and a concurrency cap, so a
steady blocked preflight or a persistent refusal is not re-evaluated on
every tick. A rollback fence blocks the hold clear whatever generation wrote
it, matching the dispatch queue's behaviour.

The accept route drops the deploy split: the configured Automatic policy is
the authority for starting the rollout, as it is for the automatic source
acceptance, so the acceptance stays `stack:create`, the UI gate returns to
`stack:create`, and the inventory row says so.

* fix(gitops): guard the preflight-drift supersede and pin the review tests

The preflight-drift invalidation only records a supersede when the
generation is not already marked, so a repeat drift does not append a
duplicate history row. The accept-route test seeds the automatic policy it
names, and the paused-target test asserts the predicate's decision rather
than only that it ran.

* chore: re-trigger CI after the Actions runner delay

* fix(gitops): make the withdrawal check durable and refuse over an unfinished rollback

The automatic re-drive predicate no longer trusts the rollout generation
pointer for a withdrawal: it refuses while the latest rollout authorization
recorded for the accepted source is superseded, so a placement re-approval
cannot revive a rollout the operator withdrew. It also refuses to mint when
a live target still carries a rollback_pending fence, whichever generation
wrote it. The supersede recorder is idempotent, so repeat invalidations and
re-approvals stop appending duplicate history rows; the hold clear and the
predicate both ignore retired rows, which can never finish a rollback. The
accept route joins the preparation note and the dispatch refusal again.

* chore: refresh CI after the Actions outage

* fix(gitops): mark an operator withdrawal explicitly and share the rollback gate

A dedicated withdrawn_at on the rollout generation is written only by the
operator's Supersede and Rollback, so the automatic policy can tell a
deliberate withdrawal from a system supersede (a preflight drift, a
placement invalidation, a re-authorization) and still re-drives after
those. Legacy superseded rows backfill as withdrawn, the safe direction.
The unfinished-rollback refusal moved into the shared handoff, so the
accept route and the SourceController honour it too, with the reason
logged; the predicate no longer carries its own copy. The accept note
joins its parts as sentences.

* fix(gitops): withdraw the latest authorization and hold over an unfinished rollback

The operator's Supersede and Rollback now mark the latest rollout
authorization recorded for the accepted source as withdrawn, not only the
generation the pointer names, so a placement re-approval after a system
supersede cannot leave the automatic policy free to redeploy what was
rolled back. The unfinished-rollback refusal moved into the shared handoff
as a holdable blocked refusal: it writes the reason on the application and
the accept note shows it, instead of a silent skip. The rollback target
set falls back to the fenced target's own generation, so the rollback can
still be finished after an acceptance detached the pointer. The migration
backfill is flag-tracked and transactional, the marker lookup is ordered by
rowid, and the accept note joins its parts as sentences.

* test(gitops): pin the rollback route, the withdrawal migration and the tick tests

The rollback route gained its own tests: a placement pointer with an older
authorization is withdrawn, and a fenced target keeps a resolvable target
set after the pointer detached. The withdrawal backfill has a migration
test covering the four states (fresh boot, column without flag, later
system supersede, dropped column), and the latest-authorization lookup has
a skewed-clock ordering test. The re-drive tests now use the settled
tick-until helper instead of a single tick, so the handoff concurrency cap
cannot defer the application under test. The rollback hold reason, the
feature docs and the Resume note now say "finish it, then resume".

* chore(deps): bump proxy-addr to 2.0.8 and source-map-js to 1.2.2

New advisories (GHSA-jqcg-44mw-7w3h, critical, and GHSA-68fv-2mgg-jv7q,
high) require either a patched version or a VEX statement. Both patches are
inside the existing dependency ranges, so the lockfiles move without any
application change.

* test(gitops): pin every withdrawal-migration state, including the flag gate

The migration test now boots one database state per case: the column
dropped without the flag, the column present without the flag (an earlier
head, or a crash between the ALTER and the backfill), a fresh boot with no
rows, and a later system supersede left untouched on the next boot.
Reverting the flag logic to a column-presence check now fails three of the
four, which is what the flag exists for.

* fix(gitops): report the handoff outcome on accept and scope the docs claims

The accept route now returns a skipped handoff's reason, so the operator
can tell a started rollout from one waiting for authorization, paused, or
held, and the UI prefixes the acceptance to the warning toast. The Resume
reply under the Automatic policy no longer tells the operator to authorize
when the reconciler will start the rollout itself. The feature docs scope
the recovery-capture claim to Local and proxy nodes (Pilot capture is a
tracked follow-up) and state that a Git-managed Blueprint skips the
managed-project drift comparison by design.

* fix(gitops): stop the accept note repeating the artifact reason

When the preparation did not resolve the artifact identity, the accept
note carried the preparation note and the handoff's unresolved-artifact
skip, which say the same thing. The skip reason is now a shared constant
and is suppressed when the preparation note already covers it; every other
skip reason still reaches the operator.
2026-10-06 13:35:29 -04:00
Anso 143f5a2005 feat(blueprints): open a Blueprint and the create dialog over the GitOps workplace (#2083)
From the GitOps workplace, opening a Blueprint or declaring a new one sent you
to Fleet first and only then opened the sheet, so the list you came from, with
its filters and scroll, was gone. The application sheet's Open Blueprint did
the same, and Escape could not take you back.

The Blueprint detail sheet and the create dialog now open over the workplace,
the way a Direct application's Git source already does. They are the same sheets
the Fleet Blueprints tab hosts, driven by one shared hook, so permissions, the
create-then-review flow and label handling are identical in both places. A row's
Open Blueprint, the application sheet's Open Blueprint (which hands its sheet
over) and New Blueprint all go through it, and fall back to the Fleet Blueprints
tab where the workplace cannot host them. The link back to the GitOps portfolio
is left out of a sheet that already sits over it.

Reading node labels is sequenced so an older answer never replaces a newer one,
and a failed read says so instead of leaving label targets silently empty.
2026-10-04 18:20:13 -04:00
Anso 8a9115f48c feat(blueprints): call the tab Blueprints, add a Needs attention filter and keep the catalog on screen (#2082)
The Fleet tab was called Deployments while every surface inside it said
Blueprints, its empty state repeated a heading and offered two create buttons,
and the populated catalog opened with a third naming of the same thing. The
filter row had a Drifted chip that missed failed, conflicting, blocked and
awaiting-confirmation Blueprints, and a tile showed only a coloured dot, never
the state it stood for. Every refresh blanked the whole tab (closing an open
sheet's context) behind a plain "Loading blueprints" line.

The tab is now Blueprints. One header row carries the count and New Blueprint;
the empty state is just its card with one create action. Needs attention
replaces Drifted and covers failed, name conflict, evict blocked, awaiting
confirmation, held repair, drift and a rollout that needs reconfirming; a tile
names its strongest non-active state, and an empty filter offers Show all.

The first load shows a skeleton shaped like the catalog, and only if it is slow.
Later refreshes keep the catalog and any open sheet on screen; a failed refresh
over a catalog shows a notice with Retry and the last list, and the error card is
only for a tab with nothing to show. Overlapping refreshes are sequenced, and a
failed refresh after creating a Blueprint no longer opens its sheet later.

Docs and the e2e tab lookup follow the new name.
2026-10-04 17:35:40 -04:00
Anso d3fb532dfa feat(blueprints): give the Blueprint sheet one Answer and a shorter toolbar (#2080)
The Blueprint detail sheet opened on GitOps rollout and runtime stages that say
nothing true about a Blueprint that is applied rather than rolled out: a
healthy, fully deployed one showed an amber "rollout not executable" and
"runtime never applied". A failed deploy was told five times, its Docker log
pushed Re-apply and Withdraw off the right edge of the table, and the toolbar
carried five buttons, three of them rare.

An Inline Blueprint now leads with one status block built from its own
deployments: in sync, failed, drifted, needs confirmation, reconciler off and
so on, naming the affected nodes and offering the verb that resolves the
loudest problem (Re-apply, Review rollout, Review state, Evict, Enable) only
when this session may run it and the server would accept it. The GitOps status
takes over when it holds a louder warning, and the source that did not speak is
named in a quiet marker. Rollout "not executable" and runtime "never applied"
are left out for a Blueprint that has no Git rollout; a paused rollout or a
failed runtime still show.

The toolbar is Apply now, Edit, Enable or Disable, and Delete. Convert to Git,
Detach Git and Retire to Direct sit in the Content row next to the source they
change, and a pinned Blueprint gets an Unpin button. A failed row shows the one
line that says why, with the full output behind a control, and its action is
named Re-apply to match the status block.
2026-10-04 00:46:50 -04:00
Anso 7051673553 feat(blueprints): pin the create actions and show form errors beside the field (#2079)
The Blueprint create dialog kept Cancel and the primary action at the end of
the scrolling body, so at common window heights the button was off screen, and
a missing name or target only surfaced as a toast while the field it named was
scrolled out of view.

The create actions now live in the dialog's own footer and stay in view, with a
short line saying whether the action adds node labels or deploys nothing yet.
Name, compose and target problems show below their field, the name field takes
focus, and each message clears as soon as that field is fixed. Reconciler
enabled is a switch with a one-line consequence, the three stateful notices are
one line, a failed compose analysis is logged and labelled instead of silently
leaving a stale classification, and the default compose and drift card captions
read in plain words. Deleting a Blueprint uses the shared destructive confirm
with a busy state and keeps the typed-name check.
2026-10-03 21:45:45 -04:00
Anso 03e2636e6a feat(blueprints): create node labels and see matched nodes from the Blueprint form (#2077)
The Blueprint form sent operators to Settings > Nodes whenever no node label
existed, and closing it to do so discarded everything typed. It also showed
the same labels in two rows (any / also all) with no list of the nodes a
selector actually reaches.

Targets now has one row of node labels with a Match any / Match all switch and
a live "Matches N nodes" list. "+ Label" stages a new label on chosen nodes
inside the form; the pending tags are listed and removable, and they count
toward the match list immediately. Nothing is written to a node until the form
is committed: Review rollout (or Create blueprint / Save changes where a
rollout cannot be reviewed) saves the Blueprint, adds the staged labels through
the existing node label endpoint, then opens the rollout preview. Save as
draft stores the Blueprint only. Staged labels that the final selector does not
use are never written. A Blueprint saved with both an any and an all list keeps
showing two rows.

Docs and the create-and-approve tutorial follow the new flow; two screenshots
that no longer match any step are removed.
2026-10-03 21:12:28 -04:00
Anso 6178bc6c4b fix(gitops): normalize Docker architecture names to OCI platform names (#2074)
* fix(gitops): normalize Docker architecture names to OCI platform names

Docker reports the daemon host's `uname -m` spelling in `docker info`
(`x86_64`, `aarch64`, `armv7l`), while registry index descriptors name
platforms with OCI names (`amd64`, `arm64`, `arm`). The digest resolver
compares the two by exact string, so GitOps artifact resolution failed on
every Docker host with "no linux/x86_64 variant", and a Direct Git source
apply with deploy stayed applied_not_deployed with "Private registry
readiness could not be proven".

Translate the architecture and OS at the read boundary, so the local and
remote platform reads return OCI names. Tests cover the docker-info
spellings, the Windows path, and the resolver's exact-match contract.

* fix(gitops): tolerate legacy platform labels and drop the ARM guess

An artifact set stored before the read translated docker-info architecture
spellings keeps platform labels like linux/x86_64. The identity comparator
and the evidence decoder now canonicalize both sides, so digest repair and
the stale-probe membership check still find the approved child, including
when the platform label comes from a leaf that has not been upgraded yet.

The armv6l/armv7l/armv8l rows are removed: OCI distinguishes the arm
variants and this code does not model the variant, so mapping them to `arm`
could qualify a child built for a different ABI. Those hosts fail closed
again. loongarch64 now maps to loong64.

* fix(gitops): match a containerd node's index-only observation

The containerd image store exposes only the image index digest locally
(image Id, RepoDigests and descriptor all carry it), so the platform
child digest is never visible on the node. The identity comparison
required the child and reported every multi-arch deploy as runtime
artifact drift even though the running image was the approved child.

An observation that names no child of the expectation now proves the
child through the frozen index digest and the platform the running
image declares. An observation that names a recorded child must still
name the approved one, and any digest that is neither the expected
index nor a recorded child keeps the comparison fail-closed.

The hub also translates the platform a leaf reports, so a leaf running
before the normalization no longer fails every resolve, and a stored
artifact row that cannot be decoded is logged instead of dropped
silently.
2026-10-03 16:30:43 -04:00
Anso a1285754d1 fix(dashboard): keep the Stack Health name column readable at 1280 (#2073)
* fix(dashboard): keep the Stack Health name column readable at 1280

The GitOps grid minima (1004px this node, 1104px all nodes) did not count the
grid's own horizontal padding, which border-box folds into min-width. At 1280
wide the table sat on that floor and the stack-name track was left 2px, so
names were unreadable and the STACK and STATE headers ran together.

Raise each GitOps minimum so it covers the fixed tracks, the gaps, the 40px
row padding and a 160px stack-name floor: 1162px this node, 1266px all nodes.
Below that the table scrolls horizontally with the name readable.

* fix(dashboard): give the plain Stack Health grids the same name floor

The non-GitOps grids carried the same floor-accounting defect as the GitOps
ones: their min-widths did not count the grid's own horizontal padding. At the
floor that left 2px (this node) and nothing (all nodes) for the stack name.
Apply the same accounting, 998px this node and 1102px all nodes, each leaving
a 160px stack-name track, and extend the regression test to all four grids.
2026-10-03 13:28:08 -04:00
Anso 2f6c0fffe0 fix(preferences): migrate explicit UI preferences on first login after upgrade (#2072)
* fix(preferences): migrate explicit prefs on first login after upgrade

An upgraded browser profile has explicit appearance and navigation
preferences in localStorage but no owner marker, because the marker only
exists once the server-backed sync has run. The first sync treated the
missing marker as a foreign cache and wiped every preference key before
hydration, so the absent-row migration then persisted defaults.

Keep the cache whenever no marker exists: it is the account's migration
source on first sync. Only a valid marker naming a different account
wipes the cache before hydration. Signing out also keeps the cache and
its marker, so a same-account re-login can still hydrate or migrate it;
the next different account is wiped at claim time, before anything can
render or migrate.

* fix(preferences): reset live stores when a different account claims the browser

Wiping the localStorage cache on an account switch left the theme and
navigation stores holding the previous account's live values, so a
brand-new account could render that look and then migrate it into its
own server row. The foreign-cache claim now resets both stores to
defaults, under the hydration guard, before hydration starts. A
same-account login, a marker-less upgrade, and the tombstone and corrupt
paths are unchanged.
2026-10-03 12:56:19 -04:00
Anso 84f335097c refactor(dashboard): give the Home stack health table's GitOps state its own column (#2068)
The GitOps source-state tag (accepted, review required, pending update, and so
on) sat beside the stack name and crowded it. It now has a GITOPS column after
SOURCE, the name cell keeps just the name and the update icon, and clicking the
tag still opens that stack in the GitOps portfolio.

The column only appears while at least one stack in the table has a state the
tag can show, so an instance with no Git-managed stacks keeps the table as it
was. Stacks without a state read "--" in it, and a state this build cannot name
no longer leaves an empty clickable button behind.
2026-10-02 20:55:00 -04:00
Anso e34948bb64 fix(gitops): announce local Git source changes on the channel GitOps views listen to (#2067)
Retry and Resume from the attention queue, and changes made in the Git source
sheet over GitOps, raised the refresh signal as a bare event. The portfolio, the
application sheet and the dashboard's Git source states only react to a signal
tagged as a gitops change (and the dashboard also needs the node), so those
views refreshed only if the server happened to push its own announcement. If it
did not, the queue stayed stale after an action.

Both places now announce through one helper that tags the change as gitops on
the application's node, the same shape the server announces.
2026-10-02 20:38:58 -04:00
Anso 67889e168f refactor(gitops): put the workplace masthead on the shared hero and drop the duplicate attention column (#2066)
The GitOps masthead is now the shared hero masthead with a GITOPS kicker. Its
meta line carries how many nodes are reporting and when the data was updated,
and reads "refreshing" while a refresh is in flight, so the floating refresh
pill over the page is gone. A failed refresh keeps the last-known data with the
same qualifier as before. Attention is one of the masthead counts.

The application table no longer repeats attention in its own column: the
attention list above it, the row tint and the state dot already carry it. The
dot now has an accessible name (and a tooltip) giving the most urgent reason,
so a row beyond the list's cap is still explained and screen readers get the
reason too. Facet chips read the shared tone classes, and the portfolio
load-error Retry is a regular button.
2026-10-02 20:23:19 -04:00
Anso 2d89f13ef2 feat(gitops): resolve attention in place (#2065)
The attention queue now lists each application once, with its most urgent
reason inline and a count of the rest, and every entry carries the step that
resolves it where the operator already is. Retry and Resume on a Direct source
run from the queue on the application's own node; Review update opens that
stack's Git source sheet already pulled to its diff, so applying an update is
two clicks from the GitOps page. A step the session may not run, or the server
does not offer, opens the Git source sheet instead of a button that would be
refused.

The full-page application view is replaced on desktop by a sheet over the
portfolio list, which stays mounted underneath, so closing it lands back on the
same filters and page. A Direct application is its stack's Git source, so a row
or a link that names one opens that sheet and never a second read-only copy.
Blueprint applications keep their authority and rollout controls in their own
sheet. The whole table row is now clickable; the actions menu is unchanged.

The Retry, Resume and Suspend request now lives in one shared handler used by
the sheet and the queue, and names the stack in its toasts. The phone screen
keeps its application page.
2026-10-02 20:05:05 -04:00
Anso ee62a449b2 refactor(gitops): organize the Git source sheet into tabs and make editing deliberate (#2064)
The sheet was one long scroll with the repository form always open and a
toolbar of five buttons. A linked source now has tabs:

- Overview: the status block, with Review update, Retry, or Resume in it.
- Source: a read-only summary of the configuration, an Edit button into
  the form (with Cancel), Adopt onto Blueprint, and the manifest.
- Automation: the apply behavior, Suspend or Resume, and the provider
  hooks.
- Secrets.
- Drift, only while the application has drift.

An unlinked stack gets no tabs, only the form. The toolbar keeps Pull now
and Detach (named the same as its confirmation); Update appears only
while something differs from what is stored, so it never sits there doing
nothing.

Reading and refreshing are more careful. A failed read now says so with a
Retry instead of presenting an empty "link a source" form; a change that
was saved but could not be re-read keeps the linked source on screen; a
pull, apply, suspend, or resume refreshes the status without discarding a
change that is still being made; and a slow answer for a stack the sheet
has left can no longer overwrite the one it shows. Detaching empties the
form instead of leaving the removed source prefilled.

Docs, the Git sources page, and the two end-to-end specs that drive the
sheet are updated.
2026-10-02 18:58:27 -04:00
Anso b689733447 refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view (#2063)
* refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view

The GitOps state used to be a stack of tinted cards on five surfaces: a
posture card, approval chips, a card per facet, a card per target, and a
caveats box, restating the same state several times. Each surface now
shows one status: a single answer (with its resolving button, such as
Review update or Retry), a quiet row naming each stage that applies, and
an Evidence control holding recorded authority, policy lines, targets,
and anything Sencho could not prove.

- Stages that say nothing about an application (unbound placement, an
  absent rollout) are left out of the row instead of printed as "n/a".
- Evidence is never silently lost: the answer carries a marker for
  partial evidence, unreached nodes, and unproven items, and every
  attention reason is kept, the first in the answer and the rest in the
  evidence.
- The Git source sheet's Status section drops from about 450px to about
  170px, and its separate source-state and last-applied rows go; the
  header now says "never applied" until something is.
- The application view no longer shows the posture twice.

Adds a domain-free StatusPath primitive, a pure status model, and a
GitOpsStatus composer; retires the facet-card and approval-chip
components and keeps the policy lines. Docs, the tutorial, and the two
affected screenshots are updated.

* test(gitops): open the status evidence in the digest drift spec

The per-service digests now sit under the Evidence control of the GitOps
status, so the spec opens it before looking for them.
2026-10-02 18:30:16 -04:00
Anso bba9d30204 feat(gitops): open the Git source in place and animate Pull (#2062)
Opening a Direct application's Git source from the GitOps portfolio
(attention queue, row menu, application header) now opens the Git Source
sheet over the portfolio instead of switching to the stack editor. The
sheet targets the application's own node, so reads, pulls, applies,
provider hooks, repository secrets, and the manifest all go to that node
even when another node is active. Changes made in the sheet refresh the
portfolio and the open application view. Adopt onto Blueprint is offered
only when that node is the hub, where adoption runs.

Sheet toolbar actions gain a pending state: the button locks at once and,
once the work outlasts the busy delay, its own icon spins. Pull now and
Save use it, so the refresh circle turns while a pull runs.
2026-10-02 17:39:37 -04:00
Anso c0b5708dd7 fix(gitops): date a recorded placement refusal instead of presenting it as current (#2042)
A placement decision that refuses records its reason once, and the reason is
cleared only when an operator approves the placement, edits the policy, or a
later change re-decides. A decision runs only when a placement change is
pending, so a refusal recorded because an operation was in flight outlives
that operation and nothing corrects it on its own.

The facet card rendered the reason in the present tense. An operator arriving
after a deploy finished was told the system was withholding a placement
because a conflict was in flight, and had to work out that the conflict was
over.

The record is kept and the moment it was made is shown. The timestamp was
already stored and already projected as the decision's own date; the frontend
carried the field and rendered nothing. Re-deciding instead would need a
trigger that does not exist today, and would let an automatic approval land at
a moment the operator is not watching, which is a larger claim than the
evidence supports.

Both tests fail if the behavior is reverted. The backend case drives the real
transitions: an operation in flight, a refusal recorded for it, the operation
finishing, and the projection still reporting the decline carrying the moment
it was recorded rather than the moment it was read.

Verified against a running instance at desktop and phone width: the card
carries the date in both, and the only desktop delta is the one added line.
2026-10-02 09:47:41 -04:00