Compare commits
10 Commits
56d97f13af
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ccefc254a | |||
| 4ff6c7296d | |||
| bc5c99898c | |||
| 80dac33f0a | |||
| facef5b254 | |||
| 6374201f6c | |||
| 9080826b06 | |||
| 39a0875d58 | |||
| 320be34970 | |||
| 25aefe90d6 |
+6
-15
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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")}}
|
||||||
|
|
||||||
@@ -172,20 +172,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<form class="mt-3 flex items-center gap-3" onsubmit="return fleetStart(event)">
|
<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>
|
<label class="text-[11.5px] text-ink-mute"><input id="fleet-canary" type="checkbox" checked> Pause after first host for canary review</label>
|
||||||
<label class="text-[11.5px] text-ink-mute">Type selected count to confirm:</label>
|
<button id="fleet-update-start-btn" class="btn btn-amber">Start agent update</button>
|
||||||
<input id="fleet-update-confirm" class="field mono text-[12.5px]" style="width:80px;padding:5px 8px" oninput="fleetReview()" autocomplete="off">
|
|
||||||
<button id="fleet-update-start-btn" class="btn btn-amber" disabled>Start agent update</button>
|
|
||||||
<span id="fleet-start-error" class="text-bad text-[12px]"></span>
|
<span id="fleet-start-error" class="text-bad text-[12px]"></span>
|
||||||
</form>
|
</form>
|
||||||
<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||document.getElementById('fleet-update-confirm').value!==String(n)}
|
|
||||||
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.hidden=!(r.dataset.name.toLowerCase().includes(q)&&r.dataset.version.toLowerCase().includes(v)&&(s==='all'||r.dataset.state===s)))}
|
|
||||||
function fleetSelectVisible(on){document.querySelectorAll('.fleet-candidate:not([hidden]) .fleet-host:not(:disabled)').forEach(x=>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}
|
|
||||||
fleetReview()
|
|
||||||
</script>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user