@skyhook-io/k8s-ui 1.16.2 → 1.17.0

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 (152) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. package/src/utils/resource-refs.ts +30 -0
@@ -40,6 +40,7 @@ import { isArgoOperationInProgress, isArgoSuspendedByRadar } from '../resources/
40
40
  import { toggleSet } from './GitOpsGraphFilterRail'
41
41
  import { useFilterState, defineFilterSchema } from '../../filter-state'
42
42
  import { parseContextName } from '../../utils/context-name'
43
+ import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
43
44
 
44
45
  // =============================================================================
45
46
  // GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
@@ -1373,8 +1374,8 @@ function GitOpsTable({
1373
1374
  )}
1374
1375
  <TableCell>
1375
1376
  {row.terminating
1376
- ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
1377
- : formatRelativeAge(row.lastSync || row.createdAt)}
1377
+ ? <span className="text-orange-400/80">{pendingFor(row.terminationStartedAt)}</span>
1378
+ : row.lastSync ? formatRelativeAgeTime(row.lastSync, '') : row.createdAt ? `Created ${formatRelativeAgeTime(row.createdAt, '')}` : ''}
1378
1379
  </TableCell>
1379
1380
  {showActions && onRowAction && (
1380
1381
  <td
@@ -1603,9 +1604,9 @@ function GitOpsTile({
1603
1604
  <div className="truncate font-mono text-[11px] text-theme-text-tertiary">{shortRevision(revision)}</div>
1604
1605
  )}
1605
1606
  {row.terminating ? (
1606
- <div className="font-medium text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</div>
1607
+ <div className="font-medium text-orange-400/80">{pendingFor(row.terminationStartedAt)}</div>
1607
1608
  ) : (
1608
- lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelativeAge(lastSyncRaw)}</div>
1609
+ lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelativeAgeTime(lastSyncRaw, '')}</div>
1609
1610
  )}
1610
1611
  </div>
