Skip to content

Commit 5645bde

Browse files
fix(hosts): source Avg compliance KPI from /fleet/score so it matches /dashboard (#676)
The /dashboard and /hosts 'Avg compliance' tiles showed different numbers because they used different denominators over the same host_rule_state data: - /dashboard (GET /api/v1/fleet/score): passing / (passing + failing) - /hosts (kpisFromHosts): passing / total, where total = COUNT(*) including skipped + error rows. Kensa marks many rules not-applicable (skipped) per host, so the /hosts figure read materially lower. passing/(passing+failing) is the canonical metric (it also drives the scheduler bands via drift.ComplianceScore). Source the /hosts headline KPI from the same /api/v1/fleet/score endpoint the dashboard uses (shared queryKey ['fleet','score'], same round(passing_fraction *100)) so the two surfaces are literally the same number and cannot drift. The client-side kpisFromHosts aggregate remains a fallback until the score loads. Spec frontend-hosts-list v1.8.0: new C-13 + AC-26 + source-inspection test.
1 parent abd41ea commit 5645bde

3 files changed

Lines changed: 68 additions & 1 deletion

File tree

frontend/src/pages/HostsListPage.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,29 @@ export function HostsListPage() {
242242
refetchInterval: 60_000,
243243
});
244244

245+
// Avg-compliance KPI value: source the SAME fleet score the dashboard KPI
246+
// uses (GET /api/v1/fleet/score = passing / (passing + failing), the
247+
// canonical compliance metric that also drives the scheduler bands) so the
248+
// two surfaces can never diverge. The client-side kpisFromHosts value (which
249+
// divides by the all-status rule total) is only a fallback shown until this
250+
// resolves. Shared queryKey with the dashboard widget, so it dedupes/caches.
251+
// Spec frontend-hosts-list AC-26.
252+
const fleetScoreQuery = useQuery({
253+
queryKey: ['fleet', 'score'],
254+
queryFn: async () => {
255+
const { data, error, response } = await api.GET('/api/v1/fleet/score', {});
256+
if (error || !response.ok) throw new Error(`HTTP ${response.status}`);
257+
return data!;
258+
},
259+
});
260+
245261
const kpis = kpisFromHosts(hosts);
262+
// Authoritative fleet score wins over the client-side aggregate so the
263+
// /hosts headline equals the /dashboard headline exactly (same endpoint,
264+
// same integer rounding). Spec frontend-hosts-list AC-26.
265+
if (fleetScoreQuery.data && fleetScoreQuery.data.total_evaluations > 0) {
266+
kpis.avgCompliance.value = Math.round(fleetScoreQuery.data.passing_fraction * 100);
267+
}
246268
if (scanQueueQuery.data) {
247269
const q = scanQueueQuery.data.queued;
248270
const r = scanQueueQuery.data.running;

frontend/tests/pages/hosts-list.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// AC-16 test('frontend-hosts-list/AC-16 — compliance_summary maps to real compliance with null honesty')
1414
// AC-17 test('frontend-hosts-list/AC-17 — avg compliance KPI excludes never-scanned hosts')
1515
// AC-18 test('frontend-hosts-list/AC-18 — critical issues KPI sums critical_failing with affected-hosts scope')
16+
// AC-26 test('frontend-hosts-list/AC-26 — avg compliance KPI sourced from /fleet/score (matches dashboard)')
1617

1718
import { describe, expect, test } from 'vitest';
1819
import { readFileSync } from 'node:fs';
@@ -125,6 +126,23 @@ test('frontend-hosts-list/AC-13 — per-host Scan buttons are live with idempote
125126
expect(btnSlice).not.toMatch(/setInterval/);
126127
});
127128

