@skyhook-io/k8s-ui 1.16.3 → 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 (121) hide show
  1. package/package.json +1 -1
  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/primitives.tsx +3 -3
  6. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  7. package/src/components/gitops/detail-helpers.ts +1 -1
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  9. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  10. package/src/components/issues/issue-timing.ts +2 -2
  11. package/src/components/issues/issues.test.ts +1 -1
  12. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  13. package/src/components/logs/WorkloadLogsViewer.tsx +5 -0
  14. package/src/components/logs/useLogStream.ts +11 -0
  15. package/src/components/resources/ResourcesView.tsx +7 -8
  16. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  17. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  18. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  19. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  20. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  22. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  24. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  25. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  26. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  27. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  28. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  30. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  32. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  33. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  34. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  35. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  36. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  38. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  39. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  40. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  41. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  42. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  43. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  44. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  45. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  46. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  47. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  48. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  49. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  51. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  54. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  55. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  56. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  57. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  58. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  59. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  60. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  61. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  62. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  63. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  64. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  66. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  67. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  68. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  69. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  70. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  71. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  72. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  73. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  74. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  75. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  76. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  77. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  78. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  79. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  80. package/src/components/resources/resource-utils-cnpg.ts +0 -4
  81. package/src/components/resources/resource-utils-keda.ts +1 -7
  82. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  83. package/src/components/resources/resource-utils-velero.ts +0 -12
  84. package/src/components/resources/resource-utils.ts +17 -33
  85. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  86. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  87. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  88. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  89. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  90. package/src/components/shared/SetImageDialog.test.ts +75 -6
  91. package/src/components/shared/SetImageDialog.tsx +92 -84
  92. package/src/components/shared/index.ts +2 -1
  93. package/src/components/timeline/TimelineList.tsx +2 -15
  94. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  95. package/src/components/topology/TopologyGraph.tsx +5 -3
  96. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  97. package/src/components/ui/ScheduleValue.tsx +3 -2
  98. package/src/components/ui/drawer-components.test.tsx +66 -1
  99. package/src/components/ui/drawer-components.tsx +43 -29
  100. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  101. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  102. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  103. package/src/components/workload/WorkloadView.tsx +120 -43
  104. package/src/types/capacity.ts +10 -0
  105. package/src/types/core.ts +32 -1
  106. package/src/utils/format.test.ts +40 -2
  107. package/src/utils/format.ts +5 -1
  108. package/src/utils/gateway-references.test.ts +17 -0
  109. package/src/utils/gateway-references.ts +29 -0
  110. package/src/utils/gitops-write-guard.test.ts +401 -0
  111. package/src/utils/gitops-write-guard.ts +509 -0
  112. package/src/utils/index.ts +2 -0
  113. package/src/utils/ingress-class-references.ts +22 -0
  114. package/src/utils/navigation.test.ts +13 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/node-lifecycle.test.ts +36 -0
  117. package/src/utils/node-lifecycle.ts +77 -0
  118. package/src/utils/resource-hierarchy.test.ts +32 -0
  119. package/src/utils/resource-hierarchy.ts +28 -2
  120. package/src/utils/resource-refs.test.ts +23 -0
  121. package/src/utils/resource-refs.ts +30 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.16.3",
3
+ "version": "1.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -377,6 +377,21 @@ describe("ApplicationDetail shell", () => {
377
377
  expect(local).toContain('ring is full, so the oldest activity is not loaded')
378
378
  })
379
379
 
