12 Commits

Author SHA1 Message Date
steve 3ccefc254a Merge pull request 'Release v1.2.1 — stable Agent updates controls' (#53) from release-v1.2.1 into main
Release / Build + push image (push) Successful in 2m3s
2026-08-22 12:36:00 +01:00
steve 4ff6c7296d docs(changelog): prepare v1.2.1
CI / Test (rest) (pull_request) Successful in 8s
CI / Test (store) (pull_request) Successful in 4s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 10s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m18s
CI / Test (server-http) (pull_request) Successful in 1m47s
2026-08-22 12:34:01 +01:00
steve bc5c99898c Merge pull request 'Stop polling idle Agent updates controls' (#52) from fix-agent-update-refresh into main 2026-08-22 12:33:45 +01:00
steve 80dac33f0a Stop polling idle agent update controls
CI / Test (rest) (pull_request) Successful in 22s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Test (store) (pull_request) Successful in 40s
CI / Build (linux/amd64) (pull_request) Successful in 7s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m45s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
2026-08-22 12:31:08 +01:00
steve facef5b254 Merge pull request 'Fix Agent updates filters and refresh state' (#51) from fix-issue-50-agent-update-filters into main 2026-08-22 12:06:44 +01:00
steve 6374201f6c Fix fleet update filters across refreshes
CI / Test (store) (pull_request) Successful in 5s
CI / Test (rest) (pull_request) Successful in 8s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Lint (pull_request) Successful in 11s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m35s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
2026-08-22 12:04:26 +01:00
steve 9080826b06 Merge pull request 'Release v1.2.0 — fleet updates and reliability' (#49) from release-v1.2.0 into main
Release / Build + push image (push) Successful in 2m10s
2026-08-22 11:34:51 +01:00
steve 39a0875d58 test(alert): drain notifications before teardown
CI / Test (rest) (pull_request) Successful in 39s
CI / Lint (pull_request) Successful in 11s
CI / Test (store) (pull_request) Successful in 43s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m34s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m27s
2026-08-22 11:31:56 +01:00
steve 320be34970 docs(changelog): prepare v1.2.0
CI / Test (store) (pull_request) Successful in 15s
CI / Lint (pull_request) Successful in 11s
CI / Test (rest) (pull_request) Failing after 33s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m44s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m27s
2026-08-22 11:29:59 +01:00
steve 25aefe90d6 Use anonymously pullable CI images 2026-08-22 11:29:15 +01:00
steve 56d97f13af Merge pull request 'Add selectable canary-first agent updates' (#48) from fix-issue-43-agent-updates-workflow into main 2026-08-22 11:11:41 +01:00
steve 383bdb7d36 Add selectable canary-first fleet updates
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m45s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m17s
2026-08-22 11:08:53 +01:00
18 changed files with 442 additions and 82 deletions
+6 -15
View File
@@ -3,7 +3,7 @@
# Notes for anyone editing this file: # Notes for anyone editing this file:
# #
# Custom runner image # Custom runner image
# Every job runs inside `gitea.dcglab.co.uk/steve/ci-runner-go` # Every job runs inside `docker.dcglab.co.uk/public/ci-runner-go:latest`
# (recipe: https://gitea.dcglab.co.uk/steve/ci/src/branch/main/images/ci-runner-go). # (recipe: https://gitea.dcglab.co.uk/steve/ci/src/branch/main/images/ci-runner-go).
# That image already ships: # That image already ships:
# * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is # * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is
@@ -11,8 +11,8 @@
# otherwise re-download Go on every job) # otherwise re-download Go on every job)
# * Node.js + npm (used by docs / e2e workflows) # * Node.js + npm (used by docs / e2e workflows)
# * Docker CLI, Buildx, Compose v2 (used by docker-build steps) # * Docker CLI, Buildx, Compose v2 (used by docker-build steps)
# When bumping the Go floor, push a new ci-runner-go image with # The runner image is intentionally tracked via `latest`: it is shared CI
# the matching Go version and bump the date pin in IMAGE below. # infrastructure, and updates should propagate to consuming repositories.
# #
# Self-hosted runner expectations # Self-hosted runner expectations
# Each runner host bind-mounts persistent volumes for # Each runner host bind-mounts persistent volumes for
@@ -71,10 +71,7 @@ jobs:
name: Test (${{ matrix.name }}) name: Test (${{ matrix.name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -110,10 +107,7 @@ jobs:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v7 - uses: golangci/golangci-lint-action@v7
@@ -130,10 +124,7 @@ jobs:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
e2e: e2e:
name: Playwright vs docker-compose name: Playwright vs docker-compose
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08 container: docker.dcglab.co.uk/public/ci-runner-go:latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+5 -6
View File
@@ -12,7 +12,9 @@
# plus install.sh / install.ps1 / the systemd unit baked in under # plus install.sh / install.ps1 / the systemd unit baked in under
# /opt/restic-manager/dist (the read-only fallback path the server # /opt/restic-manager/dist (the read-only fallback path the server
# handlers use when <DataDir>/... is empty). # handlers use when <DataDir>/... is empty).
# * Pushes to zot OCI registry (docker.dcglab.co.uk). # * Pushes to the public namespace in the zot OCI registry
# (docker.dcglab.co.uk/public/restic-manager). Pulls are anonymous;
# pushes remain authenticated.
# #
# Tag fan-out # Tag fan-out
# * tag push: :vX.Y.Z, :X.Y, :X # * tag push: :vX.Y.Z, :X.Y, :X
@@ -29,7 +31,7 @@ on:
env: env:
REGISTRY: docker.dcglab.co.uk REGISTRY: docker.dcglab.co.uk
IMAGE_NAME: restic-manager IMAGE_NAME: public/restic-manager
# Force bash as the default shell — see ci.yml header. # Force bash as the default shell — see ci.yml header.
defaults: defaults:
@@ -41,10 +43,7 @@ jobs:
name: Build + push image name: Build + push image
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+49 -1
View File
@@ -6,6 +6,49 @@ and the project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.2.1] - 2026-08-22
### Fixed
- Made the Agent updates host-selection screen stable and responsive: removed
the redundant typed-count confirmation, corrected name/version/eligibility
filtering, and stopped replacing idle or terminal controls through polling.
Progress polling now runs only while a rollout is actively running. ([#50])
## [1.2.0] - 2026-08-22
### Added
- Fleet-wide agent updates are now discoverable from Settings and the
dashboard, with arbitrary host subsets, name/tag/version/eligibility
filtering, explicit exclusion reasons, server-side membership validation,
and a canary-first pause after the first verified reconnect. Halted rolls
expose retry and resume actions. ([#43])
- Running jobs with no recent persisted activity are detected server-side and
surfaced through deduplicated alerts. Kind-aware thresholds protect normal
long-running work, terminal jobs self-resolve, and Prometheus exports stuck
job count and oldest inactivity age. ([#41])
- Snapshot projections now retain host-level refresh timestamps—including
authoritative empty reports—expose a derived stale flag, refresh after
backup, forget, and prune, and support an explicit operator reconciliation
endpoint. ([#40])
### Fixed
- Raised the bounded WebSocket read limit on both peers so ordinary large
snapshot reports and restic events no longer disconnect otherwise healthy
agents. Regression coverage exercises payloads beyond the library's former
32 KiB default in both directions. ([#44])
- Fleet-update timeout verification now performs a final authoritative read of
the agent version delivered by the reconnect `hello`, avoiding a poll/deadline
race and reporting the last observed version when verification fails. ([#43])
### Changed
- CI runner images and release images use the anonymous-pull `public`
namespace at `docker.dcglab.co.uk`; registry authentication remains required
only for publishing.
## [1.1.1] - 2026-08-22 ## [1.1.1] - 2026-08-22
### Fixed ### Fixed
@@ -140,7 +183,8 @@ with a web UI, JSON API, and self-updating agent fleet.
go vet, golangci-lint). go vet, golangci-lint).
- Threat model published (`docs/threat-model.md`). - Threat model published (`docs/threat-model.md`).
[Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.1...HEAD [Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.2.0...HEAD
[1.2.0]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.1...v1.2.0
[1.1.1]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.0...v1.1.1 [1.1.1]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.0...v1.1.1
[1.1.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.1.0 [1.1.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.1.0
[1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0 [1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0
@@ -148,3 +192,7 @@ with a web UI, JSON API, and self-updating agent fleet.
[#37]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/37 [#37]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/37
[#36]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/36 [#36]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/36
[#34]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/34 [#34]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/34
[#40]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/40
[#41]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/41
[#43]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/43
[#44]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/44
+11
View File
@@ -61,9 +61,20 @@ type Engine struct {
stuckThresholds map[string]time.Duration stuckThresholds map[string]time.Duration
closeOnce sync.Once closeOnce sync.Once
notifyWG sync.WaitGroup
done chan struct{} done chan struct{}
} }
func (e *Engine) dispatchNotification(ctx context.Context, payload notification.Payload) {
e.notifyWG.Add(1)
go func() {
defer e.notifyWG.Done()
e.hub.Dispatch(ctx, payload)
}()
}
func (e *Engine) waitNotifications() { e.notifyWG.Wait() }
// NewEngine builds the engine. agentOfflineFloor + tickPeriod default // NewEngine builds the engine. agentOfflineFloor + tickPeriod default
// to 15min and 60s respectively when zero. // to 15min and 60s respectively when zero.
func NewEngine(st *store.Store, hub *notification.Hub) *Engine { func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
+4 -4
View File
@@ -60,7 +60,7 @@ func (e *Engine) raiseAndNotify(ctx context.Context, hostID, kind, dedupKey, sev
if err == nil { if err == nil {
hostName = host.Name hostName = host.Name
} }
go e.hub.Dispatch(ctx, notification.Payload{ e.dispatchNotification(ctx, notification.Payload{
Event: notification.EventRaised, Event: notification.EventRaised,
AlertID: id, AlertID: id,
Severity: severity, Severity: severity,
@@ -85,7 +85,7 @@ func (e *Engine) Acknowledge(ctx context.Context, alertID, userID string, when t
return nil //nolint:nilerr return nil //nolint:nilerr
} }
p := alertPayload(ctx, e.store, notification.EventAcknowledged, a) p := alertPayload(ctx, e.store, notification.EventAcknowledged, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p) e.dispatchNotification(context.WithoutCancel(ctx), p)
return nil return nil
} }
@@ -99,7 +99,7 @@ func (e *Engine) Resolve(ctx context.Context, alertID string, when time.Time) er
return nil return nil
} }
p := alertPayload(ctx, e.store, notification.EventResolved, a) p := alertPayload(ctx, e.store, notification.EventResolved, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p) e.dispatchNotification(context.WithoutCancel(ctx), p)
return nil return nil
} }
@@ -164,7 +164,7 @@ func (e *Engine) resolveAndNotify(ctx context.Context, hostID, kind, dedupKey st
if a.Kind != kind || a.DedupKey != dedupKey { if a.Kind != kind || a.DedupKey != dedupKey {
continue continue
} }
go e.hub.Dispatch(ctx, notification.Payload{ e.dispatchNotification(ctx, notification.Payload{
Event: notification.EventResolved, Event: notification.EventResolved,
AlertID: a.ID, AlertID: a.ID,
Severity: a.Severity, Severity: a.Severity,
+1
View File
@@ -25,6 +25,7 @@ func setupEngine(t *testing.T) (*Engine, *store.Store, string) {
aead, _ := crypto.NewAEAD(key) aead, _ := crypto.NewAEAD(key)
hub := notification.NewHub(st, aead, "https://rm.example") hub := notification.NewHub(st, aead, "https://rm.example")
eng := NewEngine(st, hub) eng := NewEngine(st, hub)
t.Cleanup(eng.waitNotifications)
hostID := ulid.Make().String() hostID := ulid.Make().String()
if err := st.CreateHost(context.Background(), store.Host{ if err := st.CreateHost(context.Background(), store.Host{
ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64", ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64",
+31 -1
View File
@@ -71,6 +71,16 @@ func NewWorker(st *store.Store, hub Hub, disp Dispatcher, alerts AlertRaiser) *W
// worker goroutine. Returns the new fleet_update_id on success. // worker goroutine. Returns the new fleet_update_id on success.
// store.ErrFleetUpdateRunning bubbles up unchanged. // store.ErrFleetUpdateRunning bubbles up unchanged.
func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) { func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) {
return w.start(ctx, userID, targetVersion, hostIDs, false)
}
// StartCanary starts the same sequential rollout but pauses after the first
// verified host so an operator can inspect it before starting the remainder.
func (w *Worker) StartCanary(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) {
return w.start(ctx, userID, targetVersion, hostIDs, true)
}
func (w *Worker) start(ctx context.Context, userID, targetVersion string, hostIDs []string, pauseAfterFirst bool) (string, error) {
if userID == "" || targetVersion == "" { if userID == "" || targetVersion == "" {
return "", errors.New("fleetupdate: userID and targetVersion required") return "", errors.New("fleetupdate: userID and targetVersion required")
} }
@@ -85,6 +95,7 @@ func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostID
StartedByUserID: userID, StartedByUserID: userID,
TargetVersion: targetVersion, TargetVersion: targetVersion,
Status: "running", Status: "running",
PauseAfterFirst: pauseAfterFirst,
}, hostIDs); err != nil { }, hostIDs); err != nil {
return "", err return "", err
} }
@@ -137,6 +148,13 @@ func (w *Worker) run(ctx context.Context, fuID, userID, targetVersion string) {
next := pending[0] next := pending[0]
w.processHost(ctx, fuID, userID, next) w.processHost(ctx, fuID, userID, next)
if fu.PauseAfterFirst && next.Position == 0 {
updated, _, gerr := w.store.GetFleetUpdate(ctx, fuID)
if gerr == nil && updated.Status == "running" {
_ = w.store.HaltFleetUpdate(ctx, fuID, "canary succeeded; review the host, then resume remaining agents", time.Now().UTC())
}
return
}
} }
} }
@@ -195,7 +213,19 @@ func (w *Worker) processHost(ctx context.Context, fuID, userID string, slot stor
return return
} }
} }
reason := fmt.Sprintf("timeout waiting for %s to reach %s", hostID, w.targetVersion) // One authoritative final read closes the poll/deadline race. AgentVersion
// is written directly from the reconnect hello handshake.
lastVersion := "unknown"
if h, err := w.store.GetHost(ctx, hostID); err == nil && h != nil {
if h.AgentVersion == w.targetVersion {
_ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "succeeded", "", jobID)
return
}
if h.AgentVersion != "" {
lastVersion = h.AgentVersion
}
}
reason := fmt.Sprintf("timeout waiting for %s reconnect hello at %s (last reported %s)", hostID, w.targetVersion, lastVersion)
_ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "failed", reason, jobID) _ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "failed", reason, jobID)
w.halt(ctx, fuID, reason) w.halt(ctx, fuID, reason)
} }
@@ -160,6 +160,35 @@ func TestWorkerTwoHostsBothSucceed(t *testing.T) {
} }
} }
func TestWorkerCanaryPausesAfterFirstVerifiedHost(t *testing.T) {
st := openStore(t)
uid := mustCreateAdmin(t, st)
h1 := mustCreateHost(t, st, "canary", "v0")
h2 := mustCreateHost(t, st, "remainder", "v0")
hub := &fakeHub{online: map[string]bool{h1: true, h2: true}}
disp := &fakeDispatcher{st: st, target: "v2", delayMS: 20}
alerts := &recAlert{}
w := NewWorker(st, hub, disp, alerts)
w.pollPeriod = 10 * time.Millisecond
w.hostTimeout = time.Second
fuID, err := w.StartCanary(context.Background(), uid, "v2", []string{h1, h2})
if err != nil {
t.Fatalf("start canary: %v", err)
}
fu := waitForStatus(t, st, fuID, "halted", 2*time.Second)
if fu.HaltedReason != "canary succeeded; review the host, then resume remaining agents" {
t.Fatalf("halt reason: %q", fu.HaltedReason)
}
_, hosts, _ := st.GetFleetUpdate(context.Background(), fuID)
if hosts[0].Status != "succeeded" || hosts[1].Status != "pending" {
t.Fatalf("canary statuses: %+v", hosts)
}
if len(alerts.reasons) != 0 {
t.Fatalf("successful canary pause must not alert: %v", alerts.reasons)
}
}
func TestWorkerSecondHostTimesOutHalts(t *testing.T) { func TestWorkerSecondHostTimesOutHalts(t *testing.T) {
st := openStore(t) st := openStore(t)
uid := mustCreateAdmin(t, st) uid := mustCreateAdmin(t, st)
+109 -12
View File
@@ -32,6 +32,13 @@ import (
type fleetUpdateStartReq struct { type fleetUpdateStartReq struct {
TargetVersion string `json:"target_version,omitempty"` TargetVersion string `json:"target_version,omitempty"`
HostIDs []string `json:"host_ids,omitempty"` HostIDs []string `json:"host_ids,omitempty"`
CanaryFirst bool `json:"canary_first,omitempty"`
}
type fleetUpdateCandidate struct {
Host store.Host
Eligible bool
Reason string
} }
// fleetUpdateHostView is one row in the JSON response for GET // fleetUpdateHostView is one row in the JSON response for GET
@@ -56,6 +63,7 @@ type fleetUpdateView struct {
CurrentHostID string `json:"current_host_id,omitempty"` CurrentHostID string `json:"current_host_id,omitempty"`
HaltedReason string `json:"halted_reason,omitempty"` HaltedReason string `json:"halted_reason,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"`
CanaryFirst bool `json:"canary_first"`
Hosts []fleetUpdateHostView `json:"hosts"` Hosts []fleetUpdateHostView `json:"hosts"`
} }
@@ -65,6 +73,7 @@ type fleetUpdateView struct {
type fleetUpdatePage struct { type fleetUpdatePage struct {
// Idle-state fields. // Idle-state fields.
OutOfDateHosts []store.Host // online hosts whose version != target OutOfDateHosts []store.Host // online hosts whose version != target
Candidates []fleetUpdateCandidate
TargetVersion string TargetVersion string
// Active-state fields. Nil when no fleet update has ever run. // Active-state fields. Nil when no fleet update has ever run.
@@ -100,6 +109,11 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
if target == "" { if target == "" {
target = version.Version target = version.Version
} }
if target != version.Version {
writeJSONError(w, stdhttp.StatusUnprocessableEntity, "unsupported_target_version",
"fleet updates can only target the running server version")
return
}
hostIDs := body.HostIDs hostIDs := body.HostIDs
if len(hostIDs) == 0 { if len(hostIDs) == 0 {
derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target) derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target)
@@ -108,6 +122,19 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
return return
} }
hostIDs = derived hostIDs = derived
} else {
validated, reasons, err := s.validateFleetUpdateHostIDs(r.Context(), target, hostIDs)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
if len(reasons) > 0 {
writeJSON(w, stdhttp.StatusUnprocessableEntity, map[string]any{
"code": "ineligible_hosts", "message": "one or more selected hosts are ineligible", "reasons": reasons,
})
return
}
hostIDs = validated
} }
if len(hostIDs) == 0 { if len(hostIDs) == 0 {
writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible", writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible",
@@ -115,7 +142,20 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
return return
} }
fuID, err := s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs) var fuID string
var err error
if body.CanaryFirst && len(hostIDs) > 1 {
worker, supported := s.deps.FleetWorker.(interface {
StartCanary(context.Context, string, string, []string) (string, error)
})
if !supported {
writeJSONError(w, stdhttp.StatusServiceUnavailable, "canary_unavailable", "")
return
}
fuID, err = worker.StartCanary(r.Context(), user.ID, target, hostIDs)
} else {
fuID, err = s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs)
}
if err != nil { if err != nil {
if errors.Is(err, store.ErrFleetUpdateRunning) { if errors.Is(err, store.ErrFleetUpdateRunning) {
writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error()) writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error())
@@ -129,6 +169,7 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
"fleet_update_id": fuID, "fleet_update_id": fuID,
"target_version": target, "target_version": target,
"host_count": len(hostIDs), "host_count": len(hostIDs),
"canary_first": body.CanaryFirst,
}) })
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user", ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
@@ -209,6 +250,7 @@ func (s *Server) handleAPIFleetUpdateGet(w stdhttp.ResponseWriter, r *stdhttp.Re
Status: fu.Status, Status: fu.Status,
CurrentHostID: fu.CurrentHostID, CurrentHostID: fu.CurrentHostID,
HaltedReason: fu.HaltedReason, HaltedReason: fu.HaltedReason,
CanaryFirst: fu.PauseAfterFirst,
Hosts: make([]fleetUpdateHostView, 0, len(hosts)), Hosts: make([]fleetUpdateHostView, 0, len(hosts)),
} }
if fu.CompletedAt != nil { if fu.CompletedAt != nil {
@@ -286,6 +328,27 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
} }
for _, h := range hosts { for _, h := range hosts {
page.HostNames[h.ID] = h.Name page.HostNames[h.ID] = h.Name
candidate := fleetUpdateCandidate{Host: h}
switch {
case h.AgentVersion == "":
candidate.Reason = "version unknown"
case h.AgentVersion == page.TargetVersion:
candidate.Reason = "already current"
case s.deps.Hub == nil || !s.deps.Hub.Connected(h.ID):
candidate.Reason = "offline"
default:
updating, uerr := s.deps.Store.RunningUpdateJobForHost(r.Context(), h.ID)
if uerr != nil {
return page, uerr
}
if updating != "" {
candidate.Reason = "update already running"
} else {
candidate.Eligible = true
page.OutOfDateHosts = append(page.OutOfDateHosts, h)
}
}
page.Candidates = append(page.Candidates, candidate)
} }
active, err := s.deps.Store.ActiveFleetUpdate(r.Context()) active, err := s.deps.Store.ActiveFleetUpdate(r.Context())
@@ -328,20 +391,54 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
} }
} }
// Idle list (or "still out of date" reference even when an active
// roll is running — cheap to compute, harmless to attach).
for _, h := range hosts {
if h.Status != "online" {
continue
}
if h.AgentVersion == "" || h.AgentVersion == page.TargetVersion {
continue
}
page.OutOfDateHosts = append(page.OutOfDateHosts, h)
}
return page, nil return page, nil
} }
// validateFleetUpdateHostIDs deduplicates an explicit selection while
// preserving review order and rejects the entire request if membership has
// changed or any selected host is not dispatchable.
func (s *Server) validateFleetUpdateHostIDs(ctx context.Context, target string, requested []string) ([]string, map[string]string, error) {
hosts, err := s.deps.Store.ListHosts(ctx)
if err != nil {
return nil, nil, err
}
byID := make(map[string]store.Host, len(hosts))
for _, h := range hosts {
byID[h.ID] = h
}
seen := map[string]bool{}
validated := make([]string, 0, len(requested))
reasons := map[string]string{}
for _, id := range requested {
if seen[id] {
continue
}
seen[id] = true
h, ok := byID[id]
switch {
case !ok:
reasons[id] = "host not found"
case h.AgentVersion == "":
reasons[id] = "agent version unknown"
case h.AgentVersion == target:
reasons[id] = "already at target version"
case s.deps.Hub == nil || !s.deps.Hub.Connected(id):
reasons[id] = "host offline"
default:
jobID, jerr := s.deps.Store.RunningUpdateJobForHost(ctx, id)
if jerr != nil {
return nil, nil, jerr
}
if jobID != "" {
reasons[id] = "update already in progress"
} else {
validated = append(validated, id)
}
}
}
return validated, reasons, nil
}
// deriveOutOfDateOnlineHostIDs returns the list of host IDs that // deriveOutOfDateOnlineHostIDs returns the list of host IDs that
// (a) are online (Hub.Connected) and (b) have an agent_version that's // (a) are online (Hub.Connected) and (b) have an agent_version that's
// non-empty AND != target. Used by the start endpoint when the caller // non-empty AND != target. Used by the start endpoint when the caller
+128
View File
@@ -6,7 +6,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
stdhttp "net/http" stdhttp "net/http"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -19,6 +21,67 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/version" "gitea.dcglab.co.uk/steve/restic-manager/internal/version"
) )
func TestFleetUpdateIdlePageDoesNotPollOrRequireTypedConfirmation(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
_ = makeHost(t, st, "fleet-filter-host")
req, _ := stdhttp.NewRequest("GET", baseURL+"/settings/fleet-update", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get fleet update page: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Fatalf("status: got %d, want 200", res.StatusCode)
}
raw, _ := io.ReadAll(res.Body)
body := string(raw)
for _, want := range []string{
`id="fleet-filter-name"`,
`id="fleet-filter-version"`,
`id="fleet-filter-state"`,
"r.style.display=",
} {
if !strings.Contains(body, want) {
t.Errorf("page missing %q", want)
}
}
if strings.Contains(body, "Type selected count to confirm") || strings.Contains(body, `id="fleet-update-confirm"`) {
t.Error("page still renders the typed-count confirmation")
}
if strings.Contains(body, `hx-trigger="every 3s`) {
t.Error("idle selection page must not poll and replace operator input")
}
}
func TestFleetUpdateRunningPagePollsForProgress(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie, userID := loginAsAdminWithID(t, st)
hostID := makeHost(t, st, "fleet-running-host")
if err := st.CreateFleetUpdate(context.Background(), store.FleetUpdate{
ID: ulid.Make().String(), StartedByUserID: userID, TargetVersion: version.Version,
}, []string{hostID}); err != nil {
t.Fatalf("create fleet update: %v", err)
}
req, _ := stdhttp.NewRequest("GET", baseURL+"/settings/fleet-update", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get fleet update page: %v", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if body := string(raw); !strings.Contains(body, `hx-trigger="every 3s`) {
t.Error("running rollout page must poll for progress")
}
}
// fakeFleetWorker stands in for *fleetupdate.Worker in HTTP tests. // fakeFleetWorker stands in for *fleetupdate.Worker in HTTP tests.
// It records what was passed to Start/Cancel and lets tests inject // It records what was passed to Start/Cancel and lets tests inject
// canned errors. Satisfies the FleetWorker interface in // canned errors. Satisfies the FleetWorker interface in
@@ -50,6 +113,10 @@ func (f *fakeFleetWorker) Start(_ context.Context, userID, target string, hostID
return f.startID, nil return f.startID, nil
} }
func (f *fakeFleetWorker) StartCanary(ctx context.Context, userID, target string, hostIDs []string) (string, error) {
return f.Start(ctx, userID, target, hostIDs)
}
func (f *fakeFleetWorker) Cancel(_ context.Context, id string) error { func (f *fakeFleetWorker) Cancel(_ context.Context, id string) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -190,6 +257,67 @@ func TestFleetUpdateStartDerivesHostIDsWhenEmpty(t *testing.T) {
} }
} }
func TestFleetUpdateStartDeduplicatesExplicitSelection(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
worker := &fakeFleetWorker{startID: ulid.Make().String()}
srv.deps.FleetWorker = worker
cookie := loginAsAdmin(t, st)
hostID := helloOnlineHost(t, srv, st, "duplicate-host", "v0")
raw, _ := json.Marshal(map[string]any{"host_ids": []string{hostID, hostID}})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader(raw))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusAccepted {
t.Fatalf("status: %d", res.StatusCode)
}
if got := worker.startCalls[0].HostIDs; len(got) != 1 || got[0] != hostID {
t.Fatalf("deduplicated ids: %v", got)
}
}
func TestFleetUpdateStartRejectsUnknownAndIneligibleHosts(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
worker := &fakeFleetWorker{startID: ulid.Make().String()}
srv.deps.FleetWorker = worker
cookie := loginAsAdmin(t, st)
offline := makeHost(t, st, "offline-host")
if err := st.MarkHostHello(context.Background(), offline, "v0", "0.17", api.CurrentProtocolVersion, time.Now().UTC()); err != nil {
t.Fatalf("mark offline host: %v", err)
}
raw, _ := json.Marshal(map[string]any{"host_ids": []string{offline, "does-not-exist"}})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader(raw))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnprocessableEntity {
t.Fatalf("status: got %d, want 422", res.StatusCode)
}
var body struct {
Code string `json:"code"`
Reasons map[string]string `json:"reasons"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Code != "ineligible_hosts" || len(body.Reasons) != 2 {
t.Fatalf("structured reasons: %+v", body)
}
if len(worker.startCalls) != 0 {
t.Fatal("worker must not start with invalid membership")
}
}
func TestFleetUpdateCancelHappyPath(t *testing.T) { func TestFleetUpdateCancelHappyPath(t *testing.T) {
t.Parallel() t.Parallel()
srv, ts, st := rawTestServer(t) srv, ts, st := rawTestServer(t)
+11 -7
View File
@@ -42,9 +42,9 @@ func (st *Store) CreateFleetUpdate(ctx context.Context, fu FleetUpdate, hostIDs
} }
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO fleet_updates (id, started_at, started_by_user_id, target_version, status) `INSERT INTO fleet_updates (id, started_at, started_by_user_id, target_version, status, pause_after_first)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
fu.ID, fu.StartedAt.UTC().Format(time.RFC3339Nano), fu.StartedByUserID, fu.TargetVersion, fu.Status, fu.ID, fu.StartedAt.UTC().Format(time.RFC3339Nano), fu.StartedByUserID, fu.TargetVersion, fu.Status, fu.PauseAfterFirst,
); err != nil { ); err != nil {
return fmt.Errorf("store: insert fleet_updates: %w", err) return fmt.Errorf("store: insert fleet_updates: %w", err)
} }
@@ -67,12 +67,13 @@ func (st *Store) ActiveFleetUpdate(ctx context.Context) (*FleetUpdate, error) {
var current sql.NullString var current sql.NullString
var halted sql.NullString var halted sql.NullString
var completedAt sql.NullString var completedAt sql.NullString
var pauseAfterFirst int
err := st.db.QueryRowContext(ctx, err := st.db.QueryRowContext(ctx,
`SELECT id, started_at, started_by_user_id, target_version, status, `SELECT id, started_at, started_by_user_id, target_version, status,
current_host_id, halted_reason, completed_at current_host_id, halted_reason, completed_at, pause_after_first
FROM fleet_updates WHERE status = 'running' LIMIT 1`). FROM fleet_updates WHERE status = 'running' LIMIT 1`).
Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status, Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status,
&current, &halted, &completedAt) &current, &halted, &completedAt, &pauseAfterFirst)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -82,6 +83,7 @@ func (st *Store) ActiveFleetUpdate(ctx context.Context) (*FleetUpdate, error) {
fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt) fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt)
fu.CurrentHostID = current.String fu.CurrentHostID = current.String
fu.HaltedReason = halted.String fu.HaltedReason = halted.String
fu.PauseAfterFirst = pauseAfterFirst != 0
if completedAt.Valid { if completedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, completedAt.String) t, _ := time.Parse(time.RFC3339Nano, completedAt.String)
fu.CompletedAt = &t fu.CompletedAt = &t
@@ -97,12 +99,13 @@ func (st *Store) GetFleetUpdate(ctx context.Context, id string) (*FleetUpdate, [
var current sql.NullString var current sql.NullString
var halted sql.NullString var halted sql.NullString
var completedAt sql.NullString var completedAt sql.NullString
var pauseAfterFirst int
err := st.db.QueryRowContext(ctx, err := st.db.QueryRowContext(ctx,
`SELECT id, started_at, started_by_user_id, target_version, status, `SELECT id, started_at, started_by_user_id, target_version, status,
current_host_id, halted_reason, completed_at current_host_id, halted_reason, completed_at, pause_after_first
FROM fleet_updates WHERE id = ?`, id). FROM fleet_updates WHERE id = ?`, id).
Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status, Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status,
&current, &halted, &completedAt) &current, &halted, &completedAt, &pauseAfterFirst)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrNotFound return nil, nil, ErrNotFound
} }
@@ -112,6 +115,7 @@ func (st *Store) GetFleetUpdate(ctx context.Context, id string) (*FleetUpdate, [
fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt) fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt)
fu.CurrentHostID = current.String fu.CurrentHostID = current.String
fu.HaltedReason = halted.String fu.HaltedReason = halted.String
fu.PauseAfterFirst = pauseAfterFirst != 0
if completedAt.Valid { if completedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, completedAt.String) t, _ := time.Parse(time.RFC3339Nano, completedAt.String)
fu.CompletedAt = &t fu.CompletedAt = &t
@@ -0,0 +1 @@
ALTER TABLE fleet_updates ADD COLUMN pause_after_first INTEGER NOT NULL DEFAULT 0;
+1
View File
@@ -231,6 +231,7 @@ type FleetUpdate struct {
CurrentHostID string CurrentHostID string
HaltedReason string HaltedReason string
CompletedAt *time.Time CompletedAt *time.Time
PauseAfterFirst bool
} }
// FleetUpdateHost is one host's slot in a fleet update. Position is // FleetUpdateHost is one host's slot in a fleet update. Position is
+1 -1
View File
@@ -69,7 +69,7 @@
{{/* ---------- Hosts-behind hero tile (P6-18) ---------- */}} {{/* ---------- Hosts-behind hero tile (P6-18) ---------- */}}
{{if gt $page.UpdatesBehind 0}} {{if gt $page.UpdatesBehind 0}}
<div class="pt-4"> <div class="pt-4">
<a href="?updates=behind" class="hero-tile hero-tile--amber" style="display:inline-flex;"> <a href="/settings/fleet-update" class="hero-tile hero-tile--amber" style="display:inline-flex;">
<span class="hero-num">{{$page.UpdatesBehind}}</span> <span class="hero-num">{{$page.UpdatesBehind}}</span>
<span class="hero-label">{{if eq $page.UpdatesBehind 1}}host behind{{else}}hosts behind{{end}} · review →</span> <span class="hero-label">{{if eq $page.UpdatesBehind 1}}host behind{{else}}hosts behind{{end}} · review →</span>
</a> </a>
+12 -2
View File
@@ -8,14 +8,14 @@
<div class="crumbs pt-6"> <div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span> <a href="/">Dashboard</a><span class="sep">/</span>
<a href="/settings">Settings</a><span class="sep">/</span> <a href="/settings">Settings</a><span class="sep">/</span>
<span class="text-ink-mid">fleet update</span> <span class="text-ink-mid">agent updates</span>
</div> </div>
{{/* page header */}} {{/* page header */}}
<div class="flex items-baseline justify-between mt-3.5"> <div class="flex items-baseline justify-between mt-3.5">
<div> <div>
<h1 class="text-[22px] font-medium tracking-[-0.005em]"> <h1 class="text-[22px] font-medium tracking-[-0.005em]">
Fleet update Agent updates
<span class="text-ink-fade font-normal text-[14px] ml-2 mono">target {{$page.TargetVersion}}</span> <span class="text-ink-fade font-normal text-[14px] ml-2 mono">target {{$page.TargetVersion}}</span>
</h1> </h1>
<p class="text-ink-mute text-[12px] mt-1 max-w-[760px] leading-[1.55]"> <p class="text-ink-mute text-[12px] mt-1 max-w-[760px] leading-[1.55]">
@@ -28,5 +28,15 @@
{{template "fleet_update_inner" .}} {{template "fleet_update_inner" .}}
<script>
function fleetBoxes(){return Array.from(document.querySelectorAll('.fleet-host:not(:disabled)'))}
function fleetReview(){const n=fleetBoxes().filter(x=>x.checked).length;document.getElementById('fleet-selected-count').textContent=n;document.getElementById('fleet-timeout').textContent=n+' × 95s';document.getElementById('fleet-update-start-btn').disabled=n===0}
function fleetFilter(){const q=document.getElementById('fleet-filter-name').value.toLowerCase(),v=document.getElementById('fleet-filter-version').value.toLowerCase(),s=document.getElementById('fleet-filter-state').value;document.querySelectorAll('.fleet-candidate').forEach(r=>r.style.display=r.dataset.name.toLowerCase().includes(q)&&r.dataset.version.toLowerCase().includes(v)&&(s==='all'||r.dataset.state===s)?'':'none')}
function fleetSelectVisible(on){document.querySelectorAll('.fleet-candidate').forEach(r=>{const x=r.querySelector('.fleet-host:not(:disabled)');if(x&&r.style.display!=='none')x.checked=on});fleetReview()}
function fleetSelectOnly(id){fleetBoxes().forEach(x=>x.checked=x.value===id);fleetReview()}
async function fleetStart(e){e.preventDefault();const ids=fleetBoxes().filter(x=>x.checked).map(x=>x.value),out=document.getElementById('fleet-start-error');out.textContent='';const res=await fetch('/api/fleet/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host_ids:ids,canary_first:document.getElementById('fleet-canary').checked})});if(res.ok){location.reload();return false}const body=await res.json();out.textContent=body.message||body.code||'Unable to start';return false}
if(document.getElementById('fleet-selected-count'))fleetReview()
</script>
</div> </div>
{{end}} {{end}}
+1
View File
@@ -40,6 +40,7 @@
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}} {{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
</a> </a>
<a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a> <a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a>
<a href="/settings/fleet-update" class="sub-tab">Agent updates</a>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span> <span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
</div> </div>
+40 -31
View File
@@ -7,11 +7,11 @@
*/}} */}}
{{define "fleet_update_inner"}} {{define "fleet_update_inner"}}
{{$page := .Page}} {{$page := .Page}}
<div id="fleet-update-panel" class="mt-5" <div id="fleet-update-panel" class="mt-5"{{if and $page.Active (eq $page.Active.Status "running")}}
hx-get="{{$page.PollURL}}" hx-get="{{$page.PollURL}}"
hx-trigger="every 3s [document.visibilityState==='visible']" hx-trigger="every 3s [document.visibilityState==='visible']"
hx-select="#fleet-update-panel" hx-select="#fleet-update-panel"
hx-swap="outerHTML"> hx-swap="outerHTML"{{end}}>
{{if and $page.Active (eq $page.Active.Status "running")}} {{if and $page.Active (eq $page.Active.Status "running")}}
@@ -32,6 +32,7 @@
</div> </div>
<div class="text-[11.5px] text-ink-mute mt-1"> <div class="text-[11.5px] text-ink-mute mt-1">
target <span class="mono text-ink-mid">{{$page.Active.TargetVersion}}</span> target <span class="mono text-ink-mid">{{$page.Active.TargetVersion}}</span>
{{if $page.Active.PauseAfterFirst}} · canary-first{{end}}
· started <span class="mono text-ink-mid">{{relTime $page.Active.StartedAt}}</span> · started <span class="mono text-ink-mid">{{relTime $page.Active.StartedAt}}</span>
{{if $page.Active.CurrentHostID}} {{if $page.Active.CurrentHostID}}
· waiting on <span class="mono text-ink-mid">{{index $page.HostNames $page.Active.CurrentHostID}}</span> · waiting on <span class="mono text-ink-mid">{{index $page.HostNames $page.Active.CurrentHostID}}</span>
@@ -68,6 +69,12 @@
{{if $page.Active.HaltedReason}} {{if $page.Active.HaltedReason}}
<div class="text-[12px] text-bad mt-2">{{$page.Active.HaltedReason}}</div> <div class="text-[12px] text-bad mt-2">{{$page.Active.HaltedReason}}</div>
{{end}} {{end}}
{{if eq $page.Active.Status "halted"}}
<div class="mt-3 flex gap-2 text-[12px]">
<a class="btn" href="#fleet-update-start-form">Resume remaining as new rollout</a>
{{range $page.ActiveRows}}{{if eq .Status "failed"}}<button class="btn" type="button" onclick="fleetSelectOnly('{{.HostID}}');document.getElementById('fleet-update-start-form').scrollIntoView()">Retry {{.HostName}}</button>{{end}}{{end}}
</div>
{{end}}
</div> </div>
{{template "fleet_update_rows" $page}} {{template "fleet_update_rows" $page}}
@@ -122,11 +129,11 @@
{{define "fleet_update_idle_panel"}} {{define "fleet_update_idle_panel"}}
{{$page := .}} {{$page := .}}
<div class="panel rounded-[7px] px-5 py-4"> <div class="panel rounded-[7px] px-5 py-4">
{{if eq (len $page.OutOfDateHosts) 0}} {{if eq (len $page.Candidates) 0}}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="dot dot-online"></span> <span class="dot dot-online"></span>
<div> <div>
<div class="text-ink text-[14px] font-medium">All hosts are up to date.</div> <div class="text-ink text-[14px] font-medium">No hosts enrolled.</div>
<div class="text-ink-mute text-[12px] mt-0.5"> <div class="text-ink-mute text-[12px] mt-0.5">
Every online agent matches server version <span class="mono">{{$page.TargetVersion}}</span>. Every online agent matches server version <span class="mono">{{$page.TargetVersion}}</span>.
</div> </div>
@@ -137,34 +144,36 @@
<h2 class="text-[14px] font-medium">{{len $page.OutOfDateHosts}} host{{if ne (len $page.OutOfDateHosts) 1}}s{{end}} out of date</h2> <h2 class="text-[14px] font-medium">{{len $page.OutOfDateHosts}} host{{if ne (len $page.OutOfDateHosts) 1}}s{{end}} out of date</h2>
<span class="mono text-[11px] text-ink-fade">target {{$page.TargetVersion}}</span> <span class="mono text-[11px] text-ink-fade">target {{$page.TargetVersion}}</span>
</div> </div>
<ul class="mt-3 space-y-1 text-[12px]"> <div class="mt-3 flex flex-wrap gap-2">
{{range $page.OutOfDateHosts}} <input id="fleet-filter-name" class="field text-[12px]" placeholder="Filter name or tag" oninput="fleetFilter()">
<li class="flex items-center gap-3"> <input id="fleet-filter-version" class="field text-[12px] mono" placeholder="Version" oninput="fleetFilter()">
<span class="dot dot-online"></span> <select id="fleet-filter-state" class="field text-[12px]" onchange="fleetFilter()">
<span class="mono text-ink">{{.Name}}</span> <option value="all">All states</option><option value="eligible">Eligible</option><option value="excluded">Excluded</option>
<span class="mono text-ink-mute">{{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}} → {{$page.TargetVersion}}</span> </select>
</li> <button type="button" class="btn" onclick="fleetSelectVisible(true)">Select visible eligible</button>
{{end}} <button type="button" class="btn" onclick="fleetSelectVisible(false)">Clear selection</button>
</ul> </div>
<div class="panel mt-3 rounded-[7px] overflow-hidden">
<form id="fleet-update-start-form" class="mt-4 flex items-center gap-3" {{range $page.Candidates}}
hx-post="/api/fleet/update" <label class="fleet-candidate grid items-center px-3 py-2 hairline text-[12px]"
hx-headers='{"Content-Type":"application/json"}' data-name="{{.Host.Name}} {{range .Host.Tags}}{{.}} {{end}}" data-version="{{.Host.AgentVersion}}" data-state="{{if .Eligible}}eligible{{else}}excluded{{end}}"
hx-vals='{}' style="grid-template-columns: 28px 1.4fr .8fr .8fr 1.2fr;gap:12px">
hx-swap="none" <input class="fleet-host" type="checkbox" value="{{.Host.ID}}" {{if .Eligible}}checked onchange="fleetReview()"{{else}}disabled{{end}}>
hx-on::after-request="if(event.detail.successful) location.reload()"> <span class="mono">{{.Host.Name}}</span>
<label class="text-[11.5px] text-ink-mute"> <span class="mono text-ink-mute">{{if .Host.AgentVersion}}{{.Host.AgentVersion}}{{else}}unknown{{end}}</span>
Type the count <span class="mono text-ink-mute">{{$page.TargetVersion}}</span>
<span class="mono text-ink-mid">({{len $page.OutOfDateHosts}})</span> <span class="text-ink-mute">{{if .Eligible}}eligible{{else}}{{.Reason}}{{end}}</span>
to enable Start:
</label> </label>
<input type="text" id="fleet-update-confirm" class="field mono text-[12.5px]" {{end}}
style="width: 80px; padding: 5px 8px;" </div>
oninput="document.getElementById('fleet-update-start-btn').disabled = (this.value !== '{{len $page.OutOfDateHosts}}');" <div class="mt-4 text-[12px] text-ink-mute">
autocomplete="off" /> <span id="fleet-selected-count">{{len $page.OutOfDateHosts}}</span> selected · sequential, halts on first failure · worst-case
<button type="submit" id="fleet-update-start-btn" class="btn btn-amber" disabled> <span id="fleet-timeout" class="mono">{{len $page.OutOfDateHosts}} × 95s</span>
Start fleet update </div>
</button> <form class="mt-3 flex items-center gap-3" onsubmit="return fleetStart(event)">
<label class="text-[11.5px] text-ink-mute"><input id="fleet-canary" type="checkbox" checked> Pause after first host for canary review</label>
<button id="fleet-update-start-btn" class="btn btn-amber">Start agent update</button>
<span id="fleet-start-error" class="text-bad text-[12px]"></span>
</form> </form>
{{end}} {{end}}
</div> </div>