@skyhook-io/k8s-ui 1.7.13 → 1.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -7
- package/src/components/audit/index.ts +1 -1
- package/src/components/checks/ChecksView.tsx +269 -142
- package/src/components/checks/index.ts +14 -1
- package/src/components/issues/IssuesView.tsx +60 -23
- package/src/components/issues/index.ts +2 -2
- package/src/components/issues/issues.test.ts +7 -0
- package/src/components/issues/types.ts +37 -7
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesView.tsx +167 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/resource-utils.ts +39 -18
- package/src/components/shared/ResourceActionsBar.tsx +6 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -2
- package/src/components/ui/drawer-components.tsx +8 -0
- package/src/components/workload/WorkloadView.tsx +4 -4
- package/src/types/core.ts +10 -0
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.15",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"@monaco-editor/react": "^4.7.0",
|
|
70
70
|
"html-to-image": "^1.11.0",
|
|
71
71
|
"react-virtuoso": "^4.18.7",
|
|
72
|
-
"shiki": "^4.
|
|
72
|
+
"shiki": "^4.2.0"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@xterm/addon-fit": ">=0.10.0",
|
|
@@ -85,8 +85,7 @@
|
|
|
85
85
|
"yaml": ">=2.0.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@types/
|
|
89
|
-
"@types/react": "^19.2.14",
|
|
88
|
+
"@types/react": "^19.2.17",
|
|
90
89
|
"@types/react-dom": "^19.2.3",
|
|
91
90
|
"@xterm/addon-fit": "^0.11.0",
|
|
92
91
|
"@xterm/addon-web-links": "^0.12.0",
|
|
@@ -96,10 +95,10 @@
|
|
|
96
95
|
"diff": "^9.0.0",
|
|
97
96
|
"elkjs": "^0.11.1",
|
|
98
97
|
"lucide-react": "^1.16.0",
|
|
99
|
-
"react": "^19.2.
|
|
100
|
-
"react-dom": "^19.2.
|
|
98
|
+
"react": "^19.2.7",
|
|
99
|
+
"react-dom": "^19.2.7",
|
|
101
100
|
"typescript": "^6.0.2",
|
|
102
|
-
"vitest": "^4.1.
|
|
101
|
+
"vitest": "^4.1.8",
|
|
103
102
|
"yaml": "^2.9.0"
|
|
104
103
|
}
|
|
105
104
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { AuditCard, type AuditCardData } from './AuditCard'
|
|
2
2
|
export { AuditAlerts, type AuditFinding } from './AuditAlerts'
|
|
3
|
-
export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta } from './AuditFindingsTable'
|
|
3
|
+
export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta, type CheckReference } from './AuditFindingsTable'
|
|
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
|
|
4
4
|
import { ClusterName, EmptyState, FilterPill } from '../ui'
|
|
5
|
-
import type { CheckMeta } from '../audit'
|
|
5
|
+
import type { CheckMeta, CheckReference } from '../audit'
|
|
6
6
|
import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
|
|
7
7
|
import {
|
|
8
8
|
SEVERITY_BADGE_CLASS,
|
|
@@ -255,11 +255,11 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
255
255
|
</div>
|
|
256
256
|
</div>
|
|
257
257
|
|
|
258
|
-
<
|
|
258
|
+
<CheckSeverityBar totals={totals} />
|
|
259
259
|
|
|
260
260
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
261
261
|
{CHECK_SEVERITIES.map((s) => (
|
|
262
|
-
<
|
|
262
|
+
<CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(setSeverityFilter, s)} />
|
|
263
263
|
))}
|
|
264
264
|
<span className="mx-1.5 h-5 w-px bg-theme-border" />
|
|
265
265
|
{CATEGORIES.map((c) => (
|
|
@@ -366,7 +366,7 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
366
366
|
)
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
function
|
|
369
|
+
export function CheckSeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
|
|
370
370
|
const sum = CHECK_SEVERITIES.reduce((n, s) => n + totals[s], 0)
|
|
371
371
|
return (
|
|
372
372
|
<div className="flex h-1.5 overflow-hidden rounded-full bg-theme-elevated" role="img" aria-label="Severity distribution">
|
|
@@ -381,7 +381,7 @@ function SeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
|
|
|
381
381
|
)
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
-
function
|
|
384
|
+
export function CheckSeverityChip({ severity, count, active, onClick }: { severity: CheckSeverity; count: number; active: boolean; onClick: () => void }) {
|
|
385
385
|
return (
|
|
386
386
|
<button
|
|
387
387
|
type="button"
|
|
@@ -399,36 +399,117 @@ function SeverityChip({ severity, count, active, onClick }: { severity: CheckSev
|
|
|
399
399
|
)
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
402
|
+
// Re-export the audit CheckReference (single source of truth) — CheckMeta.references
|
|
403
|
+
// is typed against this same shape, so the two must not drift apart.
|
|
404
|
+
export type { CheckReference }
|
|
405
|
+
|
|
406
|
+
export interface CheckRemediationBlockProps {
|
|
407
|
+
description?: string
|
|
408
|
+
remediation?: string
|
|
409
|
+
references?: CheckReference[]
|
|
410
|
+
layout?: 'columns' | 'stack'
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function CheckRemediationBlock({ description, remediation, references, layout = 'columns' }: CheckRemediationBlockProps) {
|
|
414
|
+
if (!description && !remediation && (!references || references.length === 0)) return null
|
|
415
|
+
|
|
416
|
+
if (layout === 'stack') {
|
|
417
|
+
return (
|
|
418
|
+
<div className="flex flex-col gap-2">
|
|
419
|
+
{description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{description}</p>}
|
|
420
|
+
{remediation && (
|
|
421
|
+
<div>
|
|
422
|
+
<div className="text-[11px] uppercase tracking-wider text-theme-text-tertiary">How to fix</div>
|
|
423
|
+
<p className="mt-0.5 text-[13px] leading-relaxed text-theme-text-secondary">{remediation}</p>
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
{references && references.length > 0 && <CheckReferenceLinks references={references} />}
|
|
427
|
+
</div>
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<>
|
|
433
|
+
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
|
|
434
|
+
{remediation && (
|
|
435
|
+
<section className="md:flex-1">
|
|
436
|
+
<h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-radar-accent)]">
|
|
437
|
+
<Wrench className="h-3.5 w-3.5" /> How to fix
|
|
438
|
+
</h4>
|
|
439
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">{remediation}</p>
|
|
440
|
+
</section>
|
|
441
|
+
)}
|
|
442
|
+
{description && (
|
|
443
|
+
<section className="md:flex-1">
|
|
444
|
+
<h4 className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What this checks</h4>
|
|
445
|
+
<p className="text-sm leading-relaxed text-theme-text-secondary">{description}</p>
|
|
446
|
+
</section>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
{references && references.length > 0 && <CheckReferenceLinks references={references} />}
|
|
450
|
+
</>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function CheckReferenceLinks({ references }: { references: CheckReference[] }) {
|
|
455
|
+
return (
|
|
456
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
457
|
+
{references.map((r) => (
|
|
458
|
+
<a
|
|
459
|
+
key={r.url}
|
|
460
|
+
href={r.url}
|
|
461
|
+
target="_blank"
|
|
462
|
+
rel="noreferrer"
|
|
463
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
|
|
464
|
+
>
|
|
465
|
+
{r.label}
|
|
466
|
+
<ExternalLink className="h-3 w-3" />
|
|
467
|
+
</a>
|
|
468
|
+
))}
|
|
469
|
+
</div>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface CheckCardShellProps {
|
|
474
|
+
title: ReactNode
|
|
475
|
+
category: string
|
|
476
|
+
severity: CheckSeverity
|
|
416
477
|
open: boolean
|
|
417
478
|
onToggle: () => void
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const menuItems: { label: string; onClick: () => void }[] = []
|
|
427
|
-
if (onHideCheck) menuItems.push({ label: `Hide "${fc.title}" check`, onClick: () => onHideCheck(fc.checkID, fc.title) })
|
|
428
|
-
if (onHideCategory) menuItems.push({ label: `Hide all ${fc.category} checks`, onClick: () => onHideCategory(fc.category) })
|
|
479
|
+
summary?: ReactNode
|
|
480
|
+
description?: ReactNode
|
|
481
|
+
renderActions?: () => ReactNode
|
|
482
|
+
children?: ReactNode
|
|
483
|
+
as?: 'li' | 'div'
|
|
484
|
+
className?: string
|
|
485
|
+
dimmed?: boolean
|
|
486
|
+
}
|
|
429
487
|
|
|
488
|
+
export function CheckCardShell({
|
|
489
|
+
title,
|
|
490
|
+
category,
|
|
491
|
+
severity,
|
|
492
|
+
open,
|
|
493
|
+
onToggle,
|
|
494
|
+
summary,
|
|
495
|
+
description,
|
|
496
|
+
renderActions,
|
|
497
|
+
children,
|
|
498
|
+
as = 'li',
|
|
499
|
+
className,
|
|
500
|
+
dimmed,
|
|
501
|
+
}: CheckCardShellProps) {
|
|
502
|
+
const Container = as
|
|
430
503
|
return (
|
|
431
|
-
<
|
|
504
|
+
<Container
|
|
505
|
+
className={[
|
|
506
|
+
'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
|
|
507
|
+
dimmed ? 'opacity-60' : '',
|
|
508
|
+
className ?? '',
|
|
509
|
+
]
|
|
510
|
+
.filter(Boolean)
|
|
511
|
+
.join(' ')}
|
|
512
|
+
>
|
|
432
513
|
<div
|
|
433
514
|
role="button"
|
|
434
515
|
tabIndex={0}
|
|
@@ -441,158 +522,100 @@ function FleetCheckRow({
|
|
|
441
522
|
onToggle()
|
|
442
523
|
}
|
|
443
524
|
}}
|
|
444
|
-
className={`group flex cursor-pointer items-
|
|
525
|
+
className={`group flex cursor-pointer items-start gap-3 border-l-2 py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${SEVERITY_RAIL_CLASS[severity]}`}
|
|
445
526
|
>
|
|
446
|
-
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
527
|
+
<ChevronRight className={`mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
447
528
|
|
|
448
|
-
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
449
|
-
<div className="flex items-center gap-2">
|
|
450
|
-
<span className="truncate text-sm font-
|
|
451
|
-
<span className={`badge-sm shrink-0 text-[10px] ${categoryBadgeClass(
|
|
452
|
-
</div>
|
|
453
|
-
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
454
|
-
<span className="shrink-0 font-medium text-theme-text-secondary tabular-nums">
|
|
455
|
-
{fc.totalResources} {fc.totalResources === 1 ? 'resource' : 'resources'}
|
|
456
|
-
</span>
|
|
457
|
-
{clusterCount > 1 && (
|
|
458
|
-
<>
|
|
459
|
-
<span aria-hidden>·</span>
|
|
460
|
-
<span className="shrink-0 tabular-nums">{clusterCount} clusters</span>
|
|
461
|
-
</>
|
|
462
|
-
)}
|
|
529
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
530
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
531
|
+
<span className="truncate text-sm font-semibold text-theme-text-primary">{title}</span>
|
|
532
|
+
<span className={`badge-sm shrink-0 text-[10px] ${categoryBadgeClass(category)}`}>{category}</span>
|
|
463
533
|
</div>
|
|
534
|
+
{description}
|
|
535
|
+
{summary}
|
|
464
536
|
</div>
|
|
465
537
|
|
|
466
|
-
<span className={`badge-sm shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[
|
|
467
|
-
|
|
538
|
+
<span className={`badge-sm mt-0.5 shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[severity]}`}>
|
|
539
|
+
{SEVERITY_LABEL[severity]}
|
|
540
|
+
</span>
|
|
541
|
+
{renderActions?.()}
|
|
468
542
|
</div>
|
|
469
543
|
|
|
470
544
|
{/* Kept mounted (not `open &&`) so the grid-rows transition animates the
|
|
471
|
-
collapse too; inert when closed so SR + tab skip
|
|
545
|
+
collapse too, matching IssueRow; inert when closed so SR + tab skip
|
|
546
|
+
the clipped content. */}
|
|
472
547
|
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
|
|
473
548
|
<div className="overflow-hidden" inert={!open || undefined}>
|
|
474
|
-
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
|
|
475
|
-
<div className="flex flex-col gap-4">
|
|
476
|
-
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
|
|
477
|
-
{meta?.remediation && (
|
|
478
|
-
<section className="md:flex-1">
|
|
479
|
-
<h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-radar-accent)]">
|
|
480
|
-
<Wrench className="h-3.5 w-3.5" /> How to fix
|
|
481
|
-
</h4>
|
|
482
|
-
<p className="text-sm leading-relaxed text-theme-text-primary">{meta.remediation}</p>
|
|
483
|
-
</section>
|
|
484
|
-
)}
|
|
485
|
-
{meta?.description && (
|
|
486
|
-
<section className="md:flex-1">
|
|
487
|
-
<h4 className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What this checks</h4>
|
|
488
|
-
<p className="text-sm leading-relaxed text-theme-text-secondary">{meta.description}</p>
|
|
489
|
-
</section>
|
|
490
|
-
)}
|
|
491
|
-
</div>
|
|
492
|
-
|
|
493
|
-
{meta?.references && meta.references.length > 0 && (
|
|
494
|
-
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
495
|
-
{meta.references.map((r) => (
|
|
496
|
-
<a
|
|
497
|
-
key={r.url}
|
|
498
|
-
href={r.url}
|
|
499
|
-
target="_blank"
|
|
500
|
-
rel="noreferrer"
|
|
501
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
|
|
502
|
-
>
|
|
503
|
-
{r.label}
|
|
504
|
-
<ExternalLink className="h-3 w-3" />
|
|
505
|
-
</a>
|
|
506
|
-
))}
|
|
507
|
-
</div>
|
|
508
|
-
)}
|
|
509
|
-
|
|
510
|
-
<div className="border-t border-theme-border/70 pt-3">
|
|
511
|
-
{single ? (
|
|
512
|
-
<ResourceList
|
|
513
|
-
label={`Affected resources (${fc.totalResources})`}
|
|
514
|
-
check={fc.clusters[0]}
|
|
515
|
-
resourceHref={resourceHref}
|
|
516
|
-
onResourceClick={onResourceClick}
|
|
517
|
-
/>
|
|
518
|
-
) : (
|
|
519
|
-
<ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
520
|
-
)}
|
|
521
|
-
</div>
|
|
522
|
-
</div>
|
|
523
|
-
</div>
|
|
549
|
+
<div className="flex flex-col gap-4 border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">{children}</div>
|
|
524
550
|
</div>
|
|
525
551
|
</div>
|
|
526
|
-
</
|
|
552
|
+
</Container>
|
|
527
553
|
)
|
|
528
554
|
}
|
|
529
555
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
556
|
+
export interface CheckClusterBreakdownGroup {
|
|
557
|
+
id: string
|
|
558
|
+
label: ReactNode
|
|
559
|
+
environment?: string
|
|
560
|
+
count: ReactNode
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export interface CheckClusterBreakdownShellProps<T extends CheckClusterBreakdownGroup> {
|
|
564
|
+
heading: ReactNode
|
|
565
|
+
groups: T[]
|
|
566
|
+
renderGroupBody: (group: T) => ReactNode
|
|
567
|
+
clusterCap?: number
|
|
568
|
+
autoExpandLimit?: number
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>({
|
|
572
|
+
heading,
|
|
573
|
+
groups,
|
|
574
|
+
renderGroupBody,
|
|
575
|
+
clusterCap = CLUSTER_CAP,
|
|
576
|
+
autoExpandLimit = AUTO_EXPAND_CLUSTERS,
|
|
577
|
+
}: CheckClusterBreakdownShellProps<T>) {
|
|
545
578
|
const [openClusters, setOpenClusters] = useState<Set<string>>(
|
|
546
|
-
() => new Set(
|
|
579
|
+
() => new Set(groups.length <= autoExpandLimit ? groups.map((g) => g.id) : []),
|
|
547
580
|
)
|
|
548
581
|
const [showAllClusters, setShowAllClusters] = useState(false)
|
|
549
|
-
const shown = showAllClusters ?
|
|
550
|
-
const hiddenClusters =
|
|
582
|
+
const shown = showAllClusters ? groups : groups.slice(0, clusterCap)
|
|
583
|
+
const hiddenClusters = groups.length - shown.length
|
|
551
584
|
|
|
552
585
|
return (
|
|
553
586
|
<section className="flex flex-col gap-1.5">
|
|
554
|
-
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
555
|
-
Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
|
|
556
|
-
</h4>
|
|
587
|
+
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{heading}</h4>
|
|
557
588
|
<ul className="flex flex-col gap-1">
|
|
558
|
-
{shown.map((
|
|
559
|
-
const
|
|
560
|
-
const isOpen = openClusters.has(id)
|
|
561
|
-
const env = c.environment
|
|
589
|
+
{shown.map((group) => {
|
|
590
|
+
const isOpen = openClusters.has(group.id)
|
|
562
591
|
return (
|
|
563
|
-
<li key={id} className="rounded-lg border border-theme-border/70 bg-theme-surface">
|
|
592
|
+
<li key={group.id} className="rounded-lg border border-theme-border/70 bg-theme-surface">
|
|
564
593
|
<button
|
|
565
594
|
type="button"
|
|
566
595
|
aria-expanded={isOpen}
|
|
567
596
|
onClick={() =>
|
|
568
597
|
setOpenClusters((prev) => {
|
|
569
598
|
const next = new Set(prev)
|
|
570
|
-
if (next.has(id)) next.delete(id)
|
|
571
|
-
else next.add(id)
|
|
599
|
+
if (next.has(group.id)) next.delete(group.id)
|
|
600
|
+
else next.add(group.id)
|
|
572
601
|
return next
|
|
573
602
|
})
|
|
574
603
|
}
|
|
575
604
|
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-theme-hover/50"
|
|
576
605
|
>
|
|
577
606
|
<ChevronDown className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${isOpen ? '' : '-rotate-90'}`} />
|
|
578
|
-
<span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">
|
|
579
|
-
|
|
580
|
-
</span>
|
|
581
|
-
{env && (
|
|
607
|
+
<span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">{group.label}</span>
|
|
608
|
+
{group.environment && (
|
|
582
609
|
<span className="shrink-0 rounded bg-theme-elevated px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-secondary ring-1 ring-theme-border">
|
|
583
|
-
{
|
|
610
|
+
{group.environment}
|
|
584
611
|
</span>
|
|
585
612
|
)}
|
|
586
613
|
<span className="flex-1" />
|
|
587
|
-
<span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">
|
|
588
|
-
{c.affectedResources} {c.affectedResources === 1 ? 'resource' : 'resources'}
|
|
589
|
-
</span>
|
|
614
|
+
<span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">{group.count}</span>
|
|
590
615
|
</button>
|
|
591
616
|
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}>
|
|
592
617
|
<div className="overflow-hidden" inert={!isOpen || undefined}>
|
|
593
|
-
<div className="px-2.5 pb-2 pl-7">
|
|
594
|
-
<ResourceList check={c} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
595
|
-
</div>
|
|
618
|
+
<div className="px-2.5 pb-2 pl-7">{renderGroupBody(group)}</div>
|
|
596
619
|
</div>
|
|
597
620
|
</div>
|
|
598
621
|
</li>
|
|
@@ -605,13 +628,117 @@ function ClusterBreakdown({
|
|
|
605
628
|
onClick={() => setShowAllClusters(true)}
|
|
606
629
|
className="mt-0.5 inline-flex w-fit items-center gap-1 rounded px-2 py-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
|
|
607
630
|
>
|
|
608
|
-
View all {
|
|
631
|
+
View all {groups.length} clusters →
|
|
609
632
|
</button>
|
|
610
633
|
)}
|
|
611
634
|
</section>
|
|
612
635
|
)
|
|
613
636
|
}
|
|
614
637
|
|
|
638
|
+
function FleetCheckRow({
|
|
639
|
+
fc,
|
|
640
|
+
meta,
|
|
641
|
+
clusterLabel,
|
|
642
|
+
open,
|
|
643
|
+
onToggle,
|
|
644
|
+
resourceHref,
|
|
645
|
+
onResourceClick,
|
|
646
|
+
onHideCheck,
|
|
647
|
+
onHideCategory,
|
|
648
|
+
}: {
|
|
649
|
+
fc: FleetCheck
|
|
650
|
+
meta?: CheckMeta
|
|
651
|
+
clusterLabel?: (check: Check) => string | undefined
|
|
652
|
+
open: boolean
|
|
653
|
+
onToggle: () => void
|
|
654
|
+
resourceHref?: (ref: CheckResourceRef) => string
|
|
655
|
+
onResourceClick?: (ref: CheckResourceRef) => void
|
|
656
|
+
onHideCheck?: (checkID: string, title: string) => void
|
|
657
|
+
onHideCategory?: (category: string) => void
|
|
658
|
+
}) {
|
|
659
|
+
const clusterCount = fc.clusters.length
|
|
660
|
+
const single = clusterCount <= 1
|
|
661
|
+
|
|
662
|
+
const menuItems: { label: string; onClick: () => void }[] = []
|
|
663
|
+
if (onHideCheck) menuItems.push({ label: `Hide "${fc.title}" check`, onClick: () => onHideCheck(fc.checkID, fc.title) })
|
|
664
|
+
if (onHideCategory) menuItems.push({ label: `Hide all ${fc.category} checks`, onClick: () => onHideCategory(fc.category) })
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<CheckCardShell
|
|
668
|
+
title={fc.title}
|
|
669
|
+
category={fc.category}
|
|
670
|
+
severity={fc.severity}
|
|
671
|
+
open={open}
|
|
672
|
+
onToggle={onToggle}
|
|
673
|
+
summary={
|
|
674
|
+
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
675
|
+
<span className="shrink-0 font-medium text-theme-text-secondary tabular-nums">
|
|
676
|
+
{fc.totalResources} {fc.totalResources === 1 ? 'resource' : 'resources'}
|
|
677
|
+
</span>
|
|
678
|
+
{clusterCount > 1 && (
|
|
679
|
+
<>
|
|
680
|
+
<span aria-hidden>·</span>
|
|
681
|
+
<span className="shrink-0 tabular-nums">{clusterCount} clusters</span>
|
|
682
|
+
</>
|
|
683
|
+
)}
|
|
684
|
+
</div>
|
|
685
|
+
}
|
|
686
|
+
renderActions={() => (menuItems.length > 0 ? <RowMenu items={menuItems} /> : null)}
|
|
687
|
+
>
|
|
688
|
+
<CheckRemediationBlock description={meta?.description} remediation={meta?.remediation} references={meta?.references} />
|
|
689
|
+
|
|
690
|
+
<div className="border-t border-theme-border/70 pt-3">
|
|
691
|
+
{single ? (
|
|
692
|
+
<ResourceList
|
|
693
|
+
label={`Affected resources (${fc.totalResources})`}
|
|
694
|
+
check={fc.clusters[0]}
|
|
695
|
+
resourceHref={resourceHref}
|
|
696
|
+
onResourceClick={onResourceClick}
|
|
697
|
+
/>
|
|
698
|
+
) : (
|
|
699
|
+
<ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
</CheckCardShell>
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Per-cluster sub-groups for a check that spans the fleet. Each cluster is a
|
|
707
|
+
// collapsible row ("cluster · K resources"); open it for that cluster's
|
|
708
|
+
// resources. Caps the cluster list so a check across hundreds of clusters
|
|
709
|
+
// stays scannable.
|
|
710
|
+
function ClusterBreakdown({
|
|
711
|
+
fc,
|
|
712
|
+
clusterLabel,
|
|
713
|
+
resourceHref,
|
|
714
|
+
onResourceClick,
|
|
715
|
+
}: {
|
|
716
|
+
fc: FleetCheck
|
|
717
|
+
clusterLabel?: (check: Check) => string | undefined
|
|
718
|
+
resourceHref?: (ref: CheckResourceRef) => string
|
|
719
|
+
onResourceClick?: (ref: CheckResourceRef) => void
|
|
720
|
+
}) {
|
|
721
|
+
return (
|
|
722
|
+
<CheckClusterBreakdownShell
|
|
723
|
+
heading={
|
|
724
|
+
<>
|
|
725
|
+
Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
|
|
726
|
+
</>
|
|
727
|
+
}
|
|
728
|
+
groups={fc.clusters.map((c) => ({
|
|
729
|
+
id: c.subject.cluster_id,
|
|
730
|
+
label: <ClusterName name={clusterLabel?.(c) || c.subject.cluster_id} />,
|
|
731
|
+
environment: c.environment,
|
|
732
|
+
count: `${c.affectedResources} ${c.affectedResources === 1 ? 'resource' : 'resources'}`,
|
|
733
|
+
check: c,
|
|
734
|
+
}))}
|
|
735
|
+
renderGroupBody={(group) => (
|
|
736
|
+
<ResourceList check={group.check} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
737
|
+
)}
|
|
738
|
+
/>
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
615
742
|
// The resource lines for one cluster's check. `label` (set in the single-cluster
|
|
616
743
|
// case) renders a section heading; in the cluster-breakdown case the cluster row
|
|
617
744
|
// already provides the heading, so it's omitted.
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
CheckCardShell,
|
|
3
|
+
CheckClusterBreakdownShell,
|
|
4
|
+
CheckRemediationBlock,
|
|
5
|
+
CheckSeverityBar,
|
|
6
|
+
CheckSeverityChip,
|
|
7
|
+
ChecksView,
|
|
8
|
+
type CheckCardShellProps,
|
|
9
|
+
type CheckClusterBreakdownGroup,
|
|
10
|
+
type CheckClusterBreakdownShellProps,
|
|
11
|
+
type CheckReference,
|
|
12
|
+
type CheckRemediationBlockProps,
|
|
13
|
+
type ChecksViewProps,
|
|
14
|
+
} from './ChecksView'
|
|
2
15
|
export * from './types'
|
|
3
16
|
export * from './severity'
|