1611
1612
  {(dest || ns || row.project) && (
@@ -1926,26 +1927,10 @@ function compactClusterURL(dest: string): string {
1926
1927
  .replace(/^kubernetes\.default\.svc(:\d+)?\/?$/, 'in-cluster')
1927
1928
  }
1928
1929
 
1929
- // formatRelativeAge — inline relative-time formatter. Returns "" for unparseable
1930
- // inputs so callers can treat empty as "no timestamp" and skip the time
1931
- // cell gracefully.
1932
- function formatRelativeAge(rfc3339: string): string {
1933
- if (!rfc3339) return ''
1934
- const time = Date.parse(rfc3339)
1935
- if (!Number.isFinite(time)) return ''
1936
- const diffMs = Date.now() - time
1937
- const s = Math.max(0, Math.floor(diffMs / 1000))
1938
- if (s < 60) return `${s}s ago`
1939
- const m = Math.floor(s / 60)
1940
- if (m < 60) return `${m}m ago`
1941
- const h = Math.floor(m / 60)
1942
- if (h < 24) return `${h}h ago`
1943
- const d = Math.floor(h / 24)
1944
- if (d < 30) return `${d}d ago`
1945
- const mo = Math.floor(d / 30)
1946
- if (mo < 12) return `${mo}mo ago`
1947
- const y = Math.floor(d / 365)
1948
- return `${y}y ago`
1930
+ // How long deletion has been pending, not when it started.
1931
+ function pendingFor(startedAt?: string): string {
1932
+ const age = formatCompactAge(startedAt)
1933
+ return age ? `Pending for ${age}` : 'Pending'
1949
1934
  }
1950
1935
 
1951
1936
  // =============================================================================
@@ -82,7 +82,7 @@ export function describeGitOpsTerminating(summary?: {
82
82
  finalizers?: string[]
83
83
  }): { chipTooltip: string; actionDisabledTooltip: string } {
84
84
  const ageText = formatCompactAge(summary?.terminationStartedAt)
85
- const ageSuffix = ageText ? ` ${ageText} ago` : ''
85
+ const ageSuffix = ageText ? ` for ${ageText}` : ''
86
86
  const finalizers = summary?.finalizers ?? []
87
87
  const finSuffix = finalizers.length > 0 ? ` Finalizers: ${finalizers.join(', ')}.` : ''
88
88
  const ageInline = ageText ? ` (${ageText})` : ''
@@ -72,7 +72,7 @@ export function GitOpsStatusStrip({ insight, loading, renderRevisionMeta }: GitO
72
72
  const revision = summary.lastRevision || summary.targetRevision || ''
73
73
  const shortRev = shortRevisionForCommit(revision)
74
74
  const commitUrl = revision ? commitURLForRepo(summary.source, revision) : null
75
- const reconcileAge = formatRelative(summary.lastReconcile)
75
+ const reconcileAge = formatRelativeAgeTime(summary.lastReconcile)
76
76
  const healthSummary = buildHealthSummary(insight.changes ?? [])
77
77
  // STUCK belongs with the operation chip — it's a property of the same
78
78
  // state ("failed AND won't self-recover"). Co-locating FAILED + STUCK ·
@@ -257,7 +257,7 @@ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: stri
257
257
  // disclosure — still available for forensics but not noise during deletion.
258
258
  function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsight['summary']> }) {
259
259
  const [showHistorical, setShowHistorical] = useState(false)
260
- const pending = formatRelative(summary.terminationStartedAt) || 'recently'
260
+ const pending = formatRelativeAgeTime(summary.terminationStartedAt) || 'recently'
261
261
  const finalizers = summary.finalizers ?? []
262
262
  return (
263
263
  <div className="border-b border-orange-500/20 bg-orange-500/[0.04] px-4 py-2">
@@ -282,7 +282,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
282
282
  {(summary.lastRevision || summary.targetRevision) && (
283
283
  <MetaFact label="Revision" value={summary.lastRevision || summary.targetRevision || '-'} mono />
284
284
  )}
285
- {summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelative(summary.lastReconcile)} />}
285
+ {summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelativeAgeTime(summary.lastReconcile)} />}
286
286
  {summary.autoSyncMode && <MetaFact label="Sync mode" value={summary.autoSyncMode} />}
287
287
  </div>
288
288
  </Collapse>
@@ -1628,7 +1628,7 @@ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recen
1628
1628
  {e.count && e.count > 1 && (
1629
1629
  <span className="text-[10px] text-theme-text-tertiary">×{e.count}</span>
1630
1630
  )}
1631
- <span className="ml-auto text-[10px] text-theme-text-tertiary">{formatRelativeTime(e.lastTimestamp)}</span>
1631
+ <span className="ml-auto text-[10px] text-theme-text-tertiary">{formatRelativeAgeTime(e.lastTimestamp, '')}</span>
1632
1632
  </div>
1633
1633
  <p className="mt-0.5 text-theme-text-secondary">{e.message}</p>
1634
1634
  </div>
@@ -1639,10 +1639,6 @@ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recen
1639
1639
  )
1640
1640
  }
1641
1641
 
1642
- function formatRelativeTime(value: string): string {
1643
- return formatRelativeAgeTime(value, '')
1644
- }
1645
-
1646
1642
  interface GitOpsActivityInsightViewProps {
1647
1643
  insight?: GitOpsInsight | null
1648
1644
  error?: Error | null
@@ -1729,7 +1725,7 @@ function HistoryRows({
1729
1725
  <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
1730
1726
  <span className="font-mono text-xs text-theme-text-primary">{item.revision || item.phase || '-'}</span>
1731
1727
  <PhaseChip phase={item.phase} message={item.message} />
1732
- <span className="text-[11px] text-theme-text-tertiary">{formatRelative(item.deployedAt)}</span>
1728
+ <span className="text-[11px] text-theme-text-tertiary">{formatRelativeAgeTime(item.deployedAt)}</span>
1733
1729
  {item.initiatedBy && (
1734
1730
  <span className="text-[11px] text-theme-text-tertiary">by {item.initiatedBy}</span>
1735
1731
  )}
@@ -1831,6 +1827,3 @@ function InsightErrorState({ error }: { error: Error }) {
1831
1827
  )
1832
1828
  }
1833
1829
 
1834
- function formatRelative(value?: string) {
1835
- return formatRelativeAgeTime(value)
1836
- }
@@ -752,10 +752,10 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
752
752
  // detail title row and fleet rows.
753
753
  if (isNodeTerminating(node)) {
754
754
  chips.push({ value: 'Terminating', tone: 'warning' })
755
- if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
755
+ if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
756
756
  return chips
757
757
  }
758
- if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
758
+ if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
759
759
  const revision = stringData(data.revision) || stringData(data.lastSyncRevision)
760
760
  if (revision) chips.push({ label: 'rev', value: revision })
761
761
  const attempted = stringData(data.attemptedRevision)
@@ -886,6 +886,3 @@ function pluralize(kind: string): string {
886
886
  return `${kind}s`
887
887
  }
888
888
 
889
- function formatAge(timestamp: string): string {
890
- return formatCompactAge(timestamp)
891
- }
@@ -403,7 +403,16 @@ export function IssueRow({
403
403
  function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSource }) {
404
404
  const crash =
405
405
  issue.restart_count || issue.last_terminated_reason
406
- ? [issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null, issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null]
406
+ ? [
407
+ issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null,
408
+ issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null,
409
+ issue.restart_loop?.workload_pods && issue.restart_loop.workload_pods > 1
410
+ ? `${issue.restart_loop.looping_pods} of ${issue.restart_loop.workload_pods} pods looping`
411
+ : null,
412
+ issue.restart_loop?.startup_probe_failure ? 'startup probe failures seen' : null,
413
+ issue.restart_loop?.liveness_probe_failure ? 'liveness probe failures seen' : null,
414
+ issue.restart_loop?.readiness_probe_failure ? 'readiness probe failures seen' : null,
415
+ ]
407
416
  .filter(Boolean)
408
417
  .join(' · ')
409
418
  : null;
@@ -113,9 +113,9 @@ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
113
113
  }
114
114
  return {
115
115
  kind: 'regression',
116
- chip: 'after healthy',
116
+ chip: issue.kind === 'Node' && issue.reason === 'NotReady' ? 'NotReady' : 'after healthy',
117
117
  meta: 'failing evidence followed a healthy period',
118
- tooltip: 'A healthy period preceded this failing evidence.',
118
+ tooltip: issue.kind === 'Node' && issue.reason === 'NotReady' ? 'The Ready condition stopped reporting healthy at this time.' : 'A healthy period preceded this failing evidence.',
119
119
  };
120
120
  }
121
121
  case 'started_at_resource_creation': {
@@ -139,7 +139,7 @@ describe('IssueRow', () => {
139
139
 
140
140
  it('uses the category-group chip class in the queue row', () => {
141
141
  const html = renderToString(createElement(IssueRow, {
142
- issue: mk({ category_group: 'control_plane' }),
142
+ issue: mk({ category_group: 'control_plane', kind: 'Node' }),
143
143
  open: false,
144
144
  onToggle: () => undefined,
145
145
  }))
@@ -230,6 +230,9 @@ export interface Issue {
230
230
  // Pod crash context carried from the representative member.
231
231
  restart_count?: number;
232
232
  last_terminated_reason?: string;
233
+ /** Evidence for a container in an active restart loop (crashloop issues).
234
+ * Probe failures are observations alongside the restarts, not a cause. */
235
+ restart_loop?: IssueRestartLoop;
233
236
 
234
237
  /**
235
238
  * Best-effort timing evidence from K8s-native signals. Absent when Radar has
@@ -360,3 +363,24 @@ export function issueMessageParts(issue: Issue): { headline: string; detail: str
360
363
  if (normalized && normalized !== raw) return { headline: normalized, detail: raw };
361
364
  return { headline: raw, detail: '' };
362
365
  }
366
+
367
+ export interface IssueProbeFailure {
368
+ last_seen: string;
369
+ message?: string;
370
+ }
371
+
372
+ export interface IssueRestartLoop {
373
+ container: string;
374
+ sidecar?: boolean;
375
+ restart_count: number;
376
+ last_exit_code: number;
377
+ last_reason?: string;
378
+ last_finished_at?: string;
379
+ last_started_at?: string;
380
+ looping_pods?: number;
381
+ workload_pods?: number;
382
+ severity_reason?: string;
383
+ liveness_probe_failure?: IssueProbeFailure;
384
+ readiness_probe_failure?: IssueProbeFailure;
385
+ startup_probe_failure?: IssueProbeFailure;
386
+ }
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import { ToastProvider } from '../ui/Toast'
6
+ import { WorkloadLogsViewer } from './WorkloadLogsViewer'
7
+
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9
+
10
+ class FakeEventSource extends EventTarget {
11
+ static last: FakeEventSource | undefined
12
+ constructor(readonly url: string) {
13
+ super()
14
+ FakeEventSource.last = this
15
+ }
16
+ close() {}
17
+ emit(type: string, data: unknown) {
18
+ this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }))
19
+ }
20
+ }
21
+
22
+ let root: Root
23
+ let element: HTMLDivElement
24
+ beforeEach(() => {
25
+ vi.stubGlobal('EventSource', FakeEventSource)
26
+ element = document.createElement('div')
27
+ document.body.appendChild(element)
28
+ root = createRoot(element)
29
+ })
30
+ afterEach(async () => {
31
+ await act(async () => root.unmount())
32
+ element.remove()
33
+ vi.unstubAllGlobals()
34
+ })
35
+
36
+ it('clears the unreadable-sources notice when the stream ends', async () => {
37
+ const notice = '1 source could not be read: web-0/app: Kubernetes did not return this container\'s logs'
38
+ await act(async () =>
39
+ root.render(
40
+ <ToastProvider>
41
+ <WorkloadLogsViewer
42
+ name="web"
43
+ fetchAll={async () => ({ pods: [], logs: [] })}
44
+ createStream={() => new EventSource('/stream')}
45
+ autoStream
46
+ />
47
+ </ToastProvider>,
48
+ ),
49
+ )
50
+ const source = FakeEventSource.last
51
+ if (!source) throw new Error('stream was not opened')
52
+
53
+ await act(async () => source.emit('connected', { pods: [{ name: 'web-0', containers: ['app'] }] }))
54
+ await act(async () => source.emit('notice', { notice }))
55
+ expect(element.textContent).toContain(notice)
56
+
57
+ await act(async () => source.emit('end', { reason: 'no pods found' }))
58
+ expect(element.textContent).not.toContain(notice)
59
+ })
@@ -63,9 +63,15 @@ export interface WorkloadLogsViewerProps {
63
63
  * re-armed. Requires `createStream`. Default: false.
64
64
  */
65
65
  autoStream?: boolean
66
+ /** Pods selected when the pod list first loads; all pods when empty or none match. */
67
+ initialPods?: string[]
66
68
  }
67
69
 
68
- export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false }: WorkloadLogsViewerProps) {
70
+ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods }: WorkloadLogsViewerProps) {
71
+ const initialSelection = (names: string[]) => {
72
+ const wanted = names.filter((n) => initialPods?.includes(n))
73
+ return new Set(wanted.length > 0 ? wanted : names)
74
+ }
69
75
  const [selectedContainer, setSelectedContainer] = useState<string>('')
70
76
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
71
77
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
@@ -129,7 +135,9 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
129
135
  const previousPods = previousSnapshotPods.current
130
136
  const nextPods = resultPods.map(p => p.name)
131
137
  previousSnapshotPods.current = nextPods
132
- setSelectedPods(selected => previousPods === null || previousPods.every(pod => selected.has(pod))
138
+ setSelectedPods(selected => previousPods === null
139
+ ? initialSelection(nextPods)
140
+ : previousPods.every(pod => selected.has(pod))
133
141
  ? new Set(nextPods)
134
142
  : new Set(nextPods.filter(pod => selected.has(pod))))
135
143
 
@@ -194,7 +202,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
194
202
  setEmptyMessage(data.emptyMessage || null)
195
203
  setEmptyCommand(data.command || null)
196
204
  setSelectedPods(prev => (
197
- prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
205
+ prev.size === 0 ? initialSelection(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
198
206
  ))
199
207
  }
200
208
  },
@@ -205,6 +213,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
205
213
  content: data.content || '',
206
214
  container: data.container || '',
207
215
  pod: data.pod || '',
216
+ sourceLabel: data.sourceLabel,
208
217
  podColorIndex: podColorIndexRef.current.get(data.pod || ''),
209
218
  })
210
219
  }
@@ -237,7 +246,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
237
246
  // while new pod logs start flowing in
238
247
  }
239
248
  },
