Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,14 @@ generate-license:
go run scripts/gen-license-features.go

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

# The OpenAPI spec is also embedded into the binary so the /api/v1/openapi.yaml
# and /docs routes can serve it air-gap-clean. go:embed cannot follow paths
Expand Down
12 changes: 12 additions & 0 deletions api/error_codes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,18 @@ errors:
properties:
field: {type: string}

- code: validation.invalid_body
http_status: 400
fault: client
retryable: false
description: The request body is not valid JSON or does not match the expected shape

- code: validation.invalid_value
http_status: 400
fault: client
retryable: false
description: A field carries a value outside its allowed set (e.g. an unknown enum)

- code: validation.field_format
http_status: 400
fault: client
Expand Down
72 changes: 72 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,55 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}

/api/v1/users/me/preferences:
get:
operationId: getUsersMePreferences
summary: Return the calling user's UI preferences
description: >
Per-user UI preferences (e.g. the /hosts grid-vs-table default).
Self-scoped — any authenticated identity reads its own; no special
permission. Unset keys are simply absent (the client falls back to
its defaults). Spec system-user-preferences + api-user-preferences.
responses:
'200':
description: The caller's stored preferences
content:
application/json:
schema: {$ref: '#/components/schemas/UserPreferences'}
'401':
description: No valid session or bearer
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
patch:
operationId: patchUsersMePreferences
summary: Merge a partial update into the calling user's UI preferences
description: >
Shallow-merges the provided keys into the caller's stored
preferences and returns the merged result. Only the keys present in
the body are changed; omitted keys are retained. Self-scoped.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UserPreferences'}
responses:
'200':
description: The merged preferences
content:
application/json:
schema: {$ref: '#/components/schemas/UserPreferences'}
'400':
description: Malformed body
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
'401':
description: No valid session or bearer
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}

/api/v1/auth/mfa:enroll:
post:
operationId: postAuthMFAEnroll
Expand Down Expand Up @@ -3771,6 +3820,23 @@ components:
email: {type: string}
role: {type: string}

# Per-user UI preferences (system-user-preferences). Every field is
# optional: GET returns only the keys the user has set, and PATCH treats
# the body as a partial (present keys merge, omitted keys are retained).
# additionalProperties:false governs the valid key set — adding a knob is
# a deliberate contract change here, not arbitrary client-written JSON.
UserPreferences:
type: object
additionalProperties: false
properties:
# The /hosts page default view when the URL has no ?view= override.
hosts_view_default: {type: string, enum: [table, cards]}
density: {type: string, enum: [comfortable, compact]}
accent_color: {type: string, enum: [info, ok, brand2]}
landing_page: {type: string, enum: [hosts, dashboard, reports]}
date_format: {type: string, enum: [us12, iso24, long24]}
reduce_motion: {type: boolean}

AuthMFAEnrollResponse:
type: object
required: [provisioning_uri]
Expand Down Expand Up @@ -4432,6 +4498,12 @@ components:
# when no compliance check has ever run against the host.
# Surface for the operator dashboard's "last scan X ago" cell.
last_scan_at: {type: string, format: date-time, nullable: true}
# v1.6.0 — id of the newest COMPLETED scan_run for this host. Drives
# the host card's "view report" affordance, which links to
# /scans/{latest_scan_id} (the scan:read-gated detail page). NULL
# when the host has no completed scan yet (icon hidden). Spec
# api-hosts C-13.
latest_scan_id: {type: string, format: uuid, nullable: true}
liveness:
allOf: [{$ref: '#/components/schemas/HostLiveness'}]
nullable: true
Expand Down
134 changes: 134 additions & 0 deletions frontend/src/api/host-filtering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Pure grouping + filtering helpers for the /hosts fleet view. Kept
// separate from HostsListPage so the behavior is unit-testable in
// isolation (the page wiring stays a thin shell over these functions).
// Spec: frontend-hosts-list v1.7.0 C-10 (grouping) + C-11 (filters).

import type { DevHost, MonitoringBand } from './host-view-model';

// ─── Grouping ────────────────────────────────────────────────────────────

// Drop 'team': a host has no team/owner field (see api-hosts HostListItem),
// so only None/Status/OS are backed by real data.
export type GroupKey = 'none' | 'status' | 'os';

export interface HostGroup {
key: string;
label: string;
hosts: DevHost[];
}