129+
// @ac AC-26
130+
// AC-26: the Avg compliance KPI value is sourced from GET /api/v1/fleet/score
131+
// (the same fleet score, and the same round(passing_fraction*100) expression,
132+
// the dashboard KPI uses) so /hosts and /dashboard can never show a different
133+
// fleet-compliance number. The client-side kpisFromHosts aggregate (which
134+
// divides by the all-status rule total) is only a fallback.
135+
test('frontend-hosts-list/AC-26 — avg compliance KPI sourced from /fleet/score (matches dashboard)', () => {
136+
// Shares the dashboard's query key + endpoint.
137+
expect(PAGE_SRC).toContain("queryKey: ['fleet', 'score']");
138+
expect(PAGE_SRC).toContain("api.GET('/api/v1/fleet/score'");
139+
// The KPI value is overridden with the canonical fleet score, same rounding
140+
// as the dashboard widget (Math.round(passing_fraction * 100)).
141+
expect(PAGE_SRC).toMatch(/kpis\.avgCompliance\.value\s*=\s*Math\.round\(\s*fleetScoreQuery\.data\.passing_fraction\s*\*\s*100\s*\)/);
142+
// Guarded so an empty fleet (no pass/fail evaluations) keeps the fallback.
143+
expect(PAGE_SRC).toMatch(/fleetScoreQuery\.data\.total_evaluations\s*>\s*0/);
144+
});
145+
128146
// @ac AC-14
129147
test('frontend-hosts-list/AC-14 — scan-queue KPI wired to /fleet/scan-queue', () => {
130148
expect(PAGE_SRC).toContain("queryKey: ['fleet', 'scan_queue']");

specs/frontend/hosts-list.spec.yaml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
spec:
22
id: frontend-hosts-list
33
title: Hosts list — fleet dashboard with search, filter, card/table views
4-
version: "1.7.0"
4+
version: "1.8.0"
55
status: approved
66
tier: 2
77

@@ -96,6 +96,18 @@ spec:
9696
description: 'v1.7.0 — the card/table view is chosen as: URL ?view= (table|cards) when present (so a link or refresh is stable), otherwise the user''s server-persisted default usePreferencesStore.hostsViewDefault (system-user-preferences). Toggling the view MUST both call setHostsViewDefault(v) (persisting the new per-user default) AND set ?view= for the session — so the choice "becomes the default until the user changes it"'
9797
type: technical
9898
enforcement: error
99+
- id: C-13
100+
description: >-
101+
v1.8.0 — the headline Avg compliance KPI value MUST be sourced from the
102+
same fleet score the dashboard KPI uses (GET /api/v1/fleet/score,
103+
passing / (passing + failing) — the canonical compliance metric that
104+
also drives the scheduler bands), so the /hosts and /dashboard surfaces
105+
cannot show different fleet-compliance numbers. The client-side
106+
kpisFromHosts average (which divides by the all-status rule total,
107+
including skipped + error) is a fallback shown only until the fleet
108+
score resolves, never the authoritative displayed value.
109+
type: technical
110+
enforcement: error
99111

100112
acceptance_criteria:
101113
- id: AC-01
@@ -198,3 +210,18 @@ spec:
198210
description: "v1.7.0 source-inspection: the view resolves to search.view when it is 'table' or 'cards', otherwise usePreferencesStore.hostsViewDefault (not a hardcoded 'cards'). The ViewToggle onChange calls setHostsViewDefault(v) AND updateSearch({view:v}) so the toggle persists the per-user default and updates the URL together."
199211
priority: high
200212
references_constraints: [C-12]
213+
- id: AC-26
214+
description: >-
215+
v1.8.0 — the Avg compliance KPI value is taken from GET
216+
/api/v1/fleet/score (queryKey ['fleet','score'], shared with the
217+
dashboard KPI): when the fleet score has loaded with total_evaluations
218+
> 0, kpis.avgCompliance.value is round(passing_fraction * 100) — the
219+
same number and rounding the dashboard shows — overriding the
220+
client-side kpisFromHosts aggregate so /hosts and /dashboard agree.
221+
Source-inspection of HostsListPage: a useQuery with queryKey
222+
['fleet','score'] hits GET /api/v1/fleet/score, and the avg-compliance
223+
KPI value is set to round(passing_fraction * 100) from that query
224+
(guarded on total_evaluations > 0), the same expression the dashboard
225+
widget uses.
226+
priority: high
227+
references_constraints: [C-13]

0 commit comments

Comments
 (0)