Merge pull request 'Phase 4 — P4-07: per-host tags + dashboard chip-row filter' (#15) from p4-07-host-tags into main
Reviewed-on: #15
This commit was merged in pull request #15.
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
|
||||
|
||||
@@ -309,7 +309,9 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 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
@@ -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%); }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 whitespace-nowrap"
|
||||
style="padding: 2px 8px; border: 1px dashed var(--line); border-radius: 3px; cursor: pointer;"
|
||||
onclick="document.getElementById('tags-edit-{{$host.ID}}').classList.toggle('hidden')"
|
||||
title="Edit tags">{{if $host.Tags}}edit tags{{else}}add tags{{end}}</button>
|
||||
</div>
|
||||
{{if gt $page.ScheduleVersion 0}}
|
||||
<span class="mono text-[11px] text-ink-mute ml-2">
|
||||
version {{$page.ScheduleVersion}}
|
||||
@@ -51,6 +57,29 @@
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{/* Inline tags editor — hidden by default; toggled by the
|
||||
"edit/add tags" button above. Comma-separated input with
|
||||
autocomplete sourced from the fleet's distinct tags via a
|
||||
<datalist>. The help line under the input is always visible
|
||||
because the placeholder hint disappears once the field has
|
||||
a value, and operators editing existing tags are exactly
|
||||
the people most likely to forget the comma rule. */}}
|
||||
<form id="tags-edit-{{$host.ID}}" method="post"
|
||||
action="/hosts/{{$host.ID}}/tags"
|
||||
class="hidden mt-3"
|
||||
style="max-width: 640px;">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="text" name="tags" class="field mono text-[12px]"
|
||||
value="{{joinComma $host.Tags}}"
|
||||
list="known-tags"
|
||||
placeholder="prod, london, db" />
|
||||
<datalist id="known-tags">
|
||||
{{range $page.KnownTags}}<option value="{{.}}">{{end}}
|
||||
</datalist>
|
||||
<button type="submit" class="btn btn-primary whitespace-nowrap">Save tags</button>
|
||||
</div>
|
||||
<div class="field-help">Comma-separated. Lowercased automatically.</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user