feat(hosts): per-host tags edit + dashboard chip-row filter (P4-07)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user