ui: fleet update page + endpoints
- POST /api/fleet/update, POST /api/fleet-updates/{id}/cancel,
GET /api/fleet-updates/{id} (admin-only).
- GET /settings/fleet-update + /partial for htmx polling.
- Renders idle / running / terminal states with per-host progress.
- Tests cover happy path, derive-host-ids, conflict, cancel, get,
and RBAC.
This commit is contained in:
@@ -0,0 +1,379 @@
|
|||||||
|
// fleet_update.go — admin-only fleet rolling-update endpoints + page.
|
||||||
|
//
|
||||||
|
// Surface:
|
||||||
|
// - POST /api/fleet/update → starts a fleet update (JSON)
|
||||||
|
// - POST /api/fleet-updates/{id}/cancel
|
||||||
|
// - GET /api/fleet-updates/{id} → JSON parent + per-host array
|
||||||
|
// - GET /settings/fleet-update → admin UI page
|
||||||
|
// - GET /settings/fleet-update/partial → htmx polling fragment
|
||||||
|
//
|
||||||
|
// All routes are mounted in the admin band (see routes()).
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fleetUpdateStartReq is the JSON body for POST /api/fleet/update.
|
||||||
|
// Both fields are optional: empty target_version defaults to the
|
||||||
|
// server's current version, empty host_ids derives the out-of-date
|
||||||
|
// online subset.
|
||||||
|
type fleetUpdateStartReq struct {
|
||||||
|
TargetVersion string `json:"target_version,omitempty"`
|
||||||
|
HostIDs []string `json:"host_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fleetUpdateHostView is one row in the JSON response for GET
|
||||||
|
// /api/fleet-updates/{id}. Hostname is hydrated from the store so
|
||||||
|
// callers don't need a second round-trip per host.
|
||||||
|
type fleetUpdateHostView struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
HostName string `json:"host_name,omitempty"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
JobID string `json:"job_id,omitempty"`
|
||||||
|
FailedReason string `json:"failed_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fleetUpdateView is the JSON projection of the parent + children.
|
||||||
|
type fleetUpdateView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
StartedByUserID string `json:"started_by_user_id"`
|
||||||
|
TargetVersion string `json:"target_version"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CurrentHostID string `json:"current_host_id,omitempty"`
|
||||||
|
HaltedReason string `json:"halted_reason,omitempty"`
|
||||||
|
CompletedAt *string `json:"completed_at,omitempty"`
|
||||||
|
Hosts []fleetUpdateHostView `json:"hosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fleetUpdatePage backs both the full /settings/fleet-update page
|
||||||
|
// and the partial polled fragment. Idle / Active are mutually
|
||||||
|
// exclusive: if Active is non-nil, render the progress view.
|
||||||
|
type fleetUpdatePage struct {
|
||||||
|
// Idle-state fields.
|
||||||
|
OutOfDateHosts []store.Host // online hosts whose version != target
|
||||||
|
TargetVersion string
|
||||||
|
|
||||||
|
// Active-state fields. Nil when no fleet update has ever run.
|
||||||
|
Active *store.FleetUpdate
|
||||||
|
ActiveRows []fleetUpdateHostView
|
||||||
|
|
||||||
|
// Common.
|
||||||
|
HostNames map[string]string
|
||||||
|
// PollURL is the partial endpoint htmx polls every few seconds.
|
||||||
|
PollURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIFleetUpdateStart is POST /api/fleet/update.
|
||||||
|
func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
user, ok := s.requireUser(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.deps.FleetWorker == nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusServiceUnavailable, "fleet_worker_unavailable", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body fleetUpdateStartReq
|
||||||
|
// Empty body is fine — both fields are optional.
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target := body.TargetVersion
|
||||||
|
if target == "" {
|
||||||
|
target = version.Version
|
||||||
|
}
|
||||||
|
hostIDs := body.HostIDs
|
||||||
|
if len(hostIDs) == 0 {
|
||||||
|
derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostIDs = derived
|
||||||
|
}
|
||||||
|
if len(hostIDs) == 0 {
|
||||||
|
writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible",
|
||||||
|
"no online hosts are out of date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fuID, err := s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrFleetUpdateRunning) {
|
||||||
|
writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auditPayload, _ := json.Marshal(map[string]any{
|
||||||
|
"fleet_update_id": fuID,
|
||||||
|
"target_version": target,
|
||||||
|
"host_count": len(hostIDs),
|
||||||
|
})
|
||||||
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||||
|
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
|
||||||
|
Action: "fleet.update_started",
|
||||||
|
TargetKind: ptr("fleet_update"), TargetID: &fuID,
|
||||||
|
TS: time.Now().UTC(),
|
||||||
|
Payload: auditPayload,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"fleet_update_id": fuID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIFleetUpdateCancel is POST /api/fleet-updates/{id}/cancel.
|
||||||
|
func (s *Server) handleAPIFleetUpdateCancel(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
user, ok := s.requireUser(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.deps.FleetWorker == nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusServiceUnavailable, "fleet_worker_unavailable", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fuID := chi.URLParam(r, "id")
|
||||||
|
if fuID == "" {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fu, _, err := s.deps.Store.GetFleetUpdate(r.Context(), fuID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "fleet_update_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fu.Status != "running" {
|
||||||
|
writeJSONError(w, stdhttp.StatusConflict, "fleet_update_not_running",
|
||||||
|
"fleet update is not in the running state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deps.FleetWorker.Cancel(r.Context(), fuID); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||||
|
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
|
||||||
|
Action: "fleet.update_cancelled",
|
||||||
|
TargetKind: ptr("fleet_update"), TargetID: &fuID,
|
||||||
|
TS: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
w.WriteHeader(stdhttp.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIFleetUpdateGet is GET /api/fleet-updates/{id}.
|
||||||
|
func (s *Server) handleAPIFleetUpdateGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if _, ok := s.requireUser(r); !ok {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fuID := chi.URLParam(r, "id")
|
||||||
|
fu, hosts, err := s.deps.Store.GetFleetUpdate(r.Context(), fuID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "fleet_update_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := s.hostNameMap(r)
|
||||||
|
view := fleetUpdateView{
|
||||||
|
ID: fu.ID,
|
||||||
|
StartedAt: fu.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
StartedByUserID: fu.StartedByUserID,
|
||||||
|
TargetVersion: fu.TargetVersion,
|
||||||
|
Status: fu.Status,
|
||||||
|
CurrentHostID: fu.CurrentHostID,
|
||||||
|
HaltedReason: fu.HaltedReason,
|
||||||
|
Hosts: make([]fleetUpdateHostView, 0, len(hosts)),
|
||||||
|
}
|
||||||
|
if fu.CompletedAt != nil {
|
||||||
|
s := fu.CompletedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
view.CompletedAt = &s
|
||||||
|
}
|
||||||
|
for _, h := range hosts {
|
||||||
|
view.Hosts = append(view.Hosts, fleetUpdateHostView{
|
||||||
|
HostID: h.HostID,
|
||||||
|
HostName: names[h.HostID],
|
||||||
|
Position: h.Position,
|
||||||
|
Status: h.Status,
|
||||||
|
JobID: h.JobID,
|
||||||
|
FailedReason: h.FailedReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIFleetUpdate renders /settings/fleet-update.
|
||||||
|
func (s *Server) handleUIFleetUpdate(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
u := s.requireUIUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := s.buildFleetUpdatePage(r)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ui fleet update: build page", "err", err)
|
||||||
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := s.baseView(r, u)
|
||||||
|
view.Title = "Fleet update · restic-manager"
|
||||||
|
view.Active = "settings"
|
||||||
|
view.Page = page
|
||||||
|
if err := s.deps.UI.Render(w, "fleet_update", view); err != nil {
|
||||||
|
slog.Error("ui fleet update: render", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIFleetUpdatePartial renders just the inner panel for htmx
|
||||||
|
// auto-refresh polling — same data, no chrome.
|
||||||
|
func (s *Server) handleUIFleetUpdatePartial(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
u := s.requireUIUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := s.buildFleetUpdatePage(r)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ui fleet update partial: build page", "err", err)
|
||||||
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := s.baseView(r, u)
|
||||||
|
view.Page = page
|
||||||
|
if err := s.deps.UI.RenderPartial(w, "fleet_update_inner", view); err != nil {
|
||||||
|
slog.Error("ui fleet update partial: render", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFleetUpdatePage assembles the data both /settings/fleet-update
|
||||||
|
// and its partial render against. Resolves the most-recent fleet
|
||||||
|
// update (active OR completed/cancelled/halted) so the page can show
|
||||||
|
// the last roll's result instead of disappearing into "idle" the
|
||||||
|
// instant a roll finishes.
|
||||||
|
func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, error) {
|
||||||
|
page := fleetUpdatePage{
|
||||||
|
TargetVersion: version.Version,
|
||||||
|
HostNames: map[string]string{},
|
||||||
|
PollURL: "/settings/fleet-update/partial",
|
||||||
|
}
|
||||||
|
hosts, err := s.deps.Store.ListHosts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
for _, h := range hosts {
|
||||||
|
page.HostNames[h.ID] = h.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := s.deps.Store.ActiveFleetUpdate(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
mostRecent := active
|
||||||
|
if mostRecent == nil {
|
||||||
|
// Fall back to the most recent terminal row so the page can
|
||||||
|
// show "completed" / "halted" / "cancelled" once the worker
|
||||||
|
// finishes. One small bespoke query — keeps the page from
|
||||||
|
// flashing back to "idle" the instant a roll wraps up.
|
||||||
|
var id string
|
||||||
|
err := s.deps.Store.DB().QueryRowContext(r.Context(),
|
||||||
|
`SELECT id FROM fleet_updates ORDER BY started_at DESC LIMIT 1`).
|
||||||
|
Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
fu, _, gerr := s.deps.Store.GetFleetUpdate(r.Context(), id)
|
||||||
|
if gerr == nil {
|
||||||
|
mostRecent = fu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mostRecent != nil {
|
||||||
|
_, rows, gerr := s.deps.Store.GetFleetUpdate(r.Context(), mostRecent.ID)
|
||||||
|
if gerr == nil {
|
||||||
|
page.Active = mostRecent
|
||||||
|
page.ActiveRows = make([]fleetUpdateHostView, 0, len(rows))
|
||||||
|
for _, hr := range rows {
|
||||||
|
page.ActiveRows = append(page.ActiveRows, fleetUpdateHostView{
|
||||||
|
HostID: hr.HostID,
|
||||||
|
HostName: page.HostNames[hr.HostID],
|
||||||
|
Position: hr.Position,
|
||||||
|
Status: hr.Status,
|
||||||
|
JobID: hr.JobID,
|
||||||
|
FailedReason: hr.FailedReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveOutOfDateOnlineHostIDs returns the list of host IDs that
|
||||||
|
// (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
|
||||||
|
// omits host_ids.
|
||||||
|
func (s *Server) deriveOutOfDateOnlineHostIDs(ctx context.Context, target string) ([]string, error) {
|
||||||
|
hosts, err := s.deps.Store.ListHosts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, h := range hosts {
|
||||||
|
if h.AgentVersion == "" || h.AgentVersion == target {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !s.deps.Hub.Connected(h.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, h.ID)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostNameMap returns hostID → name; used to hydrate fleet-update
|
||||||
|
// JSON responses.
|
||||||
|
func (s *Server) hostNameMap(r *stdhttp.Request) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
hosts, err := s.deps.Store.ListHosts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, h := range hosts {
|
||||||
|
out[h.ID] = h.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// fleet_update_test.go — coverage for the P6-15 fleet-update HTTP
|
||||||
|
// surface: start/cancel/get JSON endpoints + RBAC.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeFleetWorker stands in for *fleetupdate.Worker in HTTP tests.
|
||||||
|
// It records what was passed to Start/Cancel and lets tests inject
|
||||||
|
// canned errors. Satisfies the FleetWorker interface in
|
||||||
|
// host_update.go.
|
||||||
|
type fakeFleetWorker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
startCalls []fakeStartCall
|
||||||
|
startID string
|
||||||
|
startErr error
|
||||||
|
|
||||||
|
cancelCalls []string
|
||||||
|
cancelErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStartCall struct {
|
||||||
|
UserID string
|
||||||
|
Target string
|
||||||
|
HostIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFleetWorker) Start(_ context.Context, userID, target string, hostIDs []string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.startCalls = append(f.startCalls, fakeStartCall{userID, target, append([]string(nil), hostIDs...)})
|
||||||
|
if f.startErr != nil {
|
||||||
|
return "", f.startErr
|
||||||
|
}
|
||||||
|
return f.startID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFleetWorker) Cancel(_ context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.cancelCalls = append(f.cancelCalls, id)
|
||||||
|
return f.cancelErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// helloOnlineHost is the smallest setup that lets the dispatch /
|
||||||
|
// derivation logic see a host as "online + version mismatch".
|
||||||
|
// Returns the host id.
|
||||||
|
func helloOnlineHost(t *testing.T, srv *Server, st *store.Store, name, agentVer string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := makeHost(t, st, name)
|
||||||
|
if err := st.MarkHostHello(context.Background(), id, agentVer, "0.17", api.CurrentProtocolVersion, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("mark hello: %v", err)
|
||||||
|
}
|
||||||
|
// Mark connected on the hub so deriveOutOfDateOnlineHostIDs
|
||||||
|
// considers it online without needing a real WS handshake. The
|
||||||
|
// Conn has a nil websocket pointer — tests never call Send on it.
|
||||||
|
srv.deps.Hub.Register(id, ws.NewConn(id, nil))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateStartHappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, st := rawTestServer(t)
|
||||||
|
worker := &fakeFleetWorker{startID: ulid.Make().String()}
|
||||||
|
srv.deps.FleetWorker = worker
|
||||||
|
|
||||||
|
cookie, uid := loginAsAdminWithID(t, st)
|
||||||
|
hostID := helloOnlineHost(t, srv, st, "fu-host", "v0")
|
||||||
|
|
||||||
|
body := map[string]any{"host_ids": []string{hostID}}
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
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: got %d, want 202", res.StatusCode)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
FleetUpdateID string `json:"fleet_update_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if out.FleetUpdateID != worker.startID {
|
||||||
|
t.Fatalf("fleet_update_id: got %q, want %q", out.FleetUpdateID, worker.startID)
|
||||||
|
}
|
||||||
|
worker.mu.Lock()
|
||||||
|
if len(worker.startCalls) != 1 || worker.startCalls[0].UserID != uid {
|
||||||
|
t.Fatalf("start calls: %+v", worker.startCalls)
|
||||||
|
}
|
||||||
|
if got := worker.startCalls[0].HostIDs; len(got) != 1 || got[0] != hostID {
|
||||||
|
t.Fatalf("host_ids: %v", got)
|
||||||
|
}
|
||||||
|
worker.mu.Unlock()
|
||||||
|
|
||||||
|
// Audit row.
|
||||||
|
var n int
|
||||||
|
if err := st.DB().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM audit_log WHERE action = 'fleet.update_started' AND target_id = ?`,
|
||||||
|
out.FleetUpdateID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("audit count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("audit rows: got %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateStartConflictWhenAlreadyRunning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, st := rawTestServer(t)
|
||||||
|
worker := &fakeFleetWorker{startErr: store.ErrFleetUpdateRunning}
|
||||||
|
srv.deps.FleetWorker = worker
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
_ = helloOnlineHost(t, srv, st, "fu-host", "v0")
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader([]byte(`{}`)))
|
||||||
|
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.StatusConflict {
|
||||||
|
t.Fatalf("status: got %d, want 409", res.StatusCode)
|
||||||
|
}
|
||||||
|
body := readJSONError(t, res.Body)
|
||||||
|
if body.Code != "fleet_update_in_progress" {
|
||||||
|
t.Fatalf("code: %q", body.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateStartDerivesHostIDsWhenEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, st := rawTestServer(t)
|
||||||
|
worker := &fakeFleetWorker{startID: ulid.Make().String()}
|
||||||
|
srv.deps.FleetWorker = worker
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
|
||||||
|
// Two online + out-of-date, one online + at-target, one offline.
|
||||||
|
a := helloOnlineHost(t, srv, st, "behind-a", "v0")
|
||||||
|
b := helloOnlineHost(t, srv, st, "behind-b", "v0")
|
||||||
|
_ = helloOnlineHost(t, srv, st, "uptodate", version.Version)
|
||||||
|
offlineID := makeHost(t, st, "offline-host")
|
||||||
|
if err := st.MarkHostHello(context.Background(), offlineID, "v0", "0.17", api.CurrentProtocolVersion, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("mark hello: %v", err)
|
||||||
|
}
|
||||||
|
// Don't MarkOnline → derivation should skip.
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader([]byte(`{}`)))
|
||||||
|
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: got %d, want 202", res.StatusCode)
|
||||||
|
}
|
||||||
|
worker.mu.Lock()
|
||||||
|
defer worker.mu.Unlock()
|
||||||
|
if len(worker.startCalls) != 1 {
|
||||||
|
t.Fatalf("start calls: %d", len(worker.startCalls))
|
||||||
|
}
|
||||||
|
got := worker.startCalls[0].HostIDs
|
||||||
|
want := map[string]bool{a: true, b: true}
|
||||||
|
if len(got) != 2 || !want[got[0]] || !want[got[1]] {
|
||||||
|
t.Fatalf("derived host_ids: got %v, want both of %v", got, []string{a, b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateCancelHappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, st := rawTestServer(t)
|
||||||
|
worker := &fakeFleetWorker{}
|
||||||
|
srv.deps.FleetWorker = worker
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
|
||||||
|
// Seed a running fleet update directly.
|
||||||
|
fuID := ulid.Make().String()
|
||||||
|
uid := ulid.Make().String()
|
||||||
|
if err := st.CreateUser(context.Background(), store.User{
|
||||||
|
ID: uid, Username: "starter", PasswordHash: "x",
|
||||||
|
Role: store.RoleAdmin, CreatedAt: time.Now().UTC(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
hostID := makeHost(t, st, "fu-cancel-host")
|
||||||
|
if err := st.CreateFleetUpdate(context.Background(),
|
||||||
|
store.FleetUpdate{ID: fuID, StartedByUserID: uid, TargetVersion: "v1"},
|
||||||
|
[]string{hostID}); err != nil {
|
||||||
|
t.Fatalf("seed fleet update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet-updates/"+fuID+"/cancel", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
res, err := stdhttp.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("do: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != stdhttp.StatusNoContent {
|
||||||
|
t.Fatalf("status: got %d, want 204", res.StatusCode)
|
||||||
|
}
|
||||||
|
worker.mu.Lock()
|
||||||
|
if len(worker.cancelCalls) != 1 || worker.cancelCalls[0] != fuID {
|
||||||
|
t.Fatalf("cancel calls: %v", worker.cancelCalls)
|
||||||
|
}
|
||||||
|
worker.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateCancelNotRunning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, st := rawTestServer(t)
|
||||||
|
srv.deps.FleetWorker = &fakeFleetWorker{}
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
|
||||||
|
// Seed + complete one so it's no longer running.
|
||||||
|
fuID := ulid.Make().String()
|
||||||
|
uid := ulid.Make().String()
|
||||||
|
_ = st.CreateUser(context.Background(), store.User{
|
||||||
|
ID: uid, Username: "starter2", PasswordHash: "x",
|
||||||
|
Role: store.RoleAdmin, CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
hostID := makeHost(t, st, "fu-done-host")
|
||||||
|
_ = st.CreateFleetUpdate(context.Background(),
|
||||||
|
store.FleetUpdate{ID: fuID, StartedByUserID: uid, TargetVersion: "v1"},
|
||||||
|
[]string{hostID})
|
||||||
|
if err := st.CompleteFleetUpdate(context.Background(), fuID, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet-updates/"+fuID+"/cancel", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
res, err := stdhttp.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("do: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != stdhttp.StatusConflict {
|
||||||
|
t.Fatalf("status: got %d, want 409", res.StatusCode)
|
||||||
|
}
|
||||||
|
body := readJSONError(t, res.Body)
|
||||||
|
if body.Code != "fleet_update_not_running" {
|
||||||
|
t.Fatalf("code: %q", body.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateGetHydrates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ts, st := rawTestServer(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
|
||||||
|
uid := ulid.Make().String()
|
||||||
|
_ = st.CreateUser(context.Background(), store.User{
|
||||||
|
ID: uid, Username: "starter3", PasswordHash: "x",
|
||||||
|
Role: store.RoleAdmin, CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
hostID := makeHost(t, st, "fu-get-host")
|
||||||
|
fuID := ulid.Make().String()
|
||||||
|
if err := st.CreateFleetUpdate(context.Background(),
|
||||||
|
store.FleetUpdate{ID: fuID, StartedByUserID: uid, TargetVersion: "v1.2.3"},
|
||||||
|
[]string{hostID}); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/fleet-updates/"+fuID, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
res, err := stdhttp.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("do: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != stdhttp.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want 200", res.StatusCode)
|
||||||
|
}
|
||||||
|
var got fleetUpdateView
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != fuID || got.TargetVersion != "v1.2.3" || got.Status != "running" {
|
||||||
|
t.Fatalf("parent: %+v", got)
|
||||||
|
}
|
||||||
|
if len(got.Hosts) != 1 || got.Hosts[0].HostID != hostID || got.Hosts[0].HostName != "fu-get-host" {
|
||||||
|
t.Fatalf("hosts: %+v", got.Hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetUpdateRBAC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ts, st := rawTestServer(t)
|
||||||
|
|
||||||
|
for _, role := range []store.Role{store.RoleViewer, store.RoleOperator} {
|
||||||
|
role := role
|
||||||
|
t.Run(string(role), func(t *testing.T) {
|
||||||
|
cookie := loginAsRole(t, st, role)
|
||||||
|
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader([]byte(`{}`)))
|
||||||
|
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.StatusForbidden {
|
||||||
|
t.Fatalf("status: got %d, want 403", res.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check that fakeFleetWorker satisfies the FleetWorker iface.
|
||||||
|
var _ FleetWorker = (*fakeFleetWorker)(nil)
|
||||||
@@ -285,6 +285,11 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Post("/api/hosts/{id}/update", s.handleHostUpdate)
|
r.Post("/api/hosts/{id}/update", s.handleHostUpdate)
|
||||||
r.Post("/hosts/{id}/update", s.handleHostUpdateForm)
|
r.Post("/hosts/{id}/update", s.handleHostUpdateForm)
|
||||||
|
|
||||||
|
// Fleet update (P6-15): rolling update across many hosts.
|
||||||
|
r.Post("/api/fleet/update", s.handleAPIFleetUpdateStart)
|
||||||
|
r.Post("/api/fleet-updates/{id}/cancel", s.handleAPIFleetUpdateCancel)
|
||||||
|
r.Get("/api/fleet-updates/{id}", s.handleAPIFleetUpdateGet)
|
||||||
|
|
||||||
r.Get("/api/users", s.handleAPIUsersList)
|
r.Get("/api/users", s.handleAPIUsersList)
|
||||||
r.Post("/api/users", s.handleAPIUserCreate)
|
r.Post("/api/users", s.handleAPIUserCreate)
|
||||||
r.Get("/api/users/{id}", s.handleAPIUserGet)
|
r.Get("/api/users/{id}", s.handleAPIUserGet)
|
||||||
@@ -298,6 +303,8 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
if s.deps.UI != nil {
|
if s.deps.UI != nil {
|
||||||
r.Post("/hosts/{id}/delete", s.handleUIHostDelete)
|
r.Post("/hosts/{id}/delete", s.handleUIHostDelete)
|
||||||
r.Get("/settings", s.handleUISettings)
|
r.Get("/settings", s.handleUISettings)
|
||||||
|
r.Get("/settings/fleet-update", s.handleUIFleetUpdate)
|
||||||
|
r.Get("/settings/fleet-update/partial", s.handleUIFleetUpdatePartial)
|
||||||
r.Get("/settings/users", s.handleUIUsersList)
|
r.Get("/settings/users", s.handleUIUsersList)
|
||||||
r.Get("/settings/users/new", s.handleUIUserNewGet)
|
r.Get("/settings/users/new", s.handleUIUserNewGet)
|
||||||
r.Post("/settings/users/new", s.handleUIUserNewPost)
|
r.Post("/settings/users/new", s.handleUIUserNewPost)
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ func New() (*Renderer, error) {
|
|||||||
"templates/partials/tree_node.html",
|
"templates/partials/tree_node.html",
|
||||||
"templates/partials/alert_row.html",
|
"templates/partials/alert_row.html",
|
||||||
"templates/partials/crit_banner.html",
|
"templates/partials/crit_banner.html",
|
||||||
|
"templates/partials/fleet_update_inner.html",
|
||||||
|
"templates/partials/host_update_chip.html",
|
||||||
}
|
}
|
||||||
|
|
||||||
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
|
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "title"}}Fleet update · restic-manager{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[1280px] mx-auto px-8 pb-14">
|
||||||
|
|
||||||
|
{{/* breadcrumbs */}}
|
||||||
|
<div class="crumbs pt-6">
|
||||||
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||||
|
<a href="/settings">Settings</a><span class="sep">/</span>
|
||||||
|
<span class="text-ink-mid">fleet update</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* page header */}}
|
||||||
|
<div class="flex items-baseline justify-between mt-3.5">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
|
||||||
|
Fleet update
|
||||||
|
<span class="text-ink-fade font-normal text-[14px] ml-2 mono">target {{$page.TargetVersion}}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-ink-mute text-[12px] mt-1 max-w-[760px] leading-[1.55]">
|
||||||
|
Rolling, sequential agent self-update. One host at a time, halts on first failure,
|
||||||
|
cancellable mid-roll. Only online hosts whose <span class="mono">agent_version</span>
|
||||||
|
differs from the server are eligible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "fleet_update_inner" .}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{{/*
|
||||||
|
fleet_update_inner — inner panel for /settings/fleet-update.
|
||||||
|
Rendered both as part of the full page and as the htmx polling
|
||||||
|
fragment via /settings/fleet-update/partial.
|
||||||
|
|
||||||
|
Expects .Page to be a fleetUpdatePage struct (see fleet_update.go).
|
||||||
|
*/}}
|
||||||
|
{{define "fleet_update_inner"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div id="fleet-update-panel" class="mt-5"
|
||||||
|
hx-get="{{$page.PollURL}}"
|
||||||
|
hx-trigger="every 3s [document.visibilityState==='visible']"
|
||||||
|
hx-select="#fleet-update-panel"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
|
||||||
|
{{if and $page.Active (eq $page.Active.Status "running")}}
|
||||||
|
|
||||||
|
{{/* ---------- running state ---------- */}}
|
||||||
|
<div class="panel rounded-[7px] px-5 py-4">
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="mono text-[12px] text-ink-fade">fleet update</span>
|
||||||
|
<span class="mono text-[12px] text-accent ml-2">running</span>
|
||||||
|
<span class="mono text-[11px] text-ink-fade ml-2">{{$page.Active.ID}}</span>
|
||||||
|
</div>
|
||||||
|
<form hx-post="/api/fleet-updates/{{$page.Active.ID}}/cancel" hx-swap="none">
|
||||||
|
<button class="btn btn-danger" type="submit"
|
||||||
|
onclick="return confirm('Cancel this fleet update? Hosts already updated stay updated; pending hosts will be skipped.');">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11.5px] text-ink-mute mt-1">
|
||||||
|
target <span class="mono text-ink-mid">{{$page.Active.TargetVersion}}</span>
|
||||||
|
· started <span class="mono text-ink-mid">{{relTime $page.Active.StartedAt}}</span>
|
||||||
|
{{if $page.Active.CurrentHostID}}
|
||||||
|
· waiting on <span class="mono text-ink-mid">{{index $page.HostNames $page.Active.CurrentHostID}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "fleet_update_rows" $page}}
|
||||||
|
|
||||||
|
{{else if $page.Active}}
|
||||||
|
|
||||||
|
{{/* ---------- terminal state (completed / halted / cancelled) ---------- */}}
|
||||||
|
<div class="panel rounded-[7px] px-5 py-4">
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="mono text-[12px] text-ink-fade">last fleet update</span>
|
||||||
|
{{if eq $page.Active.Status "completed"}}
|
||||||
|
<span class="mono text-[12px] text-ok ml-2">completed</span>
|
||||||
|
{{else if eq $page.Active.Status "halted"}}
|
||||||
|
<span class="mono text-[12px] text-bad ml-2">halted</span>
|
||||||
|
{{else if eq $page.Active.Status "cancelled"}}
|
||||||
|
<span class="mono text-[12px] text-warn ml-2">cancelled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="mono text-[12px] text-ink-mid ml-2">{{$page.Active.Status}}</span>
|
||||||
|
{{end}}
|
||||||
|
<span class="mono text-[11px] text-ink-fade ml-2">{{$page.Active.ID}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11.5px] text-ink-mute mt-1">
|
||||||
|
target <span class="mono text-ink-mid">{{$page.Active.TargetVersion}}</span>
|
||||||
|
· started <span class="mono text-ink-mid">{{relTime $page.Active.StartedAt}}</span>
|
||||||
|
{{if $page.Active.CompletedAt}} · finished <span class="mono text-ink-mid">{{relTime $page.Active.CompletedAt}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if $page.Active.HaltedReason}}
|
||||||
|
<div class="text-[12px] text-bad mt-2">{{$page.Active.HaltedReason}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "fleet_update_rows" $page}}
|
||||||
|
|
||||||
|
{{if gt (len $page.OutOfDateHosts) 0}}
|
||||||
|
<div class="mt-5">
|
||||||
|
{{template "fleet_update_idle_panel" $page}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
{{template "fleet_update_idle_panel" $page}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "fleet_update_rows"}}
|
||||||
|
{{$page := .}}
|
||||||
|
<div class="panel mt-3 rounded-[7px] overflow-hidden">
|
||||||
|
<div class="hairline grid items-baseline px-4 py-2.5 text-[11px] text-ink-fade uppercase tracking-[0.08em]"
|
||||||
|
style="grid-template-columns: 0.4fr 1.5fr 0.8fr 1.2fr 1.5fr; column-gap: 18px;">
|
||||||
|
<div>#</div>
|
||||||
|
<div>Host</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Job</div>
|
||||||
|
<div>Detail</div>
|
||||||
|
</div>
|
||||||
|
{{range $page.ActiveRows}}
|
||||||
|
<div class="grid items-center px-4 py-2.5 text-[12.5px] hairline"
|
||||||
|
style="grid-template-columns: 0.4fr 1.5fr 0.8fr 1.2fr 1.5fr; column-gap: 18px;">
|
||||||
|
<div class="mono text-ink-fade">{{.Position}}</div>
|
||||||
|
<div class="mono text-ink">{{if .HostName}}{{.HostName}}{{else}}{{.HostID}}{{end}}</div>
|
||||||
|
<div>
|
||||||
|
{{if eq .Status "pending"}}<span class="text-ink-fade">pending</span>
|
||||||
|
{{else if eq .Status "running"}}<span class="text-accent">running…</span>
|
||||||
|
{{else if eq .Status "succeeded"}}<span class="text-ok">succeeded</span>
|
||||||
|
{{else if eq .Status "failed"}}<span class="text-bad font-medium">failed</span>
|
||||||
|
{{else if eq .Status "skipped"}}<span class="text-ink-mute">skipped</span>
|
||||||
|
{{else}}<span class="text-ink-mute">{{.Status}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{if .JobID}}<a class="link mono text-[11.5px]" href="/jobs/{{.JobID}}">{{.JobID}}</a>{{else}}<span class="text-ink-fade">—</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="mono text-[11.5px] text-ink-mute truncate" title="{{.FailedReason}}">{{.FailedReason}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "fleet_update_idle_panel"}}
|
||||||
|
{{$page := .}}
|
||||||
|
<div class="panel rounded-[7px] px-5 py-4">
|
||||||
|
{{if eq (len $page.OutOfDateHosts) 0}}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="dot dot-online"></span>
|
||||||
|
<div>
|
||||||
|
<div class="text-ink text-[14px] font-medium">All hosts are up to date.</div>
|
||||||
|
<div class="text-ink-mute text-[12px] mt-0.5">
|
||||||
|
Every online agent matches server version <span class="mono">{{$page.TargetVersion}}</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-3 space-y-1 text-[12px]">
|
||||||
|
{{range $page.OutOfDateHosts}}
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<span class="dot dot-online"></span>
|
||||||
|
<span class="mono text-ink">{{.Name}}</span>
|
||||||
|
<span class="mono text-ink-mute">{{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}} → {{$page.TargetVersion}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form id="fleet-update-start-form" class="mt-4 flex items-center gap-3"
|
||||||
|
hx-post="/api/fleet/update"
|
||||||
|
hx-headers='{"Content-Type":"application/json"}'
|
||||||
|
hx-vals='{}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful) location.reload()">
|
||||||
|
<label class="text-[11.5px] text-ink-mute">
|
||||||
|
Type the count
|
||||||
|
<span class="mono text-ink-mid">({{len $page.OutOfDateHosts}})</span>
|
||||||
|
to enable Start:
|
||||||
|
</label>
|
||||||
|
<input type="text" id="fleet-update-confirm" class="field mono text-[12.5px]"
|
||||||
|
style="width: 80px; padding: 5px 8px;"
|
||||||
|
oninput="document.getElementById('fleet-update-start-btn').disabled = (this.value !== '{{len $page.OutOfDateHosts}}');"
|
||||||
|
autocomplete="off" />
|
||||||
|
<button type="submit" id="fleet-update-start-btn" class="btn btn-amber" disabled>
|
||||||
|
Start fleet update
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user