249
+ onNotice: (data: unknown) => {
250
+ const notice = typeof data === 'object' && data !== null && 'notice' in data ? data.notice : ''
251
+ setNotice(typeof notice === 'string' ? notice : '')
252
+ },
240
253
  onEnd: (data: any) => {
254
+ setNotice('')
241
255
  if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
242
256
  if (data?.command) setEmptyCommand(data.command)
243
257
  },
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { detectLogLevel } from './useLogBuffer'
3
+
4
+ describe('detectLogLevel with CloudNativePG records', () => {
5
+ it('uses the nested PostgreSQL severity over the instance manager level', () => {
6
+ const line = (sev: string) => JSON.stringify({ level: 'info', logger: 'postgres', msg: 'record', record: { error_severity: sev, message: 'x' } })
7
+ expect(detectLogLevel(line('ERROR'))).toBe('error')
8
+ expect(detectLogLevel(line('FATAL'))).toBe('error')
9
+ expect(detectLogLevel(line('WARNING'))).toBe('warn')
10
+ expect(detectLogLevel(line('LOG'))).toBe('info')
11
+ expect(detectLogLevel(line('DEBUG'))).toBe('debug')
12
+ })
13
+ it('ignores a record that is not a PostgreSQL one', () => {
14
+ expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'request failed', record: { error_severity: 20 } }))).toBe('error')
15
+ expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'x', record: { error_severity: 'minor' } }))).toBe('error')
16
+ })
17
+ })
@@ -10,6 +10,8 @@ export interface LogStreamHandlers {
10
10
  onPodAdded?: (data: unknown) => void
11
11
  /** Called when pods are terminated during streaming (workload logs only) */
12
12
  onPodRemoved?: (data: unknown) => void
13
+ /** Called when the set of sources the stream cannot read changes (workload logs only) */
14
+ onNotice?: (data: unknown) => void
13
15
  /** Called when the server ends the stream cleanly */
14
16
  onEnd?: (data: unknown) => void
15
17
  }
