Skip to content

Commit f6f46cd

Browse files
feat(hosts): host card scan link + working Group/Filters + server-persisted view preference (#611)
* feat(hosts): host card chart icon links to latest scan report (Part A) Backend (api-hosts v1.6.0 C-13/AC-24): GET /hosts items now carry a nullable latest_scan_id = the newest COMPLETED scan_run id per host, loaded with one DISTINCT ON query (no N+1). Queued/running-only and never-scanned hosts resolve to null. Frontend (frontend-hosts-list v1.7.0 C-09/AC-22): the previously-inert chart icon on each host card + table row is now a ViewReportButton that links to /scans/{latestScanId}. Hidden when the host has no completed scan or the viewer lacks scan:read (the destination is scan:read-gated). * feat(hosts): working Group (None/Status/OS) + Filters popover (Part B) frontend-hosts-list v1.7.0 C-10/C-11, AC-23/AC-24. Group: drop the inert 'Team' option (no backing host field) and actually partition the list — groupHosts() sections by monitoring band (worst-first) or OS (alphabetical, Unknown last) with labelled GroupHeaders; None stays flat. Filters: the dead Filters button becomes a real popover (FiltersControl) with multi-select Status / Compliance-tier / OS facets, persisted to the URL (status/os/tier params) and applied client-side via applyHostFilters (AND across dimensions, OR within). Active-filter count on the button + Clear all. Logic lives in pure, unit-tested host-filtering.ts. * feat(settings): server-side per-user UI preferences + persist hosts view (Part C) system-user-preferences v1.0.0 (new spec, 111 total). Backend: migration 0040 adds users.preferences JSONB; internal/userpref service (Get + shallow JSONB-|| Merge, scoped to one active user); GET/PATCH /api/v1/users/me/preferences — self-scoped (user id from identity, 401 for anonymous, no RBAC perm), unknown keys dropped via the typed contract, invalid enums rejected 400. Wired pool-only in newHandlers (never 503). Frontend: usePreferencesStore now hydrates from + writes through to the server (PATCH on each setter, hydrateFromServer on AppFrame mount), keeping the ow-preferences localStorage cache for instant load + offline. The /hosts view toggle resolves URL ?view= first, else the persisted hostsViewDefault, and toggling persists the per-user default — so a chosen view 'becomes the default until changed' and follows the user across devices. Specs: frontend-settings C-06/AC-30 (store now server-synced), frontend-hosts-list C-12/AC-25 (view default). New error codes validation.invalid_body / validation.invalid_value. * build: keep openapi_embed.yaml in sync at generate-api (prevent stale-embed) The embed copy is gitignored and was only refreshed by make build / make vet's file prereq, so editing api/openapi.yaml + running 'make generate-api' (the natural 'I changed the contract' command) left it stale — a bare 'go test ./internal/server/' then failed TestOpenAPIDocs_EmbeddedMatchesSource. Close the gap at the regeneration point: generate-api now depends on the embed target (re-copies when openapi.yaml is newer), and a //go:generate directive lets 'go generate ./...' refresh it via standard tooling. The drift test remains the backstop.
1 parent b064ec5 commit f6f46cd

28 files changed

Lines changed: 2286 additions & 418 deletions

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,14 @@ generate-license:
205205
go run scripts/gen-license-features.go
206206

207207
.PHONY: generate-api
208-
generate-api:
208+
generate-api: internal/server/openapi_embed.yaml
209209
@if [ ! -x "$(HOME)/go/bin/oapi-codegen" ]; then \
210210
echo "installing oapi-codegen..."; \
211211
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest; \
212212
fi
213213
$(HOME)/go/bin/oapi-codegen --config api/oapi-codegen.yaml api/openapi.yaml
214214
@echo "generated internal/server/api/server.gen.go"
215+
@echo "refreshed internal/server/openapi_embed.yaml (kept in sync with api/openapi.yaml)"
215216

216217
# The OpenAPI spec is also embedded into the binary so the /api/v1/openapi.yaml
217218
# and /docs routes can serve it air-gap-clean. go:embed cannot follow paths

api/error_codes.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ errors:
184184
properties:
185185
field: {type: string}
186186

187+
- code: validation.invalid_body
188+
http_status: 400
189+
fault: client
190+
retryable: false
191+
description: The request body is not valid JSON or does not match the expected shape
192+
193+
- code: validation.invalid_value
194+
http_status: 400
195+
fault: client
196+
retryable: false
197+
description: A field carries a value outside its allowed set (e.g. an unknown enum)
198+
187199
- code: validation.field_format
188200
http_status: 400
189201
fault: client

api/openapi.yaml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,55 @@ paths:
120120
application/json:
121121
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
122122

123+
/api/v1/users/me/preferences:
124+
get:
125+
operationId: getUsersMePreferences
126+
summary: Return the calling user's UI preferences
127+
description: >
128+
Per-user UI preferences (e.g. the /hosts grid-vs-table default).
129+
Self-scoped — any authenticated identity reads its own; no special
130+
permission. Unset keys are simply absent (the client falls back to
131+
its defaults). Spec system-user-preferences + api-user-preferences.
132+
responses:
133+
'200':
134+
description: The caller's stored preferences
135+
content:
136+
application/json:
137+
schema: {$ref: '#/components/schemas/UserPreferences'}
138+
'401':
139+
description: No valid session or bearer
140+
content:
141+
application/json:
142+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
143+
patch:
144+
operationId: patchUsersMePreferences
145+
summary: Merge a partial update into the calling user's UI preferences
146+
description: >
147+
Shallow-merges the provided keys into the caller's stored
148+
preferences and returns the merged result. Only the keys present in
149+
the body are changed; omitted keys are retained. Self-scoped.
150+
requestBody:
151+
required: true
152+
content:
153+
application/json:
154+
schema: {$ref: '#/components/schemas/UserPreferences'}
155+
responses:
156+
'200':
157+
description: The merged preferences
158+
content:
159+
application/json:
160+
schema: {$ref: '#/components/schemas/UserPreferences'}
161+
'400':
162+
description: Malformed body
163+
content:
164+
application/json:
165+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
166+
'401':
167+
description: No valid session or bearer
168+
content:
169+
application/json:
170+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
171+
123172
/api/v1/auth/mfa:enroll:
124173
post:
125174
operationId: postAuthMFAEnroll
@@ -3771,6 +3820,23 @@ components:
37713820
email: {type: string}
37723821
role: {type: string}
37733822

3823+
# Per-user UI preferences (system-user-preferences). Every field is
3824+
# optional: GET returns only the keys the user has set, and PATCH treats
3825+
# the body as a partial (present keys merge, omitted keys are retained).
3826+
# additionalProperties:false governs the valid key set — adding a knob is
3827+
# a deliberate contract change here, not arbitrary client-written JSON.
3828+
UserPreferences:
3829+
type: object
3830+
additionalProperties: false
3831+
properties:
3832+
# The /hosts page default view when the URL has no ?view= override.
3833+
hosts_view_default: {type: string, enum: [table, cards]}
3834+
density: {type: string, enum: [comfortable, compact]}
3835+
accent_color: {type: string, enum: [info, ok, brand2]}
3836+
landing_page: {type: string, enum: [hosts, dashboard, reports]}
3837+
date_format: {type: string, enum: [us12, iso24, long24]}
3838+
reduce_motion: {type: boolean}
3839+
37743840
AuthMFAEnrollResponse:
37753841
type: object
37763842
required: [provisioning_uri]
@@ -4432,6 +4498,12 @@ components:
44324498
# when no compliance check has ever run against the host.
44334499
# Surface for the operator dashboard's "last scan X ago" cell.
44344500
last_scan_at: {type: string, format: date-time, nullable: true}
4501+
# v1.6.0 — id of the newest COMPLETED scan_run for this host. Drives
4502+
# the host card's "view report" affordance, which links to
4503+
# /scans/{latest_scan_id} (the scan:read-gated detail page). NULL
4504+
# when the host has no completed scan yet (icon hidden). Spec
4505+
# api-hosts C-13.
4506+
latest_scan_id: {type: string, format: uuid, nullable: true}
44354507
liveness:
44364508
allOf: [{$ref: '#/components/schemas/HostLiveness'}]
44374509
nullable: true

frontend/src/api/host-filtering.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Pure grouping + filtering helpers for the /hosts fleet view. Kept
2+
// separate from HostsListPage so the behavior is unit-testable in
3+
// isolation (the page wiring stays a thin shell over these functions).
4+
// Spec: frontend-hosts-list v1.7.0 C-10 (grouping) + C-11 (filters).
5+
6+
import type { DevHost, MonitoringBand } from './host-view-model';
7+
8+
// ─── Grouping ────────────────────────────────────────────────────────────
9+
10+
// Drop 'team': a host has no team/owner field (see api-hosts HostListItem),
11+
// so only None/Status/OS are backed by real data.
12+
export type GroupKey = 'none' | 'status' | 'os';
13+
14+
export interface HostGroup {
15+
key: string;
16+
label: string;
17+
hosts: DevHost[];
18+
}
19+
20+
// Worst-first, mirroring the page's default down-first ordering so the
21+
// most actionable groups surface at the top.
22+
const STATUS_ORDER: MonitoringBand[] = [
23+
'critical',
24+
'down',
25+
'degraded',
26+
'online',
27+
'maintenance',
28+
'unknown',
29+
];
30+
31+
const STATUS_LABEL: Record<MonitoringBand, string> = {
32+
online: 'Online',
33+
degraded: 'Degraded',
34+
critical: 'Critical',
35+
down: 'Down',
36+
maintenance: 'Maintenance',
37+
unknown: 'Unknown',
38+
};
39+
40+
export function statusLabel(band: MonitoringBand): string {
41+
return STATUS_LABEL[band] ?? 'Unknown';
42+
}
43+
44+
// groupHosts partitions the (already sorted/filtered) host list into
45+
// labelled sections. group='none' returns a single anonymous group so the
46+
// renderer can treat both paths uniformly. Empty groups are omitted.
47+
export function groupHosts(hosts: DevHost[], group: GroupKey): HostGroup[] {
48+
if (group === 'none') {
49+
return [{ key: 'none', label: '', hosts }];
50+
}
51+
if (group === 'status') {
52+
return STATUS_ORDER.map((band) => ({
53+
key: band,
54+
label: statusLabel(band),
55+
hosts: hosts.filter((h) => h.monitoring === band),
56+
})).filter((g) => g.hosts.length > 0);
57+
}
58+
// group === 'os' — alphabetical, with the catch-all "Unknown" last.
59+
const byOs = new Map<string, DevHost[]>();
60+
for (const h of hosts) {
61+
const key = h.os || 'Unknown';
62+
(byOs.get(key) ?? byOs.set(key, []).get(key)!).push(h);
63+
}
64+
return [...byOs.entries()]
65+
.sort(([a], [b]) => {
66+
if (a === 'Unknown') return 1;
67+
if (b === 'Unknown') return -1;
68+
return a.localeCompare(b);
69+
})
70+
.map(([key, hs]) => ({ key, label: key, hosts: hs }));
71+
}
72+
73+
// ─── Filtering ───────────────────────────────────────────────────────────
74+
75+
export type TierFilter = 'crit' | 'warn' | 'ok' | 'none';
76+
77+
export interface HostFilters {
78+
status: string[]; // MonitoringBand values
79+
os: string[]; // osDisplayLabel values (host.os)
80+
tier: string[]; // TierFilter values
81+
}
82+
83+
const TIER_LABEL: Record<TierFilter, string> = {
84+
crit: 'Critical (<40%)',
85+
warn: 'Warning (40-80%)',
86+
ok: 'Compliant (>=80%)',
87+
none: 'No scan data',
88+
};
89+
90+
export function tierLabel(t: TierFilter): string {
91+
return TIER_LABEL[t] ?? t;
92+
}
93+
94+
// hostComplianceTier buckets a host's compliance score. A never-scanned
95+
// host (compliance null) is its own 'none' bucket rather than 'crit', so
96+
// "no data" and "actually failing" stay distinguishable in the filter.
97+
export function hostComplianceTier(h: DevHost): TierFilter {
98+
if (h.compliance == null) return 'none';
99+
if (h.compliance < 40) return 'crit';
100+
if (h.compliance < 80) return 'warn';
101+
return 'ok';
102+
}
103+
104+
function csv(v: string | undefined): string[] {
105+
if (!v) return [];
106+
return v
107+
.split(',')
108+
.map((s) => s.trim())
109+
.filter(Boolean);
110+
}
111+
112+
export function parseHostFilters(search: {
113+
status?: string;
114+
os?: string;
115+
tier?: string;
116+
}): HostFilters {
117+
return { status: csv(search.status), os: csv(search.os), tier: csv(search.tier) };
118+
}
119+
120+
// applyHostFilters keeps a host only when it matches EVERY active
121+
// dimension (AND across dimensions, OR within a dimension). An empty
122+
// dimension imposes no constraint.
123+
export function applyHostFilters(hosts: DevHost[], f: HostFilters): DevHost[] {
124+
return hosts.filter((h) => {
125+
if (f.status.length && !f.status.includes(h.monitoring)) return false;
126+
if (f.os.length && !f.os.includes(h.os || 'Unknown')) return false;
127+
if (f.tier.length && !f.tier.includes(hostComplianceTier(h))) return false;
128+
return true;
129+
});
130+
}
131+
132+
export function activeFilterCount(f: HostFilters): number {
133+
return f.status.length + f.os.length + f.tier.length;
134+
}

frontend/src/api/host-view-model.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export interface DevHost {
4141
// renders "—" in that case rather than the misleading "0m ago".
4242
lastCheckMinutes: number | null;
4343
lastScan: string; // "Xh ago" or "Xm ago"
44+
/**
45+
* id of the newest completed scan_run, from the list endpoint's
46+
* latest_scan_id. null when the host has no completed scan — the card's
47+
* "view report" affordance is hidden in that case. Spec
48+
* frontend-hosts-list AC-24, links to /scans/{latestScanId}.
49+
*/
50+
latestScanId: string | null;
4451
}
4552

4653
export type DeltaTier = 'crit' | 'warn' | 'ok' | 'neutral';

0 commit comments

Comments
 (0)