Skip to content

Commit 2ce3100

Browse files
feat(reports): Remediation Activity kind (Phase C2) (#658)
Add the 'remediation' report kind: an Operations read-model of remediation requests over a look-back window, the second Phase C delivery-spine kind. - Migration 0045 admits kind='remediation'. - GenerateRequest gains period_days (1..365, default 30, clamped); Generate(kind=remediation) resolves [now-period_days, now] and freezes a RemediationContent {period_from, period_to, summary, activities[]} over remediation_requests filtered on requested_at, scoped to the in-scope hosts. The summary is an exact aggregate by outcome (total, executed, rolled_back, failed, rejected, pending); activities[] is one capped row per request with requester/reviewer resolved to usernames. - Faces: CSV is the full activity log (csvSafe-guarded), PDF is the bounded one-page summary (counts + recent sample), JSON is the signed canonical content. pdf/csv kind-dispatched; oscal_sar invalid for the kind. - Frontend: kind selector gains 'Remediation Activity'; a Last 7/30/90 days period selector shows for the kind and drives period_days; a kind-aware RemediationBody renders the period + outcome summary + recent-activity table; CSV-led with a PDF-summary secondary. Spec: api-reports v1.13.0 (C-18 / AC-24), frontend-reports v1.10.0 (C-13 / AC-14). Go DB test (5-request window fixture) + frontend source-inspection test; full suites green (report pkg, frontend 336, specter 112 + 100% structural).
1 parent 802c037 commit 2ce3100

15 files changed

Lines changed: 1326 additions & 538 deletions

File tree

api/openapi.yaml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5390,18 +5390,28 @@ components:
53905390
properties:
53915391
kind:
53925392
type: string
5393-
enum: [executive, attestation, exception]
5393+
enum: [executive, attestation, exception, remediation]
53945394
description: |
53955395
The report kind; defaults to executive. attestation produces
53965396
the Framework Attestation (CSV/OSCAL bulk faces over the latest
5397-
completed scan per in-scope host).
5397+
completed scan per in-scope host); exception produces the
5398+
Exception Register; remediation produces the Remediation
5399+
Activity log over a look-back period.
53985400
group_id:
53995401
type: string
54005402
format: uuid
54015403
description: Scope the report to this group's member hosts.
54025404
framework:
54035405
type: string
54045406
description: Scope the report to this framework lens (framework_refs key).
5407+
period_days:
5408+
type: integer
5409+
minimum: 1
5410+
maximum: 365
5411+
description: |
5412+
Look-back window in days for time-windowed kinds (remediation):
5413+
the report covers requests filed in the last period_days.
5414+
Defaults to 30; ignored by point-in-time kinds.
54055415
54065416
Report:
54075417
type: object
@@ -5411,7 +5421,7 @@ components:
54115421
title: {type: string}
54125422
kind:
54135423
type: string
5414-
enum: [executive, attestation, exception]
5424+
enum: [executive, attestation, exception, remediation]
54155425
scope_label: {type: string}
54165426
scope: {$ref: '#/components/schemas/ReportScope'}
54175427
data_as_of: {type: string, format: date-time}

