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/maintenance", s.handleUIRepoMaintenanceSave)
|
||||||
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
|
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
|
||||||
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
|
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", s.handleUIAdminCredentialsSave)
|
||||||
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
||||||
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/go-chi/chi/v5"
|
"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/api"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
"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.
|
// dashboardPage is the data the dashboard template renders against.
|
||||||
type dashboardPage struct {
|
type dashboardPage struct {
|
||||||
Hosts []dashboardHostRow
|
Hosts []dashboardHostRow
|
||||||
HostCount int
|
HostCount int // unfiltered fleet size
|
||||||
|
ShownCount int // after the tag filter (== HostCount when no filter)
|
||||||
Summary store.FleetSummary
|
Summary store.FleetSummary
|
||||||
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
|
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
|
||||||
CritOpenCount int
|
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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts, err := s.deps.Store.ListHosts(r.Context())
|
allHosts, err := s.deps.Store.ListHosts(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("ui dashboard: list hosts", "err", err)
|
slog.Error("ui dashboard: list hosts", "err", err)
|
||||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
return
|
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())
|
summary, err := s.deps.Store.FleetSummary(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("ui dashboard: fleet summary", "err", err)
|
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 := s.baseView(r, u)
|
||||||
view.Page = dashboardPage{
|
view.Page = dashboardPage{
|
||||||
Hosts: rows,
|
Hosts: rows,
|
||||||
HostCount: len(hosts),
|
HostCount: len(allHosts),
|
||||||
|
ShownCount: len(rows),
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
PendingHosts: pending,
|
PendingHosts: pending,
|
||||||
CritOpenCount: critOpenCount,
|
CritOpenCount: critOpenCount,
|
||||||
|
ActiveTag: activeTag,
|
||||||
|
KnownTags: knownTags,
|
||||||
}
|
}
|
||||||
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
|
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
|
||||||
slog.Error("ui: render dashboard", "err", err)
|
slog.Error("ui: render dashboard", "err", err)
|
||||||
@@ -529,6 +556,9 @@ type hostChromeData struct {
|
|||||||
SourceGroupCount int
|
SourceGroupCount int
|
||||||
ScheduleCount int
|
ScheduleCount int
|
||||||
ScheduleVersion int64 // host_schedule_version (latest desired)
|
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.
|
// Auto-init status surfaced from the latest 'init' job.
|
||||||
// InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run).
|
// 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
|
d.RestoreAt = &t
|
||||||
}
|
}
|
||||||
|
if tags, err := s.deps.Store.DistinctHostTags(r.Context()); err == nil {
|
||||||
|
d.KnownTags = tags
|
||||||
|
}
|
||||||
return d
|
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.
|
// hostDetailPage carries everything the host detail template needs.
|
||||||
type hostDetailPage struct {
|
type hostDetailPage struct {
|
||||||
hostChromeData
|
hostChromeData
|
||||||
|
|||||||
@@ -299,6 +299,49 @@ func (s *Store) SetHostBandwidth(ctx context.Context, hostID string, upKBps, dow
|
|||||||
return nil
|
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 {
|
func nullableInt(p *int) any {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 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.
|
> **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-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
|
### 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-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%); }
|
.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 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-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%); }
|
.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 justify-between mb-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-[13px] font-semibold tracking-[0.01em]">Hosts</h2>
|
<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>
|
||||||
</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="panel rounded-[7px] overflow-hidden">
|
||||||
|
|
||||||
<div class="host-row head hairline">
|
<div class="host-row head hairline">
|
||||||
|
|||||||
@@ -39,7 +39,13 @@
|
|||||||
<span class="dot dot-failed"></span>
|
<span class="dot dot-failed"></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<h1 class="mono text-[26px] font-medium tracking-[0.005em] text-ink">{{$host.Name}}</h1>
|
<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}}
|
{{if gt $page.ScheduleVersion 0}}
|
||||||
<span class="mono text-[11px] text-ink-mute ml-2">
|
<span class="mono text-[11px] text-ink-mute ml-2">
|
||||||
version {{$page.ScheduleVersion}}
|
version {{$page.ScheduleVersion}}
|
||||||
@@ -51,6 +57,29 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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">
|
<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="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
|
||||||
<span class="text-ink-fade">·</span>
|
<span class="text-ink-fade">·</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user