Drive GetHTMLReport from ctx.States()
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
nemunaire 2026-05-18 15:30:19 +08:00
commit 16497f2ec1
2 changed files with 119 additions and 55 deletions

View file

@ -12,11 +12,10 @@ import (
// GetHTMLReport renders the legacy-records observation as a self-contained // GetHTMLReport renders the legacy-records observation as a self-contained
// HTML page suitable for iframe embedding. // HTML page suitable for iframe embedding.
// //
// The "fix this first" card is driven by the most-severe finding (no fixed // Cards are built from ctx.States(): each finding state carries the full
// rule wins by name): SeverityCrit > SeverityWarn > SeverityInfo, with the // metadata (reason, replacement, how-to-fix, locations) in CheckState.Meta.
// alphabetically-first type name as a stable tie-break. This matches what // The rule already emits states in descending severity order, so the first
// the rule sorter produces, so the top card and the rule output never // card is always the top priority without re-sorting here.
// disagree on which finding is "the" priority.
func (p *legacyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { func (p *legacyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data LegacyData var data LegacyData
if raw := ctx.Data(); len(raw) > 0 { if raw := ctx.Data(); len(raw) > 0 {
@ -65,48 +64,131 @@ func buildReportView(data *LegacyData, states []sdk.CheckState) *reportView {
CollectErrors: data.CollectErrors, CollectErrors: data.CollectErrors,
} }
groups := groupFindings(data.Findings) if len(states) == 0 {
cards := make([]findingCard, 0, len(groups)) // No rule output yet: data-only rendering with a neutral headline.
worst := SeverityInfo v.OverallStatus = "ok"
for _, g := range groups { v.OverallText = fmt.Sprintf("Data collected — %d service(s) scanned.", data.ServicesScanned)
info := deprecatedTypes[g.Rrtype] v.OverallClass = "status-ok"
if info.Severity > worst { return v
worst = info.Severity
} }
cards = append(cards, findingCard{
TypeName: g.TypeName, var cards []findingCard
Reason: info.Reason, for _, st := range states {
Replacement: info.Replacement, c, ok := cardFromState(st)
HowToFix: info.HowToFix, if !ok {
Severity: severityLabel(info.Severity), continue
SeverityCSS: info.Severity.String(), }
Count: len(g.Locations), cards = append(cards, c)
Locations: g.Locations,
})
} }
if len(cards) > 0 { if len(cards) > 0 {
v.Top = &cards[0] v.Top = &cards[0]
v.Others = cards[1:] v.Others = cards[1:]
v.OverallStatus = worst.String() worst := worstFindingStatus(states)
v.OverallText, v.OverallClass = overallLabel(worst) v.OverallStatus, v.OverallText, v.OverallClass = overallFromStatus(worst)
} else { } else if errState, ok := firstErrorState(states); ok {
// Honour the rule's status when present: an Error from the rule
// (e.g. observation load failure) must not be masked as "OK".
if errState, ok := firstErrorState(states); ok {
v.OverallStatus = "error" v.OverallStatus = "error"
v.OverallText = errState.Message v.OverallText = errState.Message
v.OverallClass = "status-crit" v.OverallClass = "status-crit"
} else { } else {
v.OverallStatus = "ok" v.OverallStatus = "ok"
v.OverallText = fmt.Sprintf("No legacy record types found across %d service(s).", data.ServicesScanned)
v.OverallClass = "status-ok" v.OverallClass = "status-ok"
v.OverallText = fmt.Sprintf("No legacy record types found across %d service(s).", data.ServicesScanned)
for _, st := range states {
if st.Code == "legacy_records_clean" {
v.OverallText = st.Message
break
}
} }
} }
return v return v
} }
// cardFromState builds a findingCard from a finding CheckState. States that
// carry no "locations" metadata (clean / error / skip states) return ok=false.
func cardFromState(st sdk.CheckState) (findingCard, bool) {
if st.Meta == nil {
return findingCard{}, false
}
rawLocs, ok := st.Meta["locations"]
if !ok {
return findingCard{}, false
}
locations := decodeLocations(rawLocs)
typeName, _ := st.Meta["type"].(string)
if typeName == "" {
typeName = st.Subject
}
reason, _ := st.Meta["reason"].(string)
replacement, _ := st.Meta["replacement"].(string)
howToFix, _ := st.Meta["how_to_fix"].(string)
sevLabel, sevCSS := severityFromStatus(st.Status)
return findingCard{
TypeName: typeName,
Reason: reason,
Replacement: replacement,
HowToFix: howToFix,
Severity: sevLabel,
SeverityCSS: sevCSS,
Count: len(locations),
Locations: locations,
}, true
}
// decodeLocations handles the JSON round-trip: Meta values are stored as any
// but pass through json.Marshal/Unmarshal when transmitted, so []FindingLocation
// arrives as []interface{} of map[string]interface{}. Re-marshaling restores
// the typed slice.
func decodeLocations(v any) []FindingLocation {
b, err := json.Marshal(v)
if err != nil {
return nil
}
var locs []FindingLocation
if err := json.Unmarshal(b, &locs); err != nil {
return nil
}
return locs
}
func severityFromStatus(s sdk.Status) (label, css string) {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return "Critical", "crit"
case sdk.StatusWarn:
return "Warning", "warn"
default:
return "Informational", "info"
}
}
func worstFindingStatus(states []sdk.CheckState) sdk.Status {
worst := sdk.StatusInfo
for _, st := range states {
switch st.Status {
case sdk.StatusCrit, sdk.StatusError:
return sdk.StatusCrit
case sdk.StatusWarn:
worst = sdk.StatusWarn
}
}
return worst
}
func overallFromStatus(s sdk.Status) (status, text, css string) {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return "crit", "Legacy records require urgent migration", "status-crit"
case sdk.StatusWarn:
return "warn", "Legacy records should be migrated", "status-warn"
default:
return "info", "Only informational legacy records found", "status-info"
}
}
func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) { func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) {
for i := range states { for i := range states {
if states[i].Status == sdk.StatusError { if states[i].Status == sdk.StatusError {
@ -116,28 +198,6 @@ func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) {
return sdk.CheckState{}, false return sdk.CheckState{}, false
} }
func severityLabel(s DeprecatedSeverity) string {
switch s {
case SeverityCrit:
return "Critical"
case SeverityWarn:
return "Warning"
default:
return "Informational"
}
}
func overallLabel(s DeprecatedSeverity) (text, css string) {
switch s {
case SeverityCrit:
return "Legacy records require urgent migration", "status-crit"
case SeverityWarn:
return "Legacy records should be migrated", "status-warn"
default:
return "Only informational legacy records found", "status-info"
}
}
var reportTmpl = template.Must(template.New("legacy-records-report").Funcs(template.FuncMap{ var reportTmpl = template.Must(template.New("legacy-records-report").Funcs(template.FuncMap{
"display": func(s string) string { "display": func(s string) string {
if s == "" || s == "@" { if s == "" || s == "@" {

View file

@ -184,8 +184,11 @@ func TestReport_TopCardMatchesWorstSeverity(t *testing.T) {
}, },
} }
data := runCollect(t, z) data := runCollect(t, z)
raw := mustMarshal(t, data)
states := (&legacyRecordsRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyLegacy, payload: raw}, sdk.CheckerOptions{})
html, err := (&legacyProvider{}).GetHTMLReport(staticReportCtx{data: mustMarshal(t, data)}) html, err := (&legacyProvider{}).GetHTMLReport(staticReportCtx{data: raw, states: states})
if err != nil { if err != nil {
t.Fatalf("GetHTMLReport: %v", err) t.Fatalf("GetHTMLReport: %v", err)
} }
@ -239,8 +242,9 @@ func (s staticObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.Re
type staticReportCtx struct { type staticReportCtx struct {
data []byte data []byte
states []sdk.CheckState
} }
func (s staticReportCtx) Data() json.RawMessage { return s.data } func (s staticReportCtx) Data() json.RawMessage { return s.data }
func (s staticReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation { return nil } func (s staticReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation { return nil }
func (s staticReportCtx) States() []sdk.CheckState { return nil } func (s staticReportCtx) States() []sdk.CheckState { return s.states }