@@ -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+
172224function 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+
13851569function ComingSoon ( { what } : { what : string } ) {
13861570 return (
13871571 < Panel >
0 commit comments