@skyhook-io/k8s-ui 1.8.10 → 1.8.11

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.
Files changed (116) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
@@ -2,7 +2,7 @@
2
2
  // GitOpsInsightViews.tsx so they can be unit-tested without importing JSX.
3
3
 
4
4
  import { SEVERITY_DOT, type Severity } from '../../../utils/badge-colors'
5
- import type { GitOpsHistoryItem } from '../../../types'
5
+ import type { GitOpsChange, GitOpsHistoryItem } from '../../../types'
6
6
  import type { SyncStatus, GitOpsHealthStatus } from '../../../types/gitops'
7
7
 
8
8
  const SYNC_STATUS_SET = new Set<SyncStatus>(['Synced', 'OutOfSync', 'Reconciling', 'Unknown'])
@@ -23,6 +23,74 @@ export function normalizeHealthStatus(value: string | undefined | null): GitOpsH
23
23
  return 'Unknown'
24
24
  }
25
25
 
26
+ // --- Resources table: filtering + sorting -------------------------------
27
+ // Pure predicates + rank functions backing the Resources list's search box,
28
+ // status facets, and sortable columns. Kept here (not inline in the view) so
29
+ // the sort/filter behavior is unit-testable without rendering.
30
+
31
+ // The status facets an operator filters the Resources list by — the three
32
+ // states worth isolating on a broadly-drifted app. (These replaced the
33
+ // per-resource OutOfSync "issues" that used to restate the whole table.)
34
+ export type ResourceStatusFacet = 'outOfSync' | 'degraded' | 'missing'
35
+
36
+ export type ResourceSortKey = 'order' | 'name' | 'sync' | 'health'
37
+
38
+ // changeSync/changeHealth normalize a change's raw backend strings onto the
39
+ // badge unions. `sync` falls back to `category` to match how ChangeRow and
40
+ // the sync badge resolve it.
41
+ export function changeSync(change: GitOpsChange): SyncStatus {
42
+ return normalizeSyncStatus(change.sync ?? change.category)
43
+ }
44
+
45
+ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
46
+ return normalizeHealthStatus(change.health)
47
+ }
48
+
49
+ // Rank sync/health so an ascending sort surfaces the states an operator cares
50
+ // about first (drift, then failures). Mirrors the GitOps application table's
51
+ // syncRank/healthRank so the two tables order status the same way.
52
+ export function syncStatusRank(sync: SyncStatus): number {
53
+ return ({ OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 } as Record<SyncStatus, number>)[sync]
54
+ }
55
+
56
+ export function healthStatusRank(health: GitOpsHealthStatus): number {
57
+ return ({ Missing: 0, Degraded: 1, Progressing: 2, Unknown: 3, Suspended: 4, Healthy: 5 } as Record<GitOpsHealthStatus, number>)[health]
58
+ }
59
+
60
+ export function changeMatchesSearch(change: GitOpsChange, query: string): boolean {
61
+ const q = query.trim().toLowerCase()
62
+ if (!q) return true
63
+ const { kind, name, namespace, group } = change.ref
64
+ return [kind, name, namespace, group].some((s) => s?.toLowerCase().includes(q))
65
+ }
66
+
67
+ // Union match: a change passes when it satisfies ANY active facet (empty set
68
+ // = no filter). OutOfSync keys off sync status; Degraded/Missing off health.
69
+ export function changeMatchesFacets(change: GitOpsChange, facets: Set<ResourceStatusFacet>): boolean {
70
+ if (facets.size === 0) return true
71
+ if (facets.has('outOfSync') && changeSync(change) === 'OutOfSync') return true
72
+ if (facets.has('degraded') && changeHealth(change) === 'Degraded') return true
73
+ if (facets.has('missing') && changeHealth(change) === 'Missing') return true
74
+ return false
75
+ }
76
+
77
+ export interface ResourceStatusCounts {
78
+ outOfSync: number
79
+ degraded: number
80
+ missing: number
81
+ }
82
+
83
+ export function resourceStatusCounts(changes: GitOpsChange[]): ResourceStatusCounts {
84
+ const counts: ResourceStatusCounts = { outOfSync: 0, degraded: 0, missing: 0 }
85
+ for (const c of changes) {
86
+ if (changeSync(c) === 'OutOfSync') counts.outOfSync++
87
+ const health = changeHealth(c)
88
+ if (health === 'Degraded') counts.degraded++
89
+ if (health === 'Missing') counts.missing++
90
+ }
91
+ return counts
92
+ }
93
+
26
94
  // Map GitOps-flavored vocabulary (Argo/Flux phase strings, insight Issue