@@ -92,6 +94,15 @@ export function useLogStream() {
92
94
  }
93
95
  })
94
96
 
97
+ es.addEventListener('notice', (event) => {
98
+ if (!isCurrent()) return
99
+ if (handlers.onNotice) {
100
+ try { handlers.onNotice(JSON.parse((event as MessageEvent).data)) } catch (e) {
101
+ console.error('Failed to parse notice event:', e)
102
+ }
103
+ }
104
+ })
105
+
95
106
  es.addEventListener('end', (event) => {
96
107
  if (!isCurrent()) return
97
108
  endedRef.current = true
@@ -105,3 +105,79 @@ describe('ResourcesSidebar count visibility', () => {
105
105
  expect(html).toContain('HorizontalPodAutoscaler')
106
106
  })
107
107
  })
108
+
109
+ describe('ResourcesSidebar category workspaces', () => {
110
+ const cnpgCluster: APIResource = {
111
+ group: 'postgresql.cnpg.io',
112
+ version: 'v1',
113
+ kind: 'Cluster',
114
+ name: 'clusters',
115
+ namespaced: true,
116
+ isCrd: true,
117
+ verbs: ['list'],
118
+ }
119
+ const objectStore: APIResource = {
120
+ group: 'barmancloud.cnpg.io',
121
+ version: 'v1',
122
+ kind: 'ObjectStore',
123
+ name: 'objectstores',
124
+ namespaced: true,
125
+ isCrd: true,
126
+ verbs: ['list'],
127
+ }
128
+
129
+ it('renders destinations above the kinds, with the active object nested and no kind selected', () => {
130
+ const html = renderToString(
131
+ <ResourcesSidebar
132
+ selectedKind={null}
133
+ onSelectedKindChange={() => {}}
134
+ apiResources={[cnpgCluster, objectStore]}
135
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 2, 'barmancloud.cnpg.io/ObjectStore': 1 }}
136
+ categoryWorkspaces={{
137
+ CloudNativePG: {
138
+ destinations: [
139
+ { id: 'overview', label: 'Overview', count: 3, countTitle: '3 clusters need attention', active: true, child: { label: 'pg-orders' }, onSelect: () => {} },
140
+ ],
141
+ defaultKindsCollapsed: true,
142
+ scopeNote: 'Counts for namespace payments',
143
+ },
144
+ }}
145
+ />
146
+ )
147
+ expect(html).toContain('Workspace')
148
+ expect(html).toContain('Overview')
149
+ expect(html).toContain('pg-orders')
150
+ expect(html).toContain('Counts for namespace payments')
151
+ expect(html).toContain('Resource kinds')
152
+ expect(html).toMatch(/aria-expanded="false"[^>]*>(?:(?!<\/button>).)*Resource kinds/)
153
+ expect(html).not.toContain('selection-strong selection-text">Pod')
154
+ })
155
+
156
+ it('keeps a workspace category visible when it has no resources', () => {
157
+ const html = renderToString(
158
+ <ResourcesSidebar
159
+ selectedKind={null}
160
+ onSelectedKindChange={() => {}}
161
+ apiResources={[cnpgCluster]}
162
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 0 }}
163
+ categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', onSelect: () => {} }] } }}
164
+ />
165
+ )
166
+ expect(html).toContain('CloudNativePG')
167
+ expect(html).toContain('Overview')
168
+ })
169
+
170
+ it('labels API groups when a workspace category spans several', () => {
171
+ const html = renderToString(
172
+ <ResourcesSidebar
173
+ selectedKind={{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io' }}
174
+ onSelectedKindChange={() => {}}
175
+ apiResources={[cnpgCluster, objectStore]}
176
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 2, 'barmancloud.cnpg.io/ObjectStore': 1 }}
177
+ categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', onSelect: () => {} }], defaultKindsCollapsed: true } }}
178
+ />
179
+ )
180
+ expect(html).toContain('postgresql.cnpg.io')
181
+ expect(html).toContain('barmancloud.cnpg.io')
182
+ })
183
+ })