Skip to content

Commit b655fac

Browse files
authored
feat(audit): badge resources with high-signal Cluster Audit findings (topology + list) (#1028)
1 parent 0b1211e commit b655fac

17 files changed

Lines changed: 381 additions & 21 deletions

File tree

internal/server/audit_handlers.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,22 @@ func (s *Server) handleAuditResource(w http.ResponseWriter, r *http.Request) {
166166
}
167167
index := bp.IndexByResource(results.Findings)
168168

169-
// Try exact kind first, then map API resource name (e.g. "deployments") to Go kind (e.g. "Deployment").
170-
// This handler is the UI's per-resource audit drill-down — group isn't on
171-
// the URL today (the UI doesn't list grouped CRDs here yet), so we look
172-
// up with group="" which matches the built-ins the audit suite scans.
173-
// When CRD audit lands (#35 follow-up), thread group through the URL.
174-
findings := index[bp.ResourceKey("", kind, namespace, name)]
175-
if findings == nil {
176-
goKind := apiResourceToKind(kind)
177-
if goKind != kind {
178-
findings = index[bp.ResourceKey("", goKind, namespace, name)]
179-
}
169+
// Resolve to the Go kind (built-in plural "deployments" → "Deployment"), then
170+
// look up with the audit's keying convention. Findings carry group backfilled
171+
// from the builtin table (built-ins → their group e.g. "apps"; CRDs → ""), so
172+
// a bare group="" lookup missed every built-in finding keyed under a real
173+
// group (Deployment under "apps", Job under "batch", …) — the drawer showed
174+
// nothing for them. Group still resolves to "" for CRDs, so those keep working.
175+
goKind := kind
176+
if mapped := apiResourceToKind(kind); mapped != kind {
177+
goKind = mapped
178+
}
179+
group := bp.GroupForBuiltinKind(goKind)
180+
findings := index[bp.ResourceKey(group, goKind, namespace, name)]
181+
if findings == nil && group != "" {
182+
// Defensive: resolve even if a finding for this kind was emitted with an
183+
// empty group despite the builtin table classifying it under a real one.
184+
findings = index[bp.ResourceKey("", goKind, namespace, name)]
180185
}
181186
if findings == nil {
182187
findings = []bp.Finding{}

packages/k8s-ui/src/components/audit/AuditAlerts.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../uti
55

66
export interface AuditFinding {
77
kind: string
8+
/** API group, backfilled by the backend from the builtin Kind→group table
9+
* (built-ins → e.g. "apps"/"batch"; CRDs → ""). Part of the resource key
10+
* used to join findings onto topology nodes / list rows. */
11+
group?: string
812
namespace: string
913
name: string
1014
checkID: string
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { renderToString } from 'react-dom/server'
3+
import { AuditBadgeTooltip } from './AuditBadgeTooltip'
4+
5+
const msgs = [
6+
{ severity: 'warning', message: 'Service selector matches no pods' },
7+
{ severity: 'danger', message: 'Ingress references missing Service' },
8+
{ severity: 'warning', message: 'Uses a deprecated API version' },
9+
{ severity: 'warning', message: 'A fourth finding' },
10+
]
11+
12+
describe('AuditBadgeTooltip', () => {
13+
it('lists each finding message up to the cap', () => {
14+
const html = renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 2)} />)
15+
expect(html).toContain('Service selector matches no pods')
16+
expect(html).toContain('Ingress references missing Service')
17+
expect(html).not.toContain('more')
18+
})
19+
20+
it('caps the list and collapses the rest into "+N more"', () => {
21+
const html = renderToString(<AuditBadgeTooltip messages={msgs} max={3} />)
22+
expect(html).toContain('+1 more')
23+
expect(html).not.toContain('A fourth finding')
24+
})
25+
26+
it('shows the click hint by default and omits it when disabled', () => {
27+
expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} />)).toContain('Click to open')
28+
expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} clickHint={false} />)).not.toContain('Click to open')
29+
})
30+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ShieldAlert, AlertTriangle } from 'lucide-react'
2+
import { clsx } from 'clsx'
3+
import { SEVERITY_TEXT } from '../../utils/badge-colors'
4+
5+
export interface AuditBadgeMessage {
6+
severity: string
7+
message: string
8+
}
9+
10+
interface AuditBadgeTooltipProps {
11+
messages: AuditBadgeMessage[]
12+
/** Max messages to list before collapsing the rest into "+N more". */
13+
max?: number
14+
/** Hint that clicking opens the resource — omitted when the badge isn't clickable. */
15+
clickHint?: boolean
16+
}
17+
18+
/**
19+
* Inline tooltip body for an audit badge: lists the actual finding messages
20+
* (danger-first) so the operator reads WHAT is wrong on hover, instead of a
21+
* content-free "N findings". Shared by the resource-list and topology-node
22+
* badges so the two can't drift.
23+
*/
24+
export function AuditBadgeTooltip({ messages, max = 3, clickHint = true }: AuditBadgeTooltipProps) {
25+
const shown = messages.slice(0, max)
26+
const overflow = messages.length - shown.length
27+
return (
28+
<div className="flex flex-col gap-1 text-left">
29+
{shown.map((m, i) => {
30+
const isDanger = m.severity === 'danger'
31+
const Icon = isDanger ? ShieldAlert : AlertTriangle
32+
return (
33+
<div key={i} className="flex items-start gap-1.5">
34+
<Icon className={clsx('w-3 h-3 shrink-0 mt-0.5', isDanger ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
35+
<span>{m.message}</span>
36+
</div>
37+
)
38+
})}
39+
{overflow > 0 && (
40+
<div className="text-theme-text-tertiary">{`+${overflow} more`}</div>
41+
)}
42+
{clickHint && (
43+
<div className="text-theme-text-tertiary mt-0.5">Click to open →</div>
44+
)}
45+
</div>
46+
)
47+
}

packages/k8s-ui/src/components/audit/AuditFindingsTable.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export interface CheckMeta {
2727
remediation: string
2828
frameworks?: string[]
2929
references?: CheckReference[]
30+
/** Finding means the specific resource is broken (reference-integrity /
31+
* lifecycle), worth a per-resource topology/list badge — vs posture /
32+
* best-practice checks that fire near-universally. Set by the backend registry. */
33+
badgeWorthy?: boolean
3034
}
3135

3236
/** An authoritative link for a check (K8s docs, CIS, NSA/CISA, …). */
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { AuditCard, type AuditCardData } from './AuditCard'
22
export { AuditAlerts, type AuditFinding } from './AuditAlerts'
3+
export { AuditBadgeTooltip, type AuditBadgeMessage } from './AuditBadgeTooltip'
34
export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta, type CheckReference } from './AuditFindingsTable'

packages/k8s-ui/src/components/resources/ResourcesView.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,12 @@ import {
129129
podMatchesProblemCategory,
130130
SEVERITY_DOT_COLOR,
131131
} from './resource-utils'
132-
import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
132+
import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
133133
import { pluralize } from '../../utils/pluralize'
134134
import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
135135
import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
136136
import { Tooltip } from '../ui/Tooltip'
137+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
137138
// CRD-specific cell components (extracted)
138139
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
139140
import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
@@ -1800,6 +1801,9 @@ interface ResourcesViewData {
18001801
onNavigate?: (path: string, options?: { replace?: boolean }) => void
18011802
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
18021803
certExpiryError?: boolean
1804+
// Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
1805+
// list shows one kind at a time, so ns/name is unambiguous). Host-injected.
1806+
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
18031807
onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
18041808
onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
18051809
}
@@ -1852,6 +1856,8 @@ interface ResourcesViewProps {
18521856
topNodeMetrics?: TopNodeMetrics[]
18531857
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
18541858
certExpiryError?: boolean
1859+
// Cluster Audit findings for the selected kind, keyed by "namespace/name".
1860+
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
18551861
// Pinned kinds
18561862
pinned?: Array<{ name: string; kind: string; group: string }>
18571863
togglePin?: (kind: { name: string; kind: string; group: string }) => void
@@ -2070,6 +2076,7 @@ export function ResourcesView({
20702076
topNodeMetrics,
20712077
certExpiry,
20722078
certExpiryError,
2079+
auditBadges,
20732080
pinned = [],
20742081
togglePin = () => {},
20752082
isPinned = () => false,
@@ -4014,9 +4021,10 @@ export function ResourcesView({
40144021
onNavigate,
40154022
certExpiry,
40164023
certExpiryError,
4024+
auditBadges,
40174025
onOpenLogs,
40184026
onOpenWorkloadLogs,
4019-
}), [onNavigate, certExpiry, certExpiryError, onOpenLogs, onOpenWorkloadLogs])
4027+
}), [onNavigate, certExpiry, certExpiryError, auditBadges, onOpenLogs, onOpenWorkloadLogs])
40204028