// Worst-first, mirroring the page's default down-first ordering so the
// most actionable groups surface at the top.
const STATUS_ORDER: MonitoringBand[] = [
'critical',
'down',
'degraded',
'online',
'maintenance',
'unknown',
];

const STATUS_LABEL: Record<MonitoringBand, string> = {
online: 'Online',
degraded: 'Degraded',
critical: 'Critical',
down: 'Down',
maintenance: 'Maintenance',
unknown: 'Unknown',
};

export function statusLabel(band: MonitoringBand): string {
return STATUS_LABEL[band] ?? 'Unknown';
}

// groupHosts partitions the (already sorted/filtered) host list into
// labelled sections. group='none' returns a single anonymous group so the
// renderer can treat both paths uniformly. Empty groups are omitted.
export function groupHosts(hosts: DevHost[], group: GroupKey): HostGroup[] {
if (group === 'none') {
return [{ key: 'none', label: '', hosts }];
}
if (group === 'status') {
return STATUS_ORDER.map((band) => ({
key: band,
label: statusLabel(band),
hosts: hosts.filter((h) => h.monitoring === band),
})).filter((g) => g.hosts.length > 0);
}
// group === 'os' — alphabetical, with the catch-all "Unknown" last.
const byOs = new Map<string, DevHost[]>();
for (const h of hosts) {
const key = h.os || 'Unknown';
(byOs.get(key) ?? byOs.set(key, []).get(key)!).push(h);
}
return [...byOs.entries()]
.sort(([a], [b]) => {
if (a === 'Unknown') return 1;
if (b === 'Unknown') return -1;
return a.localeCompare(b);
})
.map(([key, hs]) => ({ key, label: key, hosts: hs }));
}

// ─── Filtering ───────────────────────────────────────────────────────────

export type TierFilter = 'crit' | 'warn' | 'ok' | 'none';

export interface HostFilters {
status: string[]; // MonitoringBand values
os: string[]; // osDisplayLabel values (host.os)
tier: string[]; // TierFilter values
}

const TIER_LABEL: Record<TierFilter, string> = {
crit: 'Critical (<40%)',
warn: 'Warning (40-80%)',
ok: 'Compliant (>=80%)',
none: 'No scan data',
};

export function tierLabel(t: TierFilter): string {
return TIER_LABEL[t] ?? t;
}

// hostComplianceTier buckets a host's compliance score. A never-scanned
// host (compliance null) is its own 'none' bucket rather than 'crit', so
// "no data" and "actually failing" stay distinguishable in the filter.
export function hostComplianceTier(h: DevHost): TierFilter {
if (h.compliance == null) return 'none';
if (h.compliance < 40) return 'crit';
if (h.compliance < 80) return 'warn';
return 'ok';
}

function csv(v: string | undefined): string[] {
if (!v) return [];
return v
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}

export function parseHostFilters(search: {
status?: string;
os?: string;
tier?: string;
}): HostFilters {
return { status: csv(search.status), os: csv(search.os), tier: csv(search.tier) };
}

// applyHostFilters keeps a host only when it matches EVERY active
// dimension (AND across dimensions, OR within a dimension). An empty
// dimension imposes no constraint.
export function applyHostFilters(hosts: DevHost[], f: HostFilters): DevHost[] {
return hosts.filter((h) => {
if (f.status.length && !f.status.includes(h.monitoring)) return false;
if (f.os.length && !f.os.includes(h.os || 'Unknown')) return false;
if (f.tier.length && !f.tier.includes(hostComplianceTier(h))) return false;
return true;
});
}

export function activeFilterCount(f: HostFilters): number {
return f.status.length + f.os.length + f.tier.length;
}
7 changes: 7 additions & 0 deletions frontend/src/api/host-view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export interface DevHost {
// renders "—" in that case rather than the misleading "0m ago".
lastCheckMinutes: number | null;
lastScan: string; // "Xh ago" or "Xm ago"
/**
* id of the newest completed scan_run, from the list endpoint's
* latest_scan_id. null when the host has no completed scan — the card's
* "view report" affordance is hidden in that case. Spec
* frontend-hosts-list AC-24, links to /scans/{latestScanId}.
*/
latestScanId: string | null;
}

export type DeltaTier = 'crit' | 'warn' | 'ok' | 'neutral';
Expand Down
Loading
Loading