27
95
  // severities) onto the canonical Severity tokens used by SEVERITY_BADGE /
28
96
  // SEVERITY_TEXT / SEVERITY_DOT. Centralizing this keeps call sites from
@@ -17,12 +17,14 @@ import {
17
17
  import '@xyflow/react/dist/style.css'
18
18
  import { AlertTriangle, ChevronRight, Maximize, Search, X } from 'lucide-react'
19
19
  import { PaneLoader } from '../../ui/PaneLoader'
20
+ import { Input } from '../../ui/Input'
20
21
  import { clsx } from 'clsx'
21
22
 
22
23
  import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
23
24
  import { displayKind } from '../../../types'
24
25
  import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
25
26
  import { formatCompactAge } from '../../../utils/format'
27
+ import { getTopologyIcon } from '../../../utils/resource-icons'
26
28
  import { Tooltip } from '../../ui/Tooltip'
27
29
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
28
30
 
@@ -252,7 +254,7 @@ function GitOpsTreeToolbar({
252
254
  </div>
253
255
  <div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 px-2 py-1.5 backdrop-blur">
254
256
  <Search className="h-3.5 w-3.5 text-theme-text-tertiary" />
255
- <input
257
+ <Input
256
258
  value={query}
257
259
  onChange={(event) => onQueryChange(event.target.value)}
258
260
  placeholder="Find node..."
@@ -549,6 +551,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
549
551
  const gitopsTool = gitopsToolForNode(node)
550
552
  const chips = buildChips(node)
551
553
  const dim = getNodeDimensions(node)
554
+ const KindIcon = getTopologyIcon(kind)
552
555
 
553
556
  return (
554
557
  <>
@@ -584,7 +587,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
584
587
  >
585
588
  <div className="px-3 py-2.5">
586
589
  <div className="mb-1 flex items-center gap-1.5">
587
- <span className={`topology-icon topology-icon-${kind.toLowerCase()}`} />
590
+ <KindIcon className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
588
591
  <span className="truncate text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
589
592
  {node.role === 'group' ? displayKind((node.data?.groupedKind as string) || kind) : displayKind(kind)}
590
593
  </span>
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
2
  import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
3
3
  import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
4
+ import { Tooltip } from '../ui/Tooltip';
4
5
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
6
  import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
7
+ import { issueTiming } from './issue-timing';
6
8
  import {
7
9
  ISSUE_SEVERITY_BADGE_CLASS,
8
10
  ISSUE_SEVERITY_HEADER_BAND_CLASS,
@@ -60,12 +62,15 @@ export interface IssuesViewProps {
60
62
  clusterLabel?: (issue: Issue) => string | undefined;
61
63
  /** Empty-state CTA shown when there's no data. */
62
64
  emptyAction?: ReactNode;
65
+ /** Per-row trailing action, rendered after the severity badge — e.g. the
66
+ * "Diagnose with AI" button in OSS. Omit to render no per-row action. */
67
+ renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
63
68
  }
64
69
 
65
70
  // The queue list. Filtering/faceting is the host page's job (FleetPageShell on
66
71
  // the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
67
72
  // no-data terminal states only.
68
- export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
73
+ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction, renderActions }: IssuesViewProps) {
69
74
  // Single-open accordion: opening a row collapses the previous one, so the
70
75
  // queue stays scannable and you never lose your place to a wall of expansions.
71
76
  const [openId, setOpenId] = useState<string | null>(null);
@@ -104,6 +109,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
104
109
  onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
105
110
  resourceHref={resourceHref}
106
111
  onResourceClick={onResourceClick}
112
+ renderActions={renderActions}
107
113
  />
108
114
  );
109
115
  })}