40214029
return (
40224030
<ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
@@ -5155,6 +5163,7 @@ interface CellContentProps {
51555163
}
51565164

51575165
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
5166+
const { auditBadges } = useContext(ResourcesViewDataContext)
51585167
// Parent-injected extra columns short-circuit the built-in switch.
51595168
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
51605169
if (extraColumn) {
@@ -5167,6 +5176,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
51675176
if (column === 'name') {
51685177
const isTerminating = !!meta.deletionTimestamp
51695178
const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
5179+
const audit = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
5180+
const auditTotal = audit ? audit.danger + audit.warning : 0
51705181
return (
51715182
<div className="flex items-center gap-1.5 min-w-0">
51725183
<Tooltip content={meta.name}>
@@ -5184,6 +5195,16 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
51845195
)}
51855196
</Tooltip>
51865197
<CopyNameButton name={meta.name} />
5198+
{auditTotal > 0 && audit && (
5199+
<Tooltip content={audit.messages && audit.messages.length > 0
5200+
? <AuditBadgeTooltip messages={audit.messages} />
5201+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
5202+
<span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>
5203+
<AlertTriangle className="w-3 h-3" />
5204+
{auditTotal}
5205+
</span>
5206+
</Tooltip>
5207+
)}
51875208
{isTerminating && (
51885209
<Tooltip content="Resource is being deleted (has deletionTimestamp set). May be stuck due to finalizers.">
51895210
<span className="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-red-500/15 text-red-600 dark:text-red-400 rounded">
@@ -6022,6 +6043,7 @@ function ReplicaSetCell({ resource, column }: { resource: any; column: string })
60226043
}
60236044

60246045
function ServiceCell({ resource, column }: { resource: any; column: string }) {
6046+
const { auditBadges } = useContext(ResourcesViewDataContext)
60256047
switch (column) {
60266048
case 'type': {
60276049
const status = getServiceStatus(resource)
@@ -6042,6 +6064,20 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
60426064
)
60436065
}
60446066
case 'endpoints': {
6067+
// getServiceEndpointsStatus can't see live pods, so it optimistically
6068+
// reports "Active" for any service with a selector. The audit's
6069+
// serviceNoMatchingPods check DOES resolve the selector against live pods —
6070+
// the only badge-worthy finding a Service can carry — so when it fired,
6071+
// trust it over the guess instead of showing a false-green "Active".
6072+
const meta = resource.metadata || {}
6073+
const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
6074+
if (flagged && flagged.danger + flagged.warning > 0) {
6075+
return (
6076+
<span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
6077+
No endpoints
6078+
</span>
6079+
)
6080+
}
60456081
const { status, color } = getServiceEndpointsStatus(resource)
60466082
return (
60476083
<span className={clsx('badge', color)}>

packages/k8s-ui/src/components/topology/K8sResourceNode.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import { Handle, Position } from '@xyflow/react'
33
import {
44
ChevronDown,
55
ChevronUp,
6+
TriangleAlert,
67
} from 'lucide-react'
78
import { clsx } from 'clsx'
89
import type { NodeKind, HealthStatus, PodSummary } from '../../types'
910
import { displayKind } from '../../types'
10-
import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
11+
import { healthToSeverity, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
1112
import { workloadHue } from '../../utils/workload-colors'
1213
import { ownershipOf } from '../../utils/topology-neighborhood'
1314
import { midTruncate } from '../../utils/format'
1415
import { Tooltip } from '../ui/Tooltip'
16+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
1517

1618
// Get actionable tooltip content for health issues
1719
function getIssueTooltip(issue: string | undefined): React.ReactNode {
@@ -382,6 +384,15 @@ export const K8sResourceNode = memo(function K8sResourceNode({
382384
id,
383385
}: K8sResourceNodeProps) {
384386
const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
387+
// Cluster Audit findings joined onto this node by the host (web/ enriches each
388+
// node's data by auditKey). The host only counts "badge-worthy" findings —
389+
// reference-integrity / lifecycle, "this resource is actually broken" — not the
390+
// posture/best-practice nags that fire near-universally, so the indicator stays
391+
// a signal. Colored by worst severity (danger red, else warning amber).
392+
const auditDanger = typeof nodeData.auditDanger === 'number' ? nodeData.auditDanger : 0
393+
const auditWarning = typeof nodeData.auditWarning === 'number' ? nodeData.auditWarning : 0
394+
const auditTotal = auditDanger + auditWarning
395+
const auditMessages = Array.isArray(nodeData.auditMessages) ? (nodeData.auditMessages as AuditBadgeMessage[]) : []
385396
// Workload tint (application graph): a node owned by exactly one workload
386397
// carries that workload's hue. Only on healthy/unknown cards — degraded/
387398
// unhealthy already own the card background for health, which must win.
@@ -512,6 +523,16 @@ export const K8sResourceNode = memo(function K8sResourceNode({
512523
<ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
513524
</button>
514525
)}
526+
{auditTotal > 0 && (
527+
<Tooltip
528+
content={auditMessages.length > 0
529+
? <AuditBadgeTooltip messages={auditMessages} clickHint={false} />
530+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${auditDanger > 0 ? ` · ${auditDanger} danger` : ''}`}
531+
position="right"
532+
>
533+
<TriangleAlert className={clsx('w-3 h-3 cursor-help', auditDanger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
534+
</Tooltip>
535+
)}
515536
{issueTooltip ? (
516537
<Tooltip content={issueTooltip} position="right">
517538
<span className={clsx('w-1.5 h-1.5 rounded-full cursor-help', getStatusDotColor(status))} />

pkg/audit/helpers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ func ResourceKey(group, kind, namespace, name string) string {
1313
return resourceid.ResourceKey(group, kind, namespace, name)
1414
}
1515

16+
// GroupForBuiltinKind re-exports the builtin Kind→group table from pkg/resourceid
17+
// so callers resolving a finding's key (which backfills group the same way) don't
18+
// need a second import.
19+
func GroupForBuiltinKind(kind string) string {
20+
return resourceid.GroupForBuiltinKind(kind)
21+
}
22+
1623
// IndexByResource builds a lookup map from ResourceKey → []Finding.
1724
func IndexByResource(findings []Finding) map[string][]Finding {
1825
m := make(map[string][]Finding)

0 commit comments

Comments
 (0)