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
+3 -1
View File
@@ -309,7 +309,9 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
>
> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green.
- [ ] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
- [ ] **P4-07** (S) Per-host tags + dashboard filtering by tag
- [x] **P4-07** (S) Per-host tags + dashboard filtering by tag
> **As shipped (2026-05-05):** Tag column already existed on the hosts schema (JSON array, round-tripped through the Host struct since Phase 1) but had no edit UI or filter. Added `Store.SetHostTags` + `Store.DistinctHostTags` (the latter via `json_each` for autocomplete + chip-row population). Inline editor on the host detail header: `+ tag` button reveals a comma-separated input with `<datalist>` autocomplete from the fleet's distinct tags; submit lowercases / trims / dedupes server-side. Tag chips on the host header link to the dashboard pre-filtered. Dashboard chip-row above the hosts table — `All / <tag1> / <tag2> …` with the active chip highlighted via a new `.tag-active` style; `?tag=foo` filters the list with the count showing `N of M`. Operator-band POST `/hosts/{id}/tags` audited as `host.tags_updated`.
### Phase 4 acceptance
File diff suppressed because one or more lines are too long
+10
View File
@@ -306,6 +306,16 @@
.dot-critical { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in oklch, var(--bad), transparent 80%); }
.dot-resolved { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ok), transparent 80%); }
/* Tag in active/selected state used by the dashboard chip-row
filter and any other UI that wants a "this tag is currently
applied" highlight. Subtle: slight accent tint, accent border,
ink colour shift; doesn't shout. */
.tag.tag-active {
color: var(--accent);
border-color: color-mix(in oklch, var(--accent), transparent 50%);
background: color-mix(in oklch, var(--accent), transparent 92%);
}
/* tag colour variants for alerts */
.tag-warn { color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%); }
.tag-critical { color: var(--bad); border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); }
+16 -1
View File
@@ -125,10 +125,25 @@
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<h2 class="text-[13px] font-semibold tracking-[0.01em]">Hosts</h2>
<div class="text-xs text-ink-fade">{{$page.HostCount}} of {{$page.HostCount}}</div>
<div class="text-xs text-ink-fade">{{$page.ShownCount}} of {{$page.HostCount}}</div>
</div>
</div>
{{/* Tag chip-row — only renders when at least one tag exists in
the fleet. Active tag is highlighted; clicking the active
tag clears the filter. The "All" pill is shown in the active
state when no tag filter is set. */}}
{{if $page.KnownTags}}
<div class="flex items-center gap-1.5 flex-wrap mb-3 text-[11.5px]">
<span class="text-ink-fade mr-1">filter</span>
<a href="/" class="tag {{if eq $page.ActiveTag ""}}tag-active{{end}}">All</a>
{{range $page.KnownTags}}
{{$t := .}}
<a href="/?tag={{$t}}" class="tag {{if eq $page.ActiveTag $t}}tag-active{{end}}">{{$t}}</a>
{{end}}
</div>
{{end}}
<div class="panel rounded-[7px] overflow-hidden">
<div class="host-row head hairline">
+23 -1
View File
@@ -39,7 +39,13 @@
<span class="dot dot-failed"></span>
{{end}}
<h1 class="mono text-[26px] font-medium tracking-[0.005em] text-ink">{{$host.Name}}</h1>
<div class="flex gap-1.5">{{range $host.Tags}}<span class="tag">{{.}}</span>{{end}}</div>
<div class="flex gap-1.5 items-center">
{{range $host.Tags}}<a href="/?tag={{.}}" class="tag" title="filter dashboard by this tag">{{.}}</a>{{end}}
<button type="button" class="text-ink-fade text-[11px] hover:text-ink-mid"
style="padding: 2px 6px; border: 1px dashed var(--line); border-radius: 3px; cursor: pointer;"
onclick="document.getElementById('tags-edit-{{$host.ID}}').classList.toggle('hidden')"
title="Edit tags">+ tag</button>
</div>
{{if gt $page.ScheduleVersion 0}}
<span class="mono text-[11px] text-ink-mute ml-2">
version {{$page.ScheduleVersion}}
@@ -51,6 +57,22 @@
</span>
{{end}}
</div>
{{/* Inline tags editor — hidden by default; toggled by the "+ tag"
button above. Comma-separated input with autocomplete sourced
from the fleet's distinct tags via a <datalist>. */}}
<form id="tags-edit-{{$host.ID}}" method="post"
action="/hosts/{{$host.ID}}/tags"
class="hidden mt-3 flex items-center gap-2">
<input type="text" name="tags" class="field mono text-[12px]"
style="max-width: 480px;"
value="{{joinComma $host.Tags}}"
list="known-tags"
placeholder="comma-separated · e.g. prod, london, db" />
<datalist id="known-tags">
{{range $page.KnownTags}}<option value="{{.}}">{{end}}
</datalist>
<button type="submit" class="btn btn-primary">Save tags</button>
</form>
<div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute">
<span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
<span class="text-ink-fade">·</span>