docs/engineering/reports_design.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,17 @@ feel instant.** Build them aware of each other.
343343
bounded PDF summary face, and a kind-aware in-app `ExceptionBody`.
344344
Migration 0044 admits `kind='exception'`. Spec: `api-reports` v1.12.0
345345
(C-17 / AC-23), `frontend-reports` v1.9.0 (C-12 / AC-13).
346-
- **C2 — Remediation Activity kind.** *(REMAINING.)* A read-model of
347-
remediation execute/rollback events over a period (`remediation_requests`
348-
filtered on `requested_at`); adds a `period` scope dimension.
346+
- **C2 — Remediation Activity kind.** *(SHIPPED 2026-06-22, PR #658.)* A
347+
read-model of remediation requests over a look-back window
348+
(`remediation_requests` filtered on `requested_at`): a frozen
349+
`RemediationContent` {period_from, period_to, summary, activities[]}
350+
(exact counts by outcome + the activity rows, requester/reviewer resolved
351+
to usernames), a CSV activity-log face, a bounded PDF summary face, and a
352+
kind-aware in-app `RemediationBody`. The generate request gains
353+
`period_days` (1..365, default 30); the UI shows a Last 7/30/90 days
354+
selector for the kind. Migration 0045 admits `kind='remediation'`. Spec:
355+
`api-reports` v1.13.0 (C-18 / AC-24), `frontend-reports` v1.10.0
356+
(C-13 / AC-14).
349357
- **C3 — Scheduled dispatcher.** *(REMAINING.)* **Scheduled** reports +
350358
**email delivery** (notification-channel dispatch); activates the
351359
Scheduled tab. Spec: `system-report-schedule`, `frontend-reports`

frontend/src/api/schema.d.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3576,24 +3576,32 @@ export interface components {
35763576
/**
35773577
* @description The report kind; defaults to executive. attestation produces
35783578
* the Framework Attestation (CSV/OSCAL bulk faces over the latest
3579-
* completed scan per in-scope host).
3579+
* completed scan per in-scope host); exception produces the
3580+
* Exception Register; remediation produces the Remediation
3581+
* Activity log over a look-back period.
35803582
* @enum {string}
35813583
*/
3582-
kind?: "executive" | "attestation" | "exception";
3584+
kind?: "executive" | "attestation" | "exception" | "remediation";
35833585
/**
35843586
* Format: uuid
35853587
* @description Scope the report to this group's member hosts.
35863588
*/
35873589
group_id?: string;
35883590
/** @description Scope the report to this framework lens (framework_refs key). */
35893591
framework?: string;
3592+
/**
3593+
* @description Look-back window in days for time-windowed kinds (remediation):
3594+
* the report covers requests filed in the last period_days.
3595+
* Defaults to 30; ignored by point-in-time kinds.
3596+
*/
3597+
period_days?: number;
35903598
};
35913599
Report: {
35923600
/** Format: uuid */
35933601
id: string;
35943602
title: string;
35953603
/** @enum {string} */
3596-
kind: "executive" | "attestation" | "exception";
3604+
kind: "executive" | "attestation" | "exception" | "remediation";
35973605
scope_label: string;
35983606
scope: components["schemas"]["ReportScope"];
35993607
/** Format: date-time */

frontend/src/pages/reports/ReportsPage.tsx

Lines changed: 193 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,63 @@ function asExceptionContent(content: Report['content']): ExceptionContent {
169169
};
170170
}
171171

172+
// The remediation-activity content shape (see api-reports spec): the period
173+
// it covers, a summary by outcome, and the activity rows. The CSV face is
174+
// the full log; the in-app body shows the period + summary + recent sample.
175+
interface RemediationSummary {
176+
total: number;
177+
executed: number;
178+
rolled_back: number;
179+
failed: number;
180+
rejected: number;
181+
pending: number;
182+
}
183+
184+
interface RemediationActRow {
185+
host_name: string;
186+
rule_id: string;
187+
status: string;
188+
mechanism: string;
189+
requested_by: string;
190+
requested_at: string;
191+
reviewed_by: string;
192+
reviewed_at: string | null;
193+
}
194+
195+
interface RemediationContent {
196+
period_from: string;
197+
period_to: string;
198+
summary: RemediationSummary;
199+
activities: RemediationActRow[];
200+
}
201+
202+
function asRemediationSummary(s: Partial<RemediationSummary> | undefined): RemediationSummary {
203+
const n = (v: unknown): number => (typeof v === 'number' ? v : 0);
204+
return {
205+
total: n(s?.total),
206+
executed: n(s?.executed),
207+
rolled_back: n(s?.rolled_back),
208+
failed: n(s?.failed),
209+
rejected: n(s?.rejected),
210+
pending: n(s?.pending),
211+
};
212+
}
213+
214+
function asRemediationContent(content: Report['content']): RemediationContent {
215+
const c = content as Partial<RemediationContent>;
216+
return {
217+
period_from: typeof c.period_from === 'string' ? c.period_from : '',
218+
period_to: typeof c.period_to === 'string' ? c.period_to : '',
219+
summary: asRemediationSummary(c.summary as Partial<RemediationSummary> | undefined),
220+
activities: Array.isArray(c.activities) ? c.activities : [],
221+
};
222+
}
223+
172224
function kindLabel(kind: Report['kind']): string {
173225
if (kind === 'executive') return 'Executive';
174226
if (kind === 'attestation') return 'Attestation';
175227
if (kind === 'exception') return 'Exception Register';
228+
if (kind === 'remediation') return 'Remediation Activity';
176229
return kind;
177230
}
178231

@@ -192,11 +245,13 @@ export function ReportsPage() {
192245
const [tab, setTab] = useState<'library' | 'templates' | 'scheduled'>('library');
193246
const [selectedId, setSelectedId] = useState<string | null>(null);
194247
// Kind + scope for the next Generate. '' = all hosts / all frameworks.
195-
const [reportKind, setReportKind] = useState<'executive' | 'attestation' | 'exception'>(
196-
'executive',
197-
);
248+
const [reportKind, setReportKind] = useState<
249+
'executive' | 'attestation' | 'exception' | 'remediation'
250+
>('executive');
198251
const [scopeGroupId, setScopeGroupId] = useState<string>('');
199252
const [scopeFramework, setScopeFramework] = useState<string>('');
253+
// Look-back window (days) for the remediation activity kind.
254+
const [periodDays, setPeriodDays] = useState<number>(30);
200255

201256
const queryClient = useQueryClient();
202257
const canGenerate = useAuthStore((s) => s.hasPermission('host:write'));
@@ -243,14 +298,17 @@ export function ReportsPage() {
243298
const generateMutation = useMutation({
244299
mutationFn: async () => {
245300
const body: {
246-
kind?: 'executive' | 'attestation' | 'exception';
301+
kind?: 'executive' | 'attestation' | 'exception' | 'remediation';
247302
group_id?: string;
248303
framework?: string;
304+
period_days?: number;
249305
} = {};
250306
// executive is the implicit default; send kind only for the others.
251307
if (reportKind !== 'executive') body.kind = reportKind;
252308
if (scopeGroupId) body.group_id = scopeGroupId;
253309
if (scopeFramework) body.framework = scopeFramework;
310+
// The period window only applies to the remediation activity kind.
311+
if (reportKind === 'remediation') body.period_days = periodDays;
254312
const { data, error, response } = await api.POST('/api/v1/reports:generate', { body });
255313
if (error || !response.ok)
256314
throw new Error(apiErrorMessage(error, `Failed (${response.status})`));
@@ -292,10 +350,12 @@ export function ReportsPage() {
292350
aria-label="Report kind"
293351
value={reportKind}
294352
onChange={(e) =>
295-
setReportKind(e.target.value as 'executive' | 'attestation' | 'exception')
353+
setReportKind(
354+
e.target.value as 'executive' | 'attestation' | 'exception' | 'remediation',
355+
)
296356
}
297357
disabled={generateMutation.isPending}
298-
title="Executive summary (leadership), Framework Attestation (auditor evidence), or Exception Register (compliance waivers)"
358+
title="Executive summary (leadership), Framework Attestation (auditor evidence), Exception Register (compliance waivers), or Remediation Activity (fixes over a period)"
299359
style={{
300360
height: 34,
301361
padding: '0 10px',
@@ -311,6 +371,31 @@ export function ReportsPage() {
311371
<option value="executive">Executive</option>
312372
<option value="attestation">Attestation</option>
313373
<option value="exception">Exception Register</option>
374+
<option value="remediation">Remediation Activity</option>
375+
</select>
376+
)}
377+
{canGenerate && reportKind === 'remediation' && (
378+
<select
379+
aria-label="Remediation period"
380+
value={periodDays}
381+
onChange={(e) => setPeriodDays(Number(e.target.value))}
382+
disabled={generateMutation.isPending}
383+
title="Look-back window for the remediation activity log"
384+
style={{
385+
height: 34,
386+
padding: '0 10px',
387+
borderRadius: 'var(--ow-radius-sm, 6px)',
388+
border: '1px solid var(--ow-line)',
389+
background: 'var(--ow-bg-2)',
390+
color: 'var(--ow-fg-0)',
391+
fontFamily: 'inherit',
392+
fontSize: 13,
393+
cursor: generateMutation.isPending ? 'default' : 'pointer',
394+
}}
395+
>
396+
<option value={7}>Last 7 days</option>
397+
<option value={30}>Last 30 days</option>
398+
<option value={90}>Last 90 days</option>
314399
</select>
315400
)}
316401
{canGenerate && (
@@ -764,15 +849,17 @@ function ReportDetail({
764849
// evidence bundle / the full waiver register). JSON is offered for every
765850
// kind (it is the signed canonical face).
766851
const kind = resolved?.kind;
767-
const csvLed = kind === 'attestation' || kind === 'exception';
852+
const csvLed = kind === 'attestation' || kind === 'exception' || kind === 'remediation';
768853
const primaryFace: 'pdf' | 'csv' = csvLed ? 'csv' : 'pdf';
769854
const primaryLabel = csvLed ? 'Download CSV' : 'Download PDF';
770855
const primaryTitle =
771856
kind === 'attestation'
772857
? 'Download the per-host, per-rule CSV evidence'
773858
: kind === 'exception'
774859
? 'Download the full exception register (CSV)'
775-
: 'Download the one-page executive PDF';
860+
: kind === 'remediation'
861+
? 'Download the full remediation activity log (CSV)'
862+
: 'Download the one-page executive PDF';
776863

777864
// Secondary faces offered beside the primary + JSON. An attestation also
778865
// exposes its bounded PDF cover and the fleet OSCAL SAR; an exception
@@ -790,7 +877,9 @@ function ReportDetail({
790877
]
791878
: kind === 'exception'
792879
? [{ face: 'pdf', label: 'PDF', title: 'Download the one-page exception summary PDF' }]
793-
: [];
880+
: kind === 'remediation'
881+
? [{ face: 'pdf', label: 'PDF', title: 'Download the one-page remediation summary PDF' }]
882+
: [];
794883

795884
return (
796885
<div
@@ -1019,6 +1108,8 @@ function ReportDetail({
10191108
<AttestationBody content={asAttestationContent(resolved.content)} />
10201109
) : resolved.kind === 'exception' ? (
10211110
<ExceptionBody content={asExceptionContent(resolved.content)} />
1111+
) : resolved.kind === 'remediation' ? (
1112+
<RemediationBody content={asRemediationContent(resolved.content)} />
10221113
) : (
10231114
<ExecutiveBody content={asExecutiveContent(resolved.content)} />
10241115
))}
@@ -1382,6 +1473,99 @@ function ExceptionBody({ content }: { content: ExceptionContent }) {
13821473
);
13831474
}
13841475

1476+
function RemediationBody({ content }: { content: RemediationContent }) {
1477+
const s = content.summary;
1478+
const recent = content.activities.slice(0, 12);
1479+
const period =
1480+
content.period_from && content.period_to
1481+
? `${formatDate(content.period_from)} to ${formatDate(content.period_to)}`
1482+
: 'n/a';
1483+
return (
1484+
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
1485+
<section>
1486+
<SectionHead>Remediation requests</SectionHead>
1487+
<div
1488+
style={{
1489+
display: 'grid',
1490+
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
1491+
gap: 12,
1492+
}}
1493+
>
1494+
<Stat label="Period" value={period} />
1495+
<Stat label="Total requests" value={`${s.total}`} />
1496+
<Stat label="Executed" value={`${s.executed}`} tone="var(--ow-ok)" />
1497+
<Stat label="Rolled back" value={`${s.rolled_back}`} tone="var(--ow-warn)" />
1498+
<Stat label="Failed" value={`${s.failed}`} tone="var(--ow-crit)" />
1499+
<Stat label="Rejected" value={`${s.rejected}`} />
1500+
<Stat label="In progress" value={`${s.pending}`} />
1501+
</div>
1502+
</section>
1503+
1504+
<section>
1505+
<SectionHead>Recent activity</SectionHead>
1506+
{recent.length === 0 ? (
1507+
<div style={{ fontSize: 13, color: 'var(--ow-fg-3)', padding: '8px 0' }}>
1508+
No remediation requests in this period.
1509+
</div>
1510+
) : (
1511+
<Panel>
1512+
<Row head cols="1fr 1fr 120px">
1513+
<span>Host</span>
1514+
<span>Rule</span>
1515+
<span>Status</span>
1516+
</Row>
1517+
{recent.map((r, i) => (
1518+
<Row
1519+
key={`${r.host_name}:${r.rule_id}:${r.requested_at}`}
1520+
cols="1fr 1fr 120px"
1521+
first={i === 0}
1522+
>
1523+
<span
1524+
style={{
1525+
fontSize: 12,
1526+
color: 'var(--ow-fg-1)',
1527+
overflow: 'hidden',
1528+
textOverflow: 'ellipsis',
1529+
whiteSpace: 'nowrap',
1530+
}}
1531+
>
1532+
{r.host_name}
1533+
</span>
1534+
<span
1535+
style={{
1536+
fontSize: 12,
1537+
fontFamily: 'var(--ow-font-mono, monospace)',
1538+
color: 'var(--ow-fg-1)',
1539+
overflow: 'hidden',
1540+
textOverflow: 'ellipsis',
1541+
whiteSpace: 'nowrap',
1542+
}}
1543+
>
1544+
{r.rule_id}
1545+
</span>
1546+
<span style={{ fontSize: 13, color: 'var(--ow-fg-1)' }}>{r.status}</span>
1547+
</Row>
1548+
))}
1549+
</Panel>
1550+
)}
1551+
</section>
1552+
1553+
<div
1554+
style={{
1555+
fontSize: 12,
1556+
color: 'var(--ow-fg-3)',
1557+
lineHeight: 1.5,
1558+
paddingTop: 4,
1559+
borderTop: '1px solid var(--ow-line)',
1560+
}}
1561+
>
1562+
Remediation requests filed in the period above. The full activity log (every request with
1563+
its requester, approver, mechanism, and timestamps) is in the downloadable CSV face above.
1564+
</div>
1565+
</div>
1566+
);
1567+
}
1568+
13851569
function ComingSoon({ what }: { what: string }) {
13861570
return (
13871571
<Panel>

0 commit comments

Comments
 (0)