380
+ it('shows the host history-limit note beside the history, and nothing without one', () => {
381
+ const base = {
382
+ selectedView: 'history' as const,
383
+ onSelectView: () => {},
384
+ selectedWorkloadKey: null,
385
+ onSelectWorkload: () => {},
386
+ historyMode: 'local' as const,
387
+ historyRange: 'all' as const,
388
+ historyRangeOptions: [{ value: 'all' as const, label: 'All observed' }],
389
+ historyItems: [],
390
+ }
391
+ expect(renderDetail({ ...base, historyRetentionNote: <span>Host limit note</span> })).toContain('<span>Host limit note</span>')
392
+ expect(renderDetail(base)).not.toContain('Host limit note')
393
+ })
394
+
380
395
  it('shows a single honest error state when runtime history fails without source history', () => {
381
396
  const html = renderDetail({
382
397
  selectedView: 'history',
@@ -89,7 +89,7 @@ import {
89
89
  overlayProvenance,
90
90
  } from "../../utils/applications";
91
91
  import { PaneLoader } from "../ui/PaneLoader";
92
- import { midTruncate } from "../../utils/format";
92
+ import { formatRelativeAgeTime, midTruncate } from "../../utils/format";
93
93
  import { VersionTooltip, AppIdentityTooltip, RolloutSummaryTooltip } from "./AppTooltips";
94
94
  import {
95
95
  ProvenanceBadge,
@@ -253,6 +253,9 @@ export type ApplicationDetailProps = {
253
253
  // The retained ring itself was row-capped: the OLDEST part of the retention
254
254
  // window is not loaded, independent of the per-query event limit.
255
255
  historyRingTruncated?: boolean;
256
+ // The host's note about how far back history goes, or the stretch before
257
+ // the limit reads as "no history".
258
+ historyRetentionNote?: ReactNode;
256
259
  onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
257
260
  onOpenTimeline?: (timestamp?: string) => void;
258
261
  onOpenSource?: (source: AppSourceRef) => void;
@@ -346,6 +349,7 @@ export function ApplicationDetail({
346
349
  historyCoverageRecordCount,
347
350
  historyRuntimeLimited,
348
351
  historyRingTruncated,
352
+ historyRetentionNote,
349
353
  onHistoryRangeChange,
350
354
  onOpenTimeline,
351
355
  onOpenSource,
@@ -784,6 +788,7 @@ export function ApplicationDetail({
784
788
  historyCoverageRecordCount={historyCoverageRecordCount}
785
789
  historyRuntimeLimited={historyRuntimeLimited}
786
790
  historyRingTruncated={historyRingTruncated}
791
+ historyRetentionNote={historyRetentionNote}
787
792
  onHistoryRangeChange={onHistoryRangeChange}
788
793
  onOpenTimeline={onOpenTimeline}
789
794
  onOpenSource={onOpenSource}
@@ -832,6 +837,7 @@ function ApplicationWorkspace({
832
837
  historyCoverageRecordCount,
833
838
  historyRuntimeLimited,
834
839
  historyRingTruncated,
840
+ historyRetentionNote,
835
841
  onHistoryRangeChange,
836
842
  onOpenTimeline,
837
843
  onOpenSource,
@@ -884,6 +890,9 @@ function ApplicationWorkspace({
884
890
  historyCoverageRecordCount?: number;
885
891
  historyRuntimeLimited?: boolean;
886
892
  historyRingTruncated?: boolean;
893
+ // The host's note about how far back history goes, or the stretch before
894
+ // the limit reads as "no history".
895
+ historyRetentionNote?: ReactNode;
887
896
  onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
888
897
  onOpenTimeline?: (timestamp?: string) => void;
889
898
  onOpenSource?: (source: AppSourceRef) => void;
@@ -953,6 +962,7 @@ function ApplicationWorkspace({
953
962
  coverageRecordCount={historyCoverageRecordCount}
954
963
  runtimeLimited={historyRuntimeLimited}
955
964
  ringTruncated={historyRingTruncated}
965
+ retentionNote={historyRetentionNote}
956
966
  workloads={workloads}
957
967
  onSelectWorkload={onSelectWorkload}
958
968
  onNavigateToResource={onNavigateToResource}
@@ -1827,7 +1837,7 @@ function batchOverviewStats(activity: AppBatchActivity[]) {
1827
1837
  return {
1828
1838
  latestValue: latest?.latestRunPhase || latest?.label || "None",
1829
1839
  latestDetail: latest
1830
- ? `${latest.latestRunName || latest.workload.name}${latestRunTimestamp(latest) ? ` · ${relativeTime(latestRunTimestamp(latest)!)} ` : ""}`.trim()
1840
+ ? `${latest.latestRunName || latest.workload.name}${latestRunTimestamp(latest) ? ` · ${formatRelativeAgeTime(latestRunTimestamp(latest)!)} ` : ""}`.trim()
1831
1841
  : "No retained runs",
1832
1842
  retainedValue:
1833
1843
  retained > 0 ? `${succeeded} succeeded / ${failed} failed` : "0 retained",
@@ -1842,7 +1852,7 @@ function batchOverviewStats(activity: AppBatchActivity[]) {
1842
1852
  : `${schedules.length} schedules`,
1843
1853
  scheduleDetail:
1844
1854
  schedules.length === 1 && latest?.lastSuccessfulAt
1845
- ? `last success ${relativeTime(latest.lastSuccessfulAt)}`
1855
+ ? `last success ${formatRelativeAgeTime(latest.lastSuccessfulAt)}`
1846
1856
  : undefined,
1847
1857
  scheduleMono: schedules.length === 1,
1848
1858
  };
@@ -1856,17 +1866,6 @@ function activeWorkloadNames(activity: AppBatchActivity[]): string {
1856
1866
  return `${active.slice(0, 2).join(", ")} +${active.length - 2} more`;
1857
1867
  }
1858
1868
 
1859
- function relativeTime(value: string): string {
1860
- const t = Date.parse(value);
1861
- if (Number.isNaN(t)) return value;
1862
- const diff = Date.now() - t;
1863
- if (diff < 60_000) return "just now";
1864
- const minutes = Math.floor(diff / 60_000);
1865
- if (minutes < 60) return `${minutes}m ago`;
1866
- const hours = Math.floor(minutes / 60);
1867
- if (hours < 48) return `${hours}h ago`;
1868
- return `${Math.floor(hours / 24)}d ago`;
1869
- }
1870
1869
 
1871
1870
  function ApplicationLatestHistory({
1872
1871
  history,
@@ -2093,7 +2092,7 @@ function workloadRuntimeDetail(workload: AppWorkload): string {
2093
2092
  const at =
2094
2093
  batch.latestStartedAt || batch.latestFinishedAt || batch.lastScheduledAt;
2095
2094
  if (batch.latestRunName)
2096
- return `${midTruncate(batch.latestRunName, 28)}${at ? ` · ${relativeTime(at)}` : ""}`;
2095
+ return `${midTruncate(batch.latestRunName, 28)}${at ? ` · ${formatRelativeAgeTime(at)}` : ""}`;
2097
2096
  if (batch.schedule) return `Schedule ${batch.schedule}`;
2098
2097
  return "No retained runs";
2099
2098
  }
@@ -2201,6 +2200,7 @@ function ApplicationHistoryView({
2201
2200
  coverageRecordCount,
2202
2201
  runtimeLimited,
2203
2202
  ringTruncated,
2203
+ retentionNote,
2204
2204
  workloads,
2205
2205
  onSelectWorkload,
2206
2206
  onNavigateToResource,
@@ -2219,6 +2219,7 @@ function ApplicationHistoryView({
2219
2219
  coverageRecordCount?: number;
2220
2220
  runtimeLimited?: boolean;
2221
2221
  ringTruncated?: boolean;
2222
+ retentionNote?: ReactNode;
2222
2223
  workloads: AppWorkload[];
2223
2224
  onSelectWorkload: (workload: AppWorkload) => void;
2224
2225
  onNavigateToResource?: (resource: ResourceRef) => void;
@@ -2428,6 +2429,7 @@ function ApplicationHistoryView({
2428
2429
  application activity in this range may not be included.
2429
2430
  </div>
2430
2431
  )}
2432
+ {retentionNote && <div>{retentionNote}</div>}
2431
2433
  {ringTruncated && (
2432
2434
  <div>
2433
2435
  {mode === "retained"
@@ -61,19 +61,8 @@ export interface CheckResourceRef {
61
61
  /** Built-in (Radar-detected) finding source. The only V1 source. */
62
62
  export const SOURCE_RADAR_BUILTIN = 'radar_builtin';
63
63
 
64
- /**
65
- * resourceKey mirrors Go `audit.ResourceKey(group, kind, namespace, name)`:
66
- * `group|Kind|namespace|name`. Group first because group and namespace can each
67
- * independently be empty; `|` is delimiter-safe (K8s API groups follow
68
- * DNS-subdomain rules and can't contain it).
69
- */
70
- export function resourceKey(group: string, kind: string, namespace: string, name: string): string {
71
- return `${group}|${kind}|${namespace}|${name}`;
72
- }
73
-
74
- export function resourceRefKey(ref: CheckResourceRef): string {
75
- return resourceKey(ref.group, ref.kind, ref.namespace, ref.name);
76
- }
64
+ // Preserve the Checks import path while sharing exact resource identity.
65
+ export { resourceKey, resourceRefKey } from '../../utils/resource-refs';
77
66
 
78
67
  /**
79
68
  * checkFindingKey is the canonical built-in finding key:
@@ -1,12 +1,12 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import type { HealthLevel } from '../resources/resource-utils'
4
- import { formatAge } from '../resources/resource-utils'
5
4
  import { StatusDot } from '../ui/status-tone'
6
5
  import { Tooltip } from '../ui/Tooltip'
7
6
  import { AlertBanner } from '../ui/drawer-components'
8
7
  import { TONE_TEXT_CLASS } from '../ui/severity-tone'
9
8
  import type { CNPGFact, CNPGProblem } from './workspace'
9
+ import { formatRelativeAgeTime } from '../../utils/format'
10
10
 
11
11
  export interface CNPGRef {
12
12
  kind: string
@@ -35,11 +35,11 @@ export function toneTextClass(tone: HealthLevel): string {
35
35
  }
36
36
 
37
37
  export function FactValue({ fact, className }: { fact: CNPGFact; className?: string }) {
38
- const age = fact.at ? formatAge(fact.at) : null
38
+ const when = fact.at ? formatRelativeAgeTime(fact.at) : null
39
39
  const body = (
40
40
  <span className={clsx(toneTextClass(fact.tone), className)}>
41
41
  {fact.text}
42
- {age && <span className="text-theme-text-secondary">{fact.text ? ' · ' : ''}{age} ago</span>}
42
+ {when && <span className="text-theme-text-secondary">{fact.text ? ' · ' : ''}{when}</span>}
43
43
  </span>
44
44
  )
45
45
  if (!fact.source && !fact.at) return body
@@ -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
- }
@@ -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
  }))
@@ -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
+ })
@@ -246,7 +246,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
246
246
  // while new pod logs start flowing in
247
247
  }
248
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
+ },
249
253
  onEnd: (data: any) => {
254
+ setNotice('')
250
255
  if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
251
256
  if (data?.command) setEmptyCommand(data.command)
252
257
  },
@@ -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
@@ -147,10 +147,10 @@ import {
147
147
  type PrinterColumnDef, type PrinterTable, PRINTER_COLUMN_PREFIX, formatPrinterCell, printerCellSortValue,
148
148
  printerCellTone, printerColumnKey, printerColumnWidth, printerTableKey, printerTableMatchesKind, readPrinterCell,
149
149
  } from './printer-columns'
150
- import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
150
+ import { SEVERITY_BADGE, SEVERITY_TEXT, healthToSeverity, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
151
151
  import { pluralize } from '../../utils/pluralize'
152
152
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
153
- import { parseQuantityToNumber } from '../../utils/format'
153
+ import { formatRelativeAgeTime, parseQuantityToNumber } from '../../utils/format'
154
154
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
155
155
  import { isRolloutActivityVisible } from '../../utils/workload-rollout'
156
156
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
@@ -6128,7 +6128,7 @@ export function ResourcesView({
6128
6128
  )}
6129
6129
 
6130
6130
  {/* Column picker */}
6131
- <div className="relative" ref={columnPickerRef}>
6131
+ <div className="relative flex" ref={columnPickerRef}>
6132
6132
  <Tooltip content="Configure columns">
6133
6133
  <button
6134
6134
  onClick={() => setShowColumnPicker(prev => !prev)}
@@ -8722,7 +8722,7 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
8722
8722
  </span>
8723
8723
  {problems.length > 0 && (
8724
8724
  <Tooltip content={problems.join(', ')}>
8725
- <span className="text-red-400">
8725
+ <span className={SEVERITY_TEXT[healthToSeverity(status.level ?? 'unknown')]}>
8726
8726
  <AlertTriangle className="w-3.5 h-3.5" />
8727
8727
  </span>
8728
8728
  </Tooltip>
@@ -8735,7 +8735,8 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
8735
8735
  return <span className="text-sm text-theme-text-secondary">{roles}</span>
8736
8736
  }
8737
8737
  case 'conditions': {
8738
- const { problems, healthy } = getNodeConditions(resource)
8738
+ const { problems, healthy, readinessLabel } = getNodeConditions(resource)
8739
+ if (problems.length === 0 && !healthy) return <span className="text-theme-text-secondary">{readinessLabel}</span>
8739
8740
  if (healthy) {
8740
8741
  return <span className="text-sm text-green-400">Healthy</span>
8741
8742
  }
@@ -9318,9 +9319,7 @@ function LeaseCell({ resource, column }: { resource: any; column: string }) {
9318
9319
  const elapsed = (Date.now() - new Date(renewTime).getTime()) / 1000
9319
9320
  const duration = resource.spec?.leaseDurationSeconds || 0
9320
9321
  const isStale = duration > 0 && elapsed > duration
9321
- const diff = Date.now() - new Date(renewTime).getTime()
9322
- const seconds = Math.floor(diff / 1000)
9323
- const label = seconds < 60 ? `${seconds}s ago` : seconds < 3600 ? `${Math.floor(seconds / 60)}m ago` : seconds < 86400 ? `${Math.floor(seconds / 3600)}h ago` : `${Math.floor(seconds / 86400)}d ago`
9322
+ const label = formatRelativeAgeTime(renewTime)
9324
9323
  return <span className={clsx('text-sm', isStale ? 'text-red-400' : 'text-green-400')} title={new Date(renewTime).toLocaleString()}>{label}</span>
9325
9324
  }
9326
9325
  default:
@@ -1,7 +1,7 @@
1
1
  import { Activity, BarChart3, Server } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
3
3
  import { Badge, type BadgeSeverity } from '../../ui/Badge'
4
- import { formatAge } from '../resource-utils'
4
+ import { TimeValue } from '../../ui/ScheduleValue'
5
5
 
6
6
  interface AnalysisRunRendererProps {
7
7
  data: any
@@ -60,8 +60,8 @@ export function AnalysisRunRenderer({ data, onNavigate }: AnalysisRunRendererPro
60
60
  <Property label="Phase" value={<Badge severity={phaseSeverity(phase)}>{phase}</Badge>} />
61
61
  {labels['rollout-type'] && <Property label="Triggered as" value={labels['rollout-type']} />}
62
62
  {labels['step-index'] && <Property label="Canary step" value={labels['step-index']} />}
63
- <Property label="Started" value={status.startedAt ? formatAge(status.startedAt) : undefined} />
64
- <Property label="Completed" value={status.completedAt ? formatAge(status.completedAt) : undefined} />
63
+ <Property label="Started" value={status.startedAt ? <TimeValue timestamp={status.startedAt} /> : undefined} />
64
+ <Property label="Completed" value={status.completedAt ? <TimeValue timestamp={status.completedAt} /> : undefined} />
65
65
  <Property label="Message" value={status.message} />
66
66
  {ownerRollout && (
67
67
  <Property
@@ -1,7 +1,6 @@
1
1
  import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
- import { formatAge } from '../resource-utils'
5
4
  import { GitOpsStatusBadge, ManagedResourcesList, SyncCountdown } from '../../gitops'
6
5
  import {
7
6
  argoStatusToGitOpsStatus,
@@ -11,6 +10,8 @@ import {
11
10
  } from '../../../types/gitops'
12
11
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
13
12
  import { buildRepoBrowseUrl, buildPathBrowseUrl } from '../../../utils/git-provider-urls'
13
+ import { TimeValue } from '../../ui/ScheduleValue'
14
+ import { formatRelativeAgeTime } from '../../../utils/format'
14
15
 
15
16
  const REPO_LINK_CLASS = 'text-blue-400 hover:text-blue-300 hover:underline break-all'
16
17
 
@@ -297,10 +298,10 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
297
298
  <Property label="Message" value={operationState.message} />
298
299
  )}
299
300
  {operationState.startedAt && (
300
- <Property label="Started" value={formatAge(operationState.startedAt)} />
301
+ <Property label="Started" value={<TimeValue timestamp={operationState.startedAt} />} />
301
302
  )}
302
303
  {operationState.finishedAt && (
303
- <Property label="Finished" value={formatAge(operationState.finishedAt)} />
304
+ <Property label="Finished" value={<TimeValue timestamp={operationState.finishedAt} />} />
304
305
  )}
305
306
  {operationState.retryCount != null && (
306
307
  <Property
@@ -402,7 +403,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
402
403
  </span>
403
404
  {entry.deployedAt && (
404
405
  <span className="text-xs text-theme-text-tertiary">
405
- {formatAge(entry.deployedAt)}
406
+ {formatRelativeAgeTime(entry.deployedAt)}
406
407
  </span>
407
408
  )}
408
409
  </div>
@@ -425,7 +426,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
425
426
  <Property label="Current Revision" value={status.sync.revision} />
426
427
  )}
427
428
  {status.reconciledAt && (
428
- <Property label="Last Reconciled" value={formatAge(status.reconciledAt)} />
429
+ <Property label="Last Reconciled" value={<TimeValue timestamp={status.reconciledAt} />} />
429
430
  )}
430
431
  </PropertyList>
431
432
  </Section>
@@ -2,8 +2,8 @@ import { useState } from 'react'
2
2
  import { Server, Globe, Network, Layers, Download, CheckCircle, AlertCircle } from 'lucide-react'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
4
4
  import { kindToPlural } from '../../../utils/navigation'
5
- import { formatAge } from '../resource-utils'
6
5
  import { getClusterStatus, getClusterClass, getClusterVersion, getClusterEndpoint, getProviderFromInfraKind, parseCAPIConditionMessage } from '../resource-utils-capi'
6
+ import { TimeValue } from '../../ui/ScheduleValue'
7
7
 
8
8
  interface Props {
9
9
  data: any
@@ -119,7 +119,7 @@ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '', canConnect
119
119
  {className !== '-' && <Property label="Cluster Class" value={className} />}
120
120
  {endpoint !== '-' && <Property label="Control Plane Endpoint" value={endpoint} />}
121
121
  {readyCond?.lastTransitionTime && (
122
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
122
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
123
123
  )}
124
124
  </PropertyList>
125
125
  </Section>