@@ -155,6 +161,7 @@ export function IssueRow({
155
161
  const severity = normalizeIssueSeverity(issue.severity);
156
162
  const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
157
163
  const slotCtx = { issue, open };
164
+ const timing = issueTiming(issue);
158
165
 
159
166
  useEffect(() => {
160
167
  if (open) {
@@ -248,10 +255,12 @@ export function IssueRow({
248
255
  <span aria-hidden>·</span>
249
256
  {/* Non-interactive signal (the header is the toggle — a nested
250
257
  button would be invalid); the clickable link lives in the body. */}
251
- <span className="min-w-0 truncate text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence ?? '')}>
252
- ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
253
- <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
254
- </span>
258
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence ?? '')} delay={200} wrapperClassName="min-w-0">
259
+ <span className="truncate text-theme-text-tertiary">
260
+ ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
261
+ <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
262
+ </span>
263
+ </Tooltip>
255
264
  </>
256
265
  ) : null}
257
266
  {renderMeta?.(slotCtx)}
@@ -265,17 +274,22 @@ export function IssueRow({
265
274
  </span>
266
275
  {/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
267
276
  {issue.first_seen ? (
268
- <time
269
- dateTime={issue.first_seen}
270
- title={ageTitle(issue)}
271
- className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
272
- >
273
- <Clock className="h-3 w-3" aria-hidden />
274
- {formatCompactAge(issue.first_seen)}
275
- {issue.issue_timing === 'started_at_resource_creation' ? (
276
- <span className="badge-sm ml-1 text-[10px] text-theme-text-secondary">since deploy</span>
277
+ <>
278
+ <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
279
+ <time
280
+ dateTime={issue.first_seen}
281
+ className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
282
+ >
283
+ <Clock className="h-3 w-3" aria-hidden />
284
+ {formatCompactAge(issue.first_seen)}
285
+ </time>
286
+ </Tooltip>
287
+ {timing ? (
288
+ <Tooltip content={timing.tooltip} delay={200}>
289
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
290
+ </Tooltip>
277
291
  ) : null}
278
- </time>
292
+ </>
279
293
  ) : null}
280
294
  {renderActions?.(slotCtx)}
281
295
  <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
@@ -302,9 +316,11 @@ export function IssueRow({
302
316
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
303
317
  {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
304
318
  {issue.incident_parent.confidence ? (
305
- <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence)}>
306
- {issue.incident_parent.confidence} confidence
307
- </span>
319
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
320
+ <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
321
+ {issue.incident_parent.confidence} confidence
322
+ </span>
323
+ </Tooltip>
308
324
  ) : null}
309
325
  </h4>
310
326
  <ul className="flex flex-col gap-px">
@@ -343,6 +359,7 @@ function Diagnosis({ issue }: { issue: Issue }) {
343
359
  // present, else headline+detail) rather than a string that may never appear.
344
360
  const visibleMessage = [headline, detail].filter(Boolean).join(' ');
345
361
  const shownText = issue.cause ?? visibleMessage;
362
+ const timing = issueTiming(issue);
346
363
  const rawError = [
347
364
  ...new Set(
348
365
  [issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
@@ -359,15 +376,13 @@ function Diagnosis({ issue }: { issue: Issue }) {
359
376
  else if (issue.operation_retry_count) meta.push('Retrying');
360
377
  if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
361
378
  if (crash) meta.push(crash);
379
+ if (timing) {
380
+ meta.push(timing.meta);
381
+ } else if (issue.first_seen) {
382
+ meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
383
+ }
362
384
  if (issue.first_seen) {
363
- meta.push(
364
- issue.issue_timing === 'started_at_resource_creation'
365
- ? 'present since deployment'
366
- : issue.issue_timing === 'started_after_resource_was_healthy'
367
- ? `started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
368
- : `started ${formatRelativeAgeTime(issue.first_seen)}`,
369
- );
370
- if (issue.last_seen && issue.issue_timing !== 'started_at_resource_creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
385
+ if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
371
386
  }
372
387
  if (issue.change_context) meta.push(changeContextText(issue.change_context));
373
388
 
@@ -448,12 +463,11 @@ function DiagnosticContext({
448
463
  <div className="flex min-w-0 items-baseline gap-2">
449
464
  <span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
450
465
  {fact.confidence ? (
451
- <span
452
- className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary"
453
- title={confidenceTitle(fact.confidence)}
454
- >
455
- {fact.confidence} confidence
456
- </span>
466
+ <Tooltip content={confidenceTitle(fact.confidence)} delay={200}>
467
+ <span className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary">
468
+ {fact.confidence} confidence
469
+ </span>
470
+ </Tooltip>
457
471
  ) : null}
458
472
  {fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
459
473
  </div>
@@ -496,11 +510,8 @@ function DiagnosticContext({
496
510
  // freshness, the two facts the compact "2h" hides.
497
511
  function ageTitle(issue: Issue): string {
498
512
  const parts: string[] = [];
499
- if (issue.issue_timing === 'started_at_resource_creation') {
500
- parts.push('Present since deployment — treat as baseline');
501
- } else if (issue.issue_timing === 'started_after_resource_was_healthy') {
502
- parts.push('Resource was previously healthy');
503
- }
513
+ const timing = issueTiming(issue);
514
+ if (timing) parts.push(timing.tooltip);
504
515
  if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
505
516
  if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
506
517
  return parts.join('\n');
@@ -589,7 +600,9 @@ function ResourceLine({
589
600
  {r.name}
590
601
  </span>
591
602
  {count && count > 1 ? (
592
- <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums" title={`${count} affected resources grouped under this issue`}>{count} affected</span>
603
+ <Tooltip content={`${count} affected resources grouped under this issue`} delay={200}>
604
+ <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums">{count} affected</span>
605
+ </Tooltip>
593
606
  ) : null}
594
607
  {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
595
608
  </>
@@ -28,12 +28,13 @@ const issue: Issue = {
28
28
  }
29
29
 
30
30
  describe('ResourceIssuesSection', () => {
31
- it('keeps drawer context facts but omits the role badge', () => {
31
+ it('uses the Issues row treatment and defaults collapsed', () => {
32
32
  const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
33
33
 
34
- expect(html).toContain('Context')
35
- expect(html).toContain('Blocked pods')
36
- expect(html).toContain('high<!-- --> confidence')
37
- expect(html).not.toContain('Possible cause')
34
+ expect(html).toContain('Operational issues')
35
+ expect(html).toContain('StorageClassMissing')
36
+ expect(html).not.toContain('What&#x27;s wrong')
37
+ expect(html).not.toContain('Context')
38
+ expect(html).not.toContain('Blocked pods')
38
39
  })
39
40
  })
@@ -1,26 +1,8 @@
1
- import { AlertTriangle, ExternalLink } from 'lucide-react'
2
- import { Section } from '../ui/drawer-components'
3
- import { Badge } from '../ui/Badge'
4
- import type { Issue, IssueResourceRef } from './types'
5
- import { categoryLabel } from './severity'
6
- import { diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic'
1
+ import { useMemo, useState } from 'react'
2
+ import { AlertTriangle } from 'lucide-react'
3
+ import { IssueRow } from './IssuesView'
4
+ import { compareIssues, type Issue, type IssueResourceRef } from './types'
7
5
 
8
- /**
9
- * ResourceIssuesSection — the compact "Operational Issues" block for the resource
10
- * detail. Renders the live, diagnosed Issues that touch one resource (its own +,
11
- * for a workload, its owned pods' issues, server-rolled-up via RelatedIssues).
12
- * The diagnosis sibling of IssuesView's queue row — same Cause/Action vocabulary,
13
- * stripped of the queue chrome (accordion, scope copy, fan-out expansion).
14
- *
15
- * Header mirrors the queue: the plain `categoryLabel` is the operator-facing
16
- * headline and the raw `reason` rides alongside as a muted signal (so the K8s
17
- * jargon is available but not the lead). Body is primarily the plain
18
- * `cause` (which names the offending object) + the `Next step` — the diagnosis
19
- * and the fix. The raw `message`/evidence stays in the queue + MCP where the
20
- * locator detail is wanted; inline it mostly restated the cause or the category,
21
- * so it's omitted to keep the card scannable. (`message` is the body fallback
22
- * only for categories that don't yet emit a `cause`.)
23
- */
24
6
  export function ResourceIssuesSection({
25
7
  issues,
26
8
  onResourceClick,
@@ -29,135 +11,35 @@ export function ResourceIssuesSection({
29
11
  /** When provided, related resources in a causal link become clickable. */
30
12
  onResourceClick?: (ref: IssueResourceRef) => void
31
13
  }) {
32
- if (!issues || issues.length === 0) return null
14
+ const sorted = useMemo(() => [...(issues ?? [])].sort(compareIssues), [issues])
15
+ const [openId, setOpenId] = useState<string | null>(null)
16
+
17
+ if (sorted.length === 0) return null
18
+
33
19
  return (
34
- <Section title={`Operational Issues (${issues.length})`} icon={AlertTriangle} defaultExpanded>
35
- <div className="space-y-3">
36
- {issues.map((issue) => {
37
- const parent = issue.incident_parent
20
+ <section className="space-y-2">
21
+ <div className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
22
+ <AlertTriangle className="h-4 w-4 text-theme-text-tertiary" aria-hidden />
23
+ <span>Operational issues ({sorted.length})</span>
24
+ </div>
25
+ <ol className="flex flex-col gap-1.5">
26
+ {sorted.map((issue) => {
27
+ const key = issueKey(issue)
38
28
  return (
39
- <div key={issue.id} className="card-inner">
40
- <div className="mb-1 flex min-w-0 items-baseline gap-2">
41
- <Badge severity={issue.severity === 'critical' ? 'error' : 'warning'} size="sm">
42
- {issue.severity}
43
- </Badge>
44
- <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
45
- {issue.reason ? (
46
- <span className="min-w-0 flex-1 truncate text-xs text-theme-text-tertiary">{issue.reason}</span>
47
- ) : null}
48
- {issue.count ? (
49
- <span className="shrink-0 text-xs text-theme-text-tertiary tabular-nums">· {issue.count} affected</span>
50
- ) : null}
51
- </div>
52
- {issue.cause ? (
53
- <p className="text-sm leading-relaxed text-theme-text-secondary">{issue.cause}</p>
54
- ) : issue.message ? (
55
- <p className="text-sm leading-relaxed text-theme-text-secondary">{issue.message}</p>
56
- ) : null}
57
- {issue.action ? (
58
- <p className="mt-1 text-sm leading-relaxed text-theme-text-secondary">
59
- <span className="font-medium text-theme-text-primary">Next step: </span>
60
- {issue.action}
61
- </p>
62
- ) : null}
63
- {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
64
- <p className="mt-1 text-xs text-theme-text-tertiary">
65
- Suggested fix: create namespace{' '}
66
- <code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
67
- </p>
68
- ) : null}
69
- {parent ? (
70
- <p className="mt-1 text-xs text-theme-text-tertiary">
71
- {incidentParentLabel(parent.fact_type, parent.confidence)}:{' '}
72
- {onResourceClick ? (
73
- <button
74
- type="button"
75
- onClick={() => onResourceClick(parent.ref)}
76
- className="group inline-flex items-center gap-1 text-left font-mono hover:text-theme-text-secondary"
77
- title={confidenceTitle(parent.confidence ?? '')}
78
- >
79
- {parent.ref.kind} / {parent.ref.name}
80
- <ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
81
- </button>
82
- ) : (
83
- <span className="font-mono">{parent.ref.kind} / {parent.ref.name}</span>
84
- )}
85
- </p>
86
- ) : null}
87
- <CausalContext issue={issue} onResourceClick={onResourceClick} />
88
- </div>
29
+ <IssueRow
30
+ key={key}
31
+ issue={issue}
32
+ open={openId === key}
33
+ onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
34
+ onResourceClick={onResourceClick}
35
+ />
89
36
  )
90
37
  })}
91
- </div>
92
- </Section>
38
+ </ol>
39
+ </section>
93
40
  )
94
41
  }
95
42
 
96
- /**
97
- * CausalContext — the compact, drawer-density rendering of an issue's
98
- * cross-subject causal links (DiagnosticContext). Shows only the linking facts
99
- * (those carrying a confidence tier or related issues) — the queue's IssuesView
100
- * renders the fuller context with clickable resource navigation; here the related
101
- * resources are shown as plain identifiers to keep the resource panel scannable.
102
- */
103
- function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceClick?: (ref: IssueResourceRef) => void }) {
104
- const ctx = issue.diagnostic_context
105
- const links = ctx?.facts?.filter((f) => f.confidence || (f.related_issues && f.related_issues.length > 0)) ?? []
106
- if (!ctx || links.length === 0) return null
107
- return (
108
- <div className="mt-2 border-t border-theme-border/60 pt-2">
109
- <div className="mb-1 flex items-center gap-2">
110
- <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</span>
111
- </div>
112
- <ul className="space-y-1.5">
113
- {links.map((fact, idx) => (
114
- <li key={`${fact.type}-${idx}`} className="text-xs">
115
- <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
116
- <span className="font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
117
- {fact.confidence ? (
118
- <span className="badge-sm text-[10px] text-theme-text-tertiary" title={confidenceTitle(fact.confidence)}>
119
- {fact.confidence} confidence
120
- </span>
121
- ) : null}
122
- {fact.message ? <span className="text-theme-text-tertiary">{fact.message}</span> : null}
123
- </div>
124
- {fact.related_issues && fact.related_issues.length > 0 ? (
125
- <ul className="mt-0.5 space-y-0.5 pl-3">
126
- {fact.related_issues.map((rel, ri) => {
127
- const label = (
128
- <>
129
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{rel.ref.kind}</span>{' '}
130
- <span className="font-mono">
131
- {rel.ref.namespace ? `${rel.ref.namespace} / ` : ''}
132
- {rel.ref.name}
133
- </span>
134
- {rel.count && rel.count > 1 ? (
135
- <span className="ml-1 tabular-nums" title={`${rel.count} affected resources grouped under this issue`}>· {rel.count} affected</span>
136
- ) : null}
137
- </>
138
- )
139
- return (
140
- <li key={ri} className="text-theme-text-tertiary">
141
- {onResourceClick ? (
142
- <button
143
- type="button"
144
- onClick={() => onResourceClick(rel.ref)}
145
- className="group inline-flex items-center gap-1 text-left hover:text-theme-text-secondary"
146
- >
147
- {label}
148
- <ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
149
- </button>
150
- ) : (
151
- label
152
- )}
153
- </li>
154
- )
155
- })}
156
- </ul>
157
- ) : null}
158
- </li>
159
- ))}
160
- </ul>
161
- </div>
162
- )
43
+ function issueKey(issue: Issue): string {
44
+ return `${issue.cluster_id ?? ''}:${issue.id}`
163
45
  }
@@ -5,6 +5,8 @@
5
5
  export { IssueRow, IssuesView } from './IssuesView';
6
6
  export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
7
7
  export { ResourceIssuesSection } from './ResourceIssuesSection';
8
+ export { issueTiming } from './issue-timing';
9
+ export type { IssueTimingDisplay, IssueTimingDisplayKind } from './issue-timing';
8
10
  export {
9
11
  ISSUE_SEVERITIES,
10
12
  ISSUE_SEVERITY_RANK,
@@ -17,6 +19,7 @@ export {
17
19
  ISSUE_SEVERITY_LABEL,
18
20
  ISSUE_SEVERITY_BADGE_CLASS,
19
21
  ISSUE_SEVERITY_TEXT_CLASS,
22
+ ISSUE_SEVERITY_FILL_CLASS,
20
23
  ISSUE_SEVERITY_RAIL_CLASS,
21
24
  categoryLabel,
22
25
  groupLabel,
@@ -0,0 +1,53 @@
1
+ import { formatRelativeAgeTime } from '../../utils/format';
2
+ import { isDeploymentLikeWorkloadKind } from '../../types';
3
+ import type { Issue } from './types';
4
+
5
+ export type IssueTimingDisplayKind = 'creation' | 'regression';
6
+
7
+ export interface IssueTimingDisplay {
8
+ kind: IssueTimingDisplayKind;
9
+ chip: string;
10
+ meta: string;
11
+ tooltip: string;
12
+ }
13
+
14
+ function isDeploymentLikeCreation(issue: Issue): boolean {
15
+ const group = issue.group ?? '';
16
+ if (issue.kind === 'Pod') {
17
+ return issue.issue_timing_basis === 'pod_creation'
18
+ || issue.issue_timing_basis === 'owner_condition';
19
+ }
20
+ return isDeploymentLikeWorkloadKind(issue.kind, group);
21
+ }
22
+
23
+ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
24
+ switch (issue.issue_timing) {
25
+ case 'started_after_resource_was_healthy': {
26
+ const started = issue.first_seen ? `started ${formatRelativeAgeTime(issue.first_seen)} after being healthy` : 'started after being healthy';
27
+ return {
28
+ kind: 'regression',
29
+ chip: 'after healthy',
30
+ meta: started,
31
+ tooltip: 'Previously healthy before this failing signal.',
32
+ };
33
+ }
34
+ case 'started_at_resource_creation': {
35
+ if (isDeploymentLikeCreation(issue)) {
36
+ return {
37
+ kind: 'creation',
38
+ chip: 'since deploy',
39
+ meta: 'present since deployment or first reconciliation',
40
+ tooltip: 'Failing signal began during deployment or first reconciliation.',
41
+ };
42
+ }
43
+ return {
44
+ kind: 'creation',
45
+ chip: 'since creation',
46
+ meta: 'present since creation or first reconciliation',
47
+ tooltip: 'Failing signal began during resource creation or first reconciliation.',
48
+ };
49
+ }
50
+ default:
51
+ return null;
52
+ }
53
+ }