feat(hosts): per-host tags edit + dashboard chip-row filter (P4-07)

This commit is contained in:
2026-05-05 11:16:09 +01:00
parent c1426110e5
commit 168059ae45
8 changed files with 183 additions and 7 deletions
+1
View File
@@ -238,6 +238,7 @@ func (s *Server) routes(r chi.Router) {
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
r.Post("/hosts/{id}/tags", s.handleUIHostTagsSave)
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
+86 -3
View File
@@ -13,6 +13,7 @@ import (
"github.com/coder/websocket"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
@@ -127,10 +128,16 @@ func (s *Server) version() string {
// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
Hosts []dashboardHostRow
HostCount int
HostCount int // unfiltered fleet size
ShownCount int // after the tag filter (== HostCount when no filter)
Summary store.FleetSummary
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
CritOpenCount int
// Tag filter state. ActiveTag is the chip currently selected
// ("" = all). KnownTags is the full set of tags in use across
// the fleet, used to render the chip-row.
ActiveTag string
KnownTags []string
}
// dashboardHostRow carries a host plus the per-row Run-now decision
@@ -197,12 +204,29 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
return
}
hosts, err := s.deps.Store.ListHosts(r.Context())
allHosts, err := s.deps.Store.ListHosts(r.Context())
if err != nil {
slog.Error("ui dashboard: list hosts", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// Tag filter (chip-row above the table). Empty = show all.
activeTag := r.URL.Query().Get("tag")
hosts := allHosts
if activeTag != "" {
filtered := make([]store.Host, 0, len(allHosts))
for _, h := range allHosts {
for _, t := range h.Tags {
if t == activeTag {
filtered = append(filtered, h)
break
}
}
}
hosts = filtered
}
knownTags, _ := s.deps.Store.DistinctHostTags(r.Context())
summary, err := s.deps.Store.FleetSummary(r.Context())
if err != nil {
slog.Error("ui dashboard: fleet summary", "err", err)
@@ -252,10 +276,13 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
view := s.baseView(r, u)
view.Page = dashboardPage{
Hosts: rows,
HostCount: len(hosts),
HostCount: len(allHosts),
ShownCount: len(rows),
Summary: summary,
PendingHosts: pending,
CritOpenCount: critOpenCount,
ActiveTag: activeTag,
KnownTags: knownTags,
}
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err)
@@ -529,6 +556,9 @@ type hostChromeData struct {
SourceGroupCount int
ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired)
// KnownTags is the union of tags already in use across the fleet,
// used for autocomplete on the host-tags edit form. Cheap query.
KnownTags []string
// Auto-init status surfaced from the latest 'init' job.
// InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run).
@@ -582,9 +612,62 @@ func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, cru
}
d.RestoreAt = &t
}
if tags, err := s.deps.Store.DistinctHostTags(r.Context()); err == nil {
d.KnownTags = tags
}
return d
}
// handleUIHostTagsSave accepts a comma-separated tag list, normalises,
// dedups, and writes. Operator-band; mounted in server.go.
func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
stdhttp.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
raw := r.PostForm.Get("tags")
tags := normaliseTags(raw)
if err := s.deps.Store.SetHostTags(r.Context(), hostID, tags); err != nil {
slog.Error("ui host tags: save", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "host.tags_updated",
TargetKind: ptr("host"), TargetID: &hostID,
TS: time.Now().UTC(),
})
stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
}
// normaliseTags splits a comma-separated string, lowercases each token,
// trims whitespace, drops empties, and dedupes. Order is preserved
// from first occurrence (so the user's typing order shows on screen).
func normaliseTags(raw string) []string {
parts := strings.Split(raw, ",")
seen := make(map[string]bool, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.ToLower(strings.TrimSpace(p))
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
hostChromeData
+43
View File
@@ -299,6 +299,49 @@ func (s *Store) SetHostBandwidth(ctx context.Context, hostID string, upKBps, dow
return nil
}
// SetHostTags replaces the host's tag list. Tags are passed already
// normalised (lowercase, deduped) by the caller — store-layer just
// JSON-marshals and writes. Empty slice clears all tags.
func (s *Store) SetHostTags(ctx context.Context, hostID string, tags []string) error {
if tags == nil {
tags = []string{}
}
b, err := json.Marshal(tags)
if err != nil {
return fmt.Errorf("store: marshal tags: %w", err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE hosts SET tags = ? WHERE id = ?`, string(b), hostID)
if err != nil {
return fmt.Errorf("store: set host tags: %w", err)
}
return nil
}
// DistinctHostTags returns the union of every tag in use across the
// fleet, sorted. Powers the autocomplete on the host-tags editor and
// the chip-row filter on the dashboard. Cheap at fleet sizes this
// codebase targets — re-query on each render is fine.
func (s *Store) DistinctHostTags(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT json_each.value
FROM hosts, json_each(hosts.tags)
ORDER BY 1`)
if err != nil {
return nil, fmt.Errorf("store: distinct host tags: %w", err)
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func nullableInt(p *int) any {
if p == nil {
return nil