@skyhook-io/k8s-ui 1.7.13 → 1.7.14
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 +1 -1
- 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 +56 -17
- package/src/components/issues/index.ts +2 -2
- package/src/components/issues/issues.test.ts +7 -0
- package/src/components/issues/types.ts +15 -5
- 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/package.json
CHANGED
|
@@ -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'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
|
|
3
3
|
import { ClusterName, EmptyState } from '../ui';
|
|
4
4
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
@@ -90,25 +90,48 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
issue
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
onResourceClick,
|
|
100
|
-
}: {
|
|
93
|
+
export interface IssueRowSlotContext {
|
|
94
|
+
issue: Issue;
|
|
95
|
+
open: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface IssueRowProps {
|
|
101
99
|
issue: Issue;
|
|
102
100
|
clusterLabel?: (issue: Issue) => string | undefined;
|
|
103
101
|
open: boolean;
|
|
104
102
|
onToggle: () => void;
|
|
105
103
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
106
104
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
107
|
-
|
|
105
|
+
as?: 'li' | 'div';
|
|
106
|
+
className?: string;
|
|
107
|
+
dimmed?: boolean;
|
|
108
|
+
renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
109
|
+
renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
110
|
+
renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
111
|
+
ResourceLinkIcon?: ComponentType<{ className?: string }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function IssueRow({
|
|
115
|
+
issue,
|
|
116
|
+
clusterLabel,
|
|
117
|
+
open,
|
|
118
|
+
onToggle,
|
|
119
|
+
resourceHref,
|
|
120
|
+
onResourceClick,
|
|
121
|
+
as = 'li',
|
|
122
|
+
className,
|
|
123
|
+
dimmed,
|
|
124
|
+
renderBadges,
|
|
125
|
+
renderMeta,
|
|
126
|
+
renderActions,
|
|
127
|
+
ResourceLinkIcon = ExternalLink,
|
|
128
|
+
}: IssueRowProps) {
|
|
108
129
|
const cluster = clusterLabel?.(issue);
|
|
109
130
|
const affected = affectedSummary(issue.affected);
|
|
110
131
|
const { headline } = issueMessageParts(issue);
|
|
111
132
|
const [renderDetails, setRenderDetails] = useState(open);
|
|
133
|
+
const Container = as;
|
|
134
|
+
const slotCtx = { issue, open };
|
|
112
135
|
|
|
113
136
|
useEffect(() => {
|
|
114
137
|
if (open) {
|
|
@@ -122,8 +145,12 @@ function IssueRow({
|
|
|
122
145
|
}, [open, renderDetails]);
|
|
123
146
|
|
|
124
147
|
return (
|
|
125
|
-
<
|
|
126
|
-
className=
|
|
148
|
+
<Container
|
|
149
|
+
className={[
|
|
150
|
+
'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
|
|
151
|
+
dimmed ? 'opacity-60' : '',
|
|
152
|
+
className ?? '',
|
|
153
|
+
].filter(Boolean).join(' ')}
|
|
127
154
|
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
|
|
128
155
|
>
|
|
129
156
|
{/* The whole header is the single toggle target — chevron is just the
|
|
@@ -149,6 +176,7 @@ function IssueRow({
|
|
|
149
176
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
150
177
|
<span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
151
178
|
<span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
179
|
+
{renderBadges?.(slotCtx)}
|
|
152
180
|
{/* The detector reason/message rides the title row so the most
|
|
153
181
|
useful triage signal is visible without expanding — it fills
|
|
154
182
|
the otherwise-empty band between the title and the severity
|
|
@@ -180,6 +208,7 @@ function IssueRow({
|
|
|
180
208
|
<span className="shrink-0 tabular-nums">{affected}</span>
|
|
181
209
|
</>
|
|
182
210
|
) : null}
|
|
211
|
+
{renderMeta?.(slotCtx)}
|
|
183
212
|
</div>
|
|
184
213
|
</div>
|
|
185
214
|
|
|
@@ -203,6 +232,7 @@ function IssueRow({
|
|
|
203
232
|
<span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
|
|
204
233
|
{ISSUE_SEVERITY_LABEL[issue.severity]}
|
|
205
234
|
</span>
|
|
235
|
+
{renderActions?.(slotCtx)}
|
|
206
236
|
</div>
|
|
207
237
|
|
|
208
238
|
{renderDetails ? (
|
|
@@ -218,16 +248,16 @@ function IssueRow({
|
|
|
218
248
|
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
|
|
219
249
|
<div className="flex flex-col gap-4">
|
|
220
250
|
<Diagnosis issue={issue} />
|
|
221
|
-
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
251
|
+
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
222
252
|
<div className="border-t border-theme-border/70 pt-3">
|
|
223
|
-
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
253
|
+
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
224
254
|
</div>
|
|
225
255
|
</div>
|
|
226
256
|
</div>
|
|
227
257
|
</div>
|
|
228
258
|
</div>
|
|
229
259
|
) : null}
|
|
230
|
-
</
|
|
260
|
+
</Container>
|
|
231
261
|
);
|
|
232
262
|
}
|
|
233
263
|
|
|
@@ -316,10 +346,12 @@ function DiagnosticContext({
|
|
|
316
346
|
issue,
|
|
317
347
|
resourceHref,
|
|
318
348
|
onResourceClick,
|
|
349
|
+
ResourceLinkIcon,
|
|
319
350
|
}: {
|
|
320
351
|
issue: Issue;
|
|
321
352
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
322
353
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
354
|
+
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
323
355
|
}) {
|
|
324
356
|
const ctx = issue.diagnostic_context;
|
|
325
357
|
const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
|
|
@@ -347,6 +379,7 @@ function DiagnosticContext({
|
|
|
347
379
|
refForLink={memberRef(issue, related.ref)}
|
|
348
380
|
resourceHref={resourceHref}
|
|
349
381
|
onResourceClick={onResourceClick}
|
|
382
|
+
ResourceLinkIcon={ResourceLinkIcon}
|
|
350
383
|
/>
|
|
351
384
|
))}
|
|
352
385
|
</ul>
|
|
@@ -359,6 +392,7 @@ function DiagnosticContext({
|
|
|
359
392
|
refForLink={memberRef(issue, ref)}
|
|
360
393
|
resourceHref={resourceHref}
|
|
361
394
|
onResourceClick={onResourceClick}
|
|
395
|
+
ResourceLinkIcon={ResourceLinkIcon}
|
|
362
396
|
/>
|
|
363
397
|
))}
|
|
364
398
|
</ul>
|
|
@@ -424,10 +458,12 @@ function AffectedResources({
|
|
|
424
458
|
issue,
|
|
425
459
|
resourceHref,
|
|
426
460
|
onResourceClick,
|
|
461
|
+
ResourceLinkIcon,
|
|
427
462
|
}: {
|
|
428
463
|
issue: Issue;
|
|
429
464
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
430
465
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
466
|
+
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
431
467
|
}) {
|
|
432
468
|
const members = issue.members ?? [];
|
|
433
469
|
// count is the backend fan-out size (members, subject excluded — see
|
|
@@ -439,7 +475,7 @@ function AffectedResources({
|
|
|
439
475
|
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
440
476
|
an <li>, so it needs a list parent of its own. */}
|
|
441
477
|
<ul className="flex flex-col gap-px">
|
|
442
|
-
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
478
|
+
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
443
479
|
</ul>
|
|
444
480
|
{members.length > 0 && (
|
|
445
481
|
<>
|
|
@@ -453,6 +489,7 @@ function AffectedResources({
|
|
|
453
489
|
refForLink={memberRef(issue, m)}
|
|
454
490
|
resourceHref={resourceHref}
|
|
455
491
|
onResourceClick={onResourceClick}
|
|
492
|
+
ResourceLinkIcon={ResourceLinkIcon}
|
|
456
493
|
/>
|
|
457
494
|
))}
|
|
458
495
|
</ul>
|
|
@@ -472,11 +509,13 @@ function ResourceLine({
|
|
|
472
509
|
refForLink,
|
|
473
510
|
resourceHref,
|
|
474
511
|
onResourceClick,
|
|
512
|
+
ResourceLinkIcon,
|
|
475
513
|
}: {
|
|
476
514
|
label?: string;
|
|
477
515
|
refForLink: IssueResourceRef;
|
|
478
516
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
479
517
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
518
|
+
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
480
519
|
}) {
|
|
481
520
|
const r = refForLink;
|
|
482
521
|
const linkable = !!(onResourceClick || resourceHref);
|
|
@@ -488,7 +527,7 @@ function ResourceLine({
|
|
|
488
527
|
{r.namespace ? `${r.namespace} / ` : ''}
|
|
489
528
|
{r.name}
|
|
490
529
|
</span>
|
|
491
|
-
{linkable && <
|
|
530
|
+
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
492
531
|
</>
|
|
493
532
|
);
|
|
494
533
|
const cls = 'group/r flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// module-internal and don't collide at the top-level barrel with the Checks
|
|
3
3
|
// queue's identically-named helpers when both land. Issue-prefixed public
|
|
4
4
|
// names are safe to surface.
|
|
5
|
-
export { IssuesView } from './IssuesView';
|
|
6
|
-
export type { IssuesViewProps } from './IssuesView';
|
|
5
|
+
export { IssueRow, IssuesView } from './IssuesView';
|
|
6
|
+
export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
|
|
7
7
|
export {
|
|
8
8
|
ISSUE_SEVERITIES,
|
|
9
9
|
ISSUE_SEVERITY_RANK,
|
|
@@ -28,6 +28,13 @@ describe('compareIssues', () => {
|
|
|
28
28
|
expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
it('orders direct startup blockers before generic problem rows at same severity', () => {
|
|
32
|
+
const generic = mk({ id: 'generic', source: 'problem', first_seen: '2026-05-01T00:00:00Z' })
|
|
33
|
+
const blocker = mk({ id: 'blocker', source: 'scheduling', first_seen: '2026-01-01T00:00:00Z' })
|
|
34
|
+
const missing = mk({ id: 'missing', source: 'missing_ref', first_seen: '2026-03-01T00:00:00Z' })
|
|
35
|
+
expect([generic, blocker, missing].sort(compareIssues).map((i) => i.id)).toEqual(['blocker', 'missing', 'generic'])
|
|
36
|
+
})
|
|
37
|
+
|
|
31
38
|
it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
|
|
32
39
|
// Two same-severity rows, same first_seen — order is the deterministic name tiebreak.
|
|
33
40
|
const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
|
|
@@ -24,6 +24,13 @@ export const ISSUE_SEVERITY_RANK: Record<IssueSeverity, number> = {
|
|
|
24
24
|
warning: 1,
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
const ISSUE_SOURCE_RANK: Record<string, number> = {
|
|
28
|
+
scheduling: 4,
|
|
29
|
+
missing_ref: 3,
|
|
30
|
+
condition: 2,
|
|
31
|
+
problem: 1,
|
|
32
|
+
};
|
|
33
|
+
|
|
27
34
|
export function isIssueSeverity(s: string): s is IssueSeverity {
|
|
28
35
|
return s === 'critical' || s === 'warning';
|
|
29
36
|
}
|
|
@@ -209,15 +216,18 @@ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResource
|
|
|
209
216
|
|
|
210
217
|
/**
|
|
211
218
|
* compareIssues is the queue's stable sort order (extracted from IssuesView so
|
|
212
|
-
* it can be unit-tested). Severity first (critical before warning), then
|
|
213
|
-
*
|
|
214
|
-
* on every poll, so sorting by
|
|
215
|
-
*
|
|
216
|
-
*
|
|
219
|
+
* it can be unit-tested). Severity first (critical before warning), then
|
|
220
|
+
* direct-blocker source priority, then ONSET — first_seen DESC, deliberately
|
|
221
|
+
* NOT last_seen: last_seen bumps to compose-time on every poll, so sorting by
|
|
222
|
+
* it would reshuffle same-severity rows on each refetch. The remaining keys
|
|
223
|
+
* (cluster → namespace → name → id) are a fully deterministic tiebreak so the
|
|
224
|
+
* order never churns under auto-refresh.
|
|
217
225
|
*/
|
|
218
226
|
export function compareIssues(a: Issue, b: Issue): number {
|
|
219
227
|
const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
|
|
220
228
|
if (r !== 0) return r;
|
|
229
|
+
const sr = (ISSUE_SOURCE_RANK[b.source] ?? 0) - (ISSUE_SOURCE_RANK[a.source] ?? 0);
|
|
230
|
+
if (sr !== 0) return sr;
|
|
221
231
|
const fa = a.first_seen ?? '';
|
|
222
232
|
const fb = b.first_seen ?? '';
|
|
223
233
|
if (fa !== fb) return fb.localeCompare(fa);
|
|
@@ -23,7 +23,7 @@ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDele
|
|
|
23
23
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
24
24
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
25
25
|
import type { SelectedResource, WorkloadRevision } from '../../types'
|
|
26
|
-
import {
|
|
26
|
+
import { displayKindName } from '../ui/drawer-components'
|
|
27
27
|
import { getDefaultContainerName } from '../resources/resource-utils'
|
|
28
28
|
|
|
29
29
|
// ============================================================================
|
|
@@ -517,20 +517,20 @@ export function ResourceActionsBar({
|
|
|
517
517
|
<Tooltip
|
|
518
518
|
content={
|
|
519
519
|
onCompareTo && onCompareAcrossClusters
|
|
520
|
-
? `Compare ${
|
|
520
|
+
? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
521
521
|
: onCompareAcrossClusters
|
|
522
522
|
? `Compare across clusters`
|
|
523
|
-
: `Compare to another ${
|
|
523
|
+
: `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
524
524
|
}
|
|
525
525
|
>
|
|
526
526
|
<button
|
|
527
527
|
onClick={onCompareTo ?? onCompareAcrossClusters}
|
|
528
528
|
aria-label={
|
|
529
529
|
onCompareTo && onCompareAcrossClusters
|
|
530
|
-
? `Compare ${
|
|
530
|
+
? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
531
531
|
: onCompareAcrossClusters
|
|
532
532
|
? `Compare across clusters`
|
|
533
|
-
: `Compare to another ${
|
|
533
|
+
: `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
534
534
|
}
|
|
535
535
|
className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
|
|
536
536
|
>
|
|
@@ -577,7 +577,7 @@ export function ResourceActionsBar({
|
|
|
577
577
|
onClose={() => setShowDeleteConfirm(false)}
|
|
578
578
|
onConfirm={handleDeleteConfirm}
|
|
579
579
|
resourceName={resource.name}
|
|
580
|
-
resourceKind={
|
|
580
|
+
resourceKind={displayKindName(resource.kind, data?.kind)}
|
|
581
581
|
namespaceName={resource.namespace}
|
|
582
582
|
isLoading={isDeleting ?? false}
|
|
583
583
|
cascadeDependents={cascadeDependents}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
2
|
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
|
3
3
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
4
|
-
import { formatKindName } from './drawer-components'
|
|
5
4
|
import { pluralize } from '../../utils/pluralize'
|
|
6
5
|
|
|
7
6
|
export interface CascadeDependent {
|
|
@@ -115,7 +114,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
|
|
|
115
114
|
<div className="px-3 pb-2.5 space-y-1.5">
|
|
116
115
|
{grouped.map(([kind, names]) => (
|
|
117
116
|
<div key={kind} className="text-xs">
|
|
118
|
-
<span className="font-medium text-theme-text-primary">{
|
|
117
|
+
<span className="font-medium text-theme-text-primary">{kind}</span>
|
|
119
118
|
<span className="text-theme-text-tertiary ml-1">({names.length})</span>
|
|
120
119
|
<div className="ml-3 mt-0.5 text-theme-text-secondary font-mono break-all">
|
|
121
120
|
{names.slice(0, MAX_NAMES_PER_KIND).join(', ')}
|
|
@@ -701,6 +701,14 @@ export function formatKindName(kind: string): string {
|
|
|
701
701
|
return kind
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
+
// Resolve the display label for a kind chip. The fetched resource's `dataKind`
|
|
705
|
+
// is the authoritative PascalCase kind and is correct for every CRD; prefer it.
|
|
706
|
+
// Before data loads, fall back to deriving from the URL plural — which is only
|
|
707
|
+
// reliable for the core kinds in formatKindName's map.
|
|
708
|
+
export function displayKindName(urlPlural: string, dataKind?: string): string {
|
|
709
|
+
return dataKind || formatKindName(urlPlural)
|
|
710
|
+
}
|
|
711
|
+
|
|
704
712
|
// Type for copy handler
|
|
705
713
|
export type CopyHandler = (text: string, key: string) => void
|
|
706
714
|
|
|
@@ -51,7 +51,7 @@ import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlVi
|
|
|
51
51
|
import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
|
|
52
52
|
import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
|
|
53
53
|
import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
|
|
54
|
-
import { getKindColorOutline,
|
|
54
|
+
import { getKindColorOutline, displayKindName } from '../ui/drawer-components'
|
|
55
55
|
import { midTruncate } from '../../utils/format'
|
|
56
56
|
|
|
57
57
|
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
@@ -555,7 +555,7 @@ export function WorkloadView({
|
|
|
555
555
|
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
|
556
556
|
<div className="flex items-center gap-2 flex-wrap">
|
|
557
557
|
<span className={clsx('badge', getKindColorOutline(apiKind))}>
|
|
558
|
-
{
|
|
558
|
+
{displayKindName(apiKind, resource?.kind)}
|
|
559
559
|
</span>
|
|
560
560
|
{status && (
|
|
561
561
|
<span className={clsx('badge', status.color)}>
|
|
@@ -706,7 +706,7 @@ export function WorkloadView({
|
|
|
706
706
|
</div>
|
|
707
707
|
<div className="flex items-center gap-3 text-sm text-theme-text-secondary">
|
|
708
708
|
<span className={clsx('badge', getKindColorOutline(apiKind))}>
|
|
709
|
-
{
|
|
709
|
+
{displayKindName(apiKind, resource?.kind)}
|
|
710
710
|
</span>
|
|
711
711
|
{status && (
|
|
712
712
|
<span className={clsx('badge', status.color)}>
|
|
@@ -860,7 +860,7 @@ export function WorkloadView({
|
|
|
860
860
|
)}
|
|
861
861
|
>
|
|
862
862
|
<span className="truncate text-xs font-medium text-theme-text-primary">{midTruncate(o.name, 26)}</span>
|
|
863
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{
|
|
863
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{o.kind}</span>
|
|
864
864
|
</button>
|
|
865
865
|
)
|
|
866
866
|
})}
|