@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,6 +1,7 @@
1
1
  import { argoApplicationSetConditionsToGitOpsStatus, argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
2
2
  import type { GitOpsResourceTree } from '../../types/gitops-tree'
3
3
  import { formatCompactAge } from '../../utils/format'
4
+ import { apiVersionToGroup } from '../../utils/navigation'
4
5
 
5
6
  // =============================================================================
6
7
  // Shared helpers used by both per-cluster Radar's GitOps detail view (OSS
@@ -92,12 +93,31 @@ export function describeGitOpsTerminating(summary?: {
92
93
  }
93
94
  }
94
95
 
96
+ const FLUX_KIND_GROUPS: Readonly<Record<string, string | undefined>> = {
97
+ gitrepositories: 'source.toolkit.fluxcd.io',
98
+ ocirepositories: 'source.toolkit.fluxcd.io',
99
+ helmrepositories: 'source.toolkit.fluxcd.io',
100
+ helmcharts: 'source.toolkit.fluxcd.io',
101
+ buckets: 'source.toolkit.fluxcd.io',
102
+ kustomizations: 'kustomize.toolkit.fluxcd.io',
103
+ helmreleases: 'helm.toolkit.fluxcd.io',
104
+ alerts: 'notification.toolkit.fluxcd.io',
105
+ }
106
+ const ARGO_CD_KINDS = new Set(['applications', 'applicationsets', 'appprojects'])
107
+
108
+
95
109
  // getGitOpsResourceStatus dispatches sync/health extraction by kind. Argo
96
110
  // Applications read from .status (rich operationState + sync state); Argo
97
111
  // ApplicationSets read their own generator conditions; Flux kinds read
98
112
  // .status.conditions + .spec.suspend. Returns null if the resource has
99
113
  // no recognizable status (e.g. just-created, status not yet populated).
100
114
  export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStatus | null {
115
+ // Existing callers also pass status-only objects with a controller-specific
116
+ // kind. A loaded API identity, when present, must agree with that kind.
117
+ const group = resource?.apiVersion ? apiVersionToGroup(resource.apiVersion)
118
+ : ARGO_CD_KINDS.has(kind) ? 'argoproj.io' : FLUX_KIND_GROUPS[kind]
119
+ const tool = getGitOpsTool(kind, group)
120
+ if (!tool) return null
101
121
  if (kind === 'applications') {
102
122
  return argoStatusToGitOpsStatus(resource?.status ?? {})
103
123
  }
@@ -110,6 +130,7 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
110
130
  (resource?.status?.conditions ?? []) as FluxCondition[]
111
131
  )
112
132
  }
133
+ if (tool === 'argo') return null
113
134
  const conditions = (resource?.status?.conditions ?? []) as FluxCondition[]
114
135
  return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true, {
115
136
  generation: resource?.metadata?.generation,
@@ -117,12 +138,22 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
117
138
  })
118
139
  }
119
140
 
120
- // getGitOpsTool routes a kind+group to 'argo' or 'flux'. Same shape used
121
- // by both detail pages + the fleet list normalizers so a kustomization
122
- // always reads as 'flux' and an Argo Application always as 'argo'.
123
- export function getGitOpsTool(kind: string, group?: string): 'argo' | 'flux' {
124
- if (group === 'argoproj.io' || kind === 'applications' || kind === 'applicationsets' || kind === 'appprojects') return 'argo'
125
- return 'flux'
141
+
142
+ // getGitOpsTool decides the controller family from the API group, the same
143
+ // rule the backend applies (pkg/gitops ToolFor). The kind alone is not
144
+ // identity: SIG Apps (app.k8s.io) defines an Application too, and argoproj.io
145
+ // is shared with Argo Rollouts and Workflows. null means the object is not a
146
+ // supported GitOps resource and has no GitOps view or operations.
147
+ export function getGitOpsTool(kind: string, group: string | undefined): 'argo' | 'flux' | null {
148
+ if (group === 'argoproj.io') return ARGO_CD_KINDS.has(kind) ? 'argo' : null
149
+ if (group && FLUX_KIND_GROUPS[kind] === group) return 'flux'
150
+ return null
151
+ }
152
+
153
+ // gitOpsDetailGroup is the API group a detail page acts on: the fetched
154
+ // object's own apiVersion once it has loaded, the route's group before that.
155
+ export function gitOpsDetailGroup(routeGroup: string, resource: { apiVersion?: string } | null | undefined): string {
156
+ return resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : routeGroup
126
157
  }
127
158
 
128
159
  // isDestinationRef reports whether a resource named on a remote GitOps
@@ -41,6 +41,7 @@ export {
41
41
  describeGitOpsTerminating,
42
42
  getGitOpsResourceStatus,
43
43
  getGitOpsTool,
44
+ gitOpsDetailGroup,
44
45
  isDestinationRef,
45
46
  } from './detail-helpers'
46
47
  export { SyncOptionsDialog } from './SyncOptionsDialog'
@@ -88,8 +88,8 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
88
88
  tone="healthy"
89
89
  variant="card"
90
90
  icon={CircleCheck}
91
- headline="Nothing broken right now"
92
- body="No active issues across the selected scope."
91
+ headline="No issues detected"
92
+ body="Radar's checks found nothing broken in the selected scope. They don't cover every failure, and Audit lists configuration that can still break things."
93
93
  />
94
94
  ) : (
95
95
  <EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
@@ -288,13 +288,16 @@ export function IssueRow({
288
288
  onToggle();
289
289
  }
290
290
  }}
291
- className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
291
+ className={`group @container/issue flex cursor-pointer items-center gap-3 phone:flex-wrap phone:gap-y-2 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
292
292
  >
293
293
  <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
294
294
 
295
- <div className="flex min-w-0 flex-1 flex-col gap-1">
296
- <div className="flex min-w-0 items-baseline gap-2">
297
- <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
295
+ {/* Phones: the title takes its own line with the badge and reason
296
+ under it, and the severity, actions and chevron get a last line
297
+ (the right cluster below wraps under the text). */}
298
+ <div className="flex min-w-0 flex-1 flex-col gap-1 phone:basis-[calc(100%-30px)]">
299
+ <div className="flex min-w-0 items-baseline gap-2 phone:flex-wrap phone:gap-y-1">
300
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary phone:basis-full">{issueTitle(issue)}</span>
298
301
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
299
302
  {renderBadges?.(slotCtx)}
300
303
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -351,15 +354,17 @@ export function IssueRow({
351
354
  {/* Narrow container: the meta chips drop to their own line here rather
352
355
  than stealing width from the title on the row above. Hidden once the
353
356
  row is wide enough to hold them inline in the right cluster. */}
354
- {metaChips('flex @2xl/issue:hidden')}
357
+ {metaChips('flex @2xl/issue:hidden phone:hidden')}
355
358
  </div>
356
359
 
357
360
  {/* Right cluster — the always-visible affordances (per-row action +
358
361
  chevron) plus, on a wide container, the inline meta chips. */}
359
- <div className="flex shrink-0 items-center gap-3">
360
- {metaChips('hidden @2xl/issue:flex')}
361
- {renderActions?.(slotCtx)}
362
- <CollapseChevron open={open} className="h-4 w-4" />
362
+ <div className="flex shrink-0 items-center gap-3 phone:basis-full phone:pl-[30px]">
363
+ {metaChips('hidden @2xl/issue:flex phone:flex')}
364
+ <div className="contents phone:ml-auto phone:flex phone:items-center phone:gap-3">
365
+ {renderActions?.(slotCtx)}
366
+ <CollapseChevron open={open} className="h-4 w-4" />
367
+ </div>
363
368
  </div>
364
369
  </div>
365
370
 
@@ -54,6 +54,8 @@ export function diagnosticFactLabel(type: string): string {
54
54
  return 'Admission dependency';
55
55
  case 'network_policy_isolates':
56
56
  return 'Affected resources';
57
+ case 'tls_certificate_consumers':
58
+ return 'Used for TLS by';
57
59
  default:
58
60
  return type.replace(/_/g, ' ');
59
61
  }
@@ -140,6 +140,7 @@ export function categoryLabel(category: string): string {
140
140
  // Reasons filed under a category whose label names a different operation:
141
141
  // CNPG archiving and an unanswered scheduled run sit under backup_failed.
142
142
  const REASON_TITLE: Record<string, string> = {
143
+ FlaggerCanaryFailed: "Canary rollout failed",
143
144
  InvalidSchedule: 'Scheduling error reported',
144
145
  UnparseableSchedule: 'Scheduling error reported',
145
146
  UnParseableCronJobSchedule: 'Scheduling error reported',
@@ -707,7 +707,7 @@ export function LogCore({
707
707
  })}
708
708
  </div>
709
709
 
710
- <div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2">
710
+ <div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2 phone:gap-1">
711
711
 
712
712
  {/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
713
713
  {hasStructuredEntries && (
@@ -715,7 +715,7 @@ export function LogCore({
715
715
  <Tooltip content={`Structured: ${STRUCTURED_MODE_LABELS[structuredMode]} — click to cycle`} delay={TIP_DELAY} position="bottom">
716
716
  <button
717
717
  onClick={cycleStructuredMode}
718
- className={`p-1.5 rounded-l transition-colors ${
718
+ className={`p-1.5 rounded-l phone:rounded transition-colors ${
719
719
  structuredMode === 'compact' ? iconBtnInactive : palette.toolbarActive
720
720
  }`}
721
721
  aria-label={`Structured log display mode: ${STRUCTURED_MODE_LABELS[structuredMode]}`}
@@ -723,7 +723,8 @@ export function LogCore({
723
723
  <Braces className="w-4 h-4" />
724
724
  </button>
725
725
  </Tooltip>
726
- <div className="relative" ref={structuredMenuRef}>
726
+ {/* Phones keep the icon (it cycles the modes) and drop the picker to fit one row. */}
727
+ <div className="relative phone:hidden" ref={structuredMenuRef}>
727
728
  <Tooltip content="Pick structured display mode" delay={TIP_DELAY} position="bottom">
728
729
  <button
729
730
  onClick={() => setShowStructuredMenu(prev => !prev)}
@@ -874,7 +875,7 @@ export function LogCore({
874
875
  </Tooltip>
875
876
 
876
877
  {!themeLocked && (
877
- <Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom">
878
+ <Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom" wrapperClassName="phone:hidden">
878
879
  <button
879
880
  onClick={toggleDark}
880
881
  className={iconBtnInactive}
@@ -987,8 +988,9 @@ export function LogCore({
987
988
  </div>
988
989
 
989
990
  {/* Search bar */}
991
+ {/* Phones: the query takes the first line and the search options wrap under it. */}
990
992
  {search.isOpen && (
991
- <div className={`@container/logsearch flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
993
+ <div className={`@container/logsearch flex items-center gap-2 phone:flex-wrap phone:gap-y-1.5 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
992
994
  <Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
993
995
  <Input
994
996
  value={search.query}
@@ -1005,7 +1007,7 @@ export function LogCore({
1005
1007
  }
1006
1008
  }}
1007
1009
  placeholder="Search logs..."
1008
- className={`flex-1 bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
1010
+ className={`flex-1 phone:basis-[calc(100%-1.5rem)] bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
1009
1011
  autoFocus
1010
1012
  />
1011
1013
 
@@ -1187,8 +1189,8 @@ export function LogCore({
1187
1189
  </div>
1188
1190
  )}
1189
1191
 
1190
- {/* Keyboard shortcut hints */}
1191
- <div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
1192
+ {/* Keyboard shortcut hints (a phone has no keyboard to use them) */}
1193
+ <div className={`flex items-center gap-4 px-3 py-1 border-t phone:hidden ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
1192
1194
  {onStartStream && (!sourceUnavailable || isStreaming) && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
1193
1195
  <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
1194
1196
  {search.mode !== 'hide' && (
@@ -1353,7 +1355,7 @@ function LogLine({
1353
1355
  }
1354
1356
 
1355
1357
  return (
1356
- <div className={`flex ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
1358
+ <div className={`flex phone:flex-wrap ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
1357
1359
  {leadSlot}
1358
1360
  {showTimestamp && entry.timestamp && (
1359
1361
  <span
@@ -1371,10 +1373,10 @@ function LogLine({
1371
1373
  [{entry.sourceLabel || entry.pod.split('-').slice(-2).join('-')}]
1372
1374
  </span>
1373
1375
  )}
1374
- <span className="flex-1 min-w-0">{contentElement}</span>
1376
+ <span className="flex-1 min-w-0 phone:order-last phone:basis-full">{contentElement}</span>
1375
1377
  <button
1376
1378
  onClick={handleCopy}
1377
- className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity`}
1379
+ className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity phone:ml-auto phone:opacity-100`}
1378
1380
  title="Copy line"
1379
1381
  >
1380
1382
  <Copy className="w-3 h-3" />
@@ -60,16 +60,16 @@ afterEach(async () => {
60
60
  vi.unstubAllGlobals()
61
61
  })
62
62
 
63
- async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app']) {
63
+ async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app'], podName = 'web-0') {
64
64
  await act(async () =>
65
65
  root.render(
66
66
  <ToastProvider>
67
67
  <LogsViewer
68
68
  namespace="default"
69
- podName="web-0"
69
+ podName={podName}
70
70
  containers={containers}
71
71
  fetchLogs={fetchLogs}
72
- createStream={() => new EventSource('/stream')}
72
+ createStream={() => new EventSource(`/pods/default/${podName}/logs/stream`)}
73
73
  autoStream={autoStream}
74
74
  />
75
75
  </ToastProvider>,
@@ -234,62 +234,6 @@ it('with auto-stream on, a time range picked after the stream ended loads it ins
234
234
  expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
235
235
  })
236
236
 
237
- it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
238
- const fetchLogs = vi.fn(async () => ({ app: '' }))
239
- await renderViewer(fetchLogs, true)
240
- const opening = FakeEventSource.last
241
-
242
- const range = element.querySelector<HTMLSelectElement>('select')
243
- if (!range) throw new Error('range select not rendered')
244
- await act(async () => {
245
- range.value = range.options[range.options.length - 1].value
246
- range.dispatchEvent(new Event('change', { bubbles: true }))
247
- })
248
-
249
- expect(opening?.closed).toBe(true)
250
- expect(FakeEventSource.last).not.toBe(opening)
251
- expect(fetchLogs).not.toHaveBeenCalled()
252
- })
253
-
254
- it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
255
- const fetchLogs = vi.fn(async () => ({ app: '' }))
256
- await renderViewer(fetchLogs, true)
257
- await act(async () => FakeEventSource.last?.emit('connected', {}))
258
- await act(async () => FakeEventSource.last?.emit('end', {}))
259
- const range = element.querySelector<HTMLSelectElement>('select')
260
- if (!range) throw new Error('range select not rendered')
261
- const pick = (index: number) => act(async () => {
262
- range.value = range.options[index].value
263
- range.dispatchEvent(new Event('change', { bubbles: true }))
264
- })
265
- await pick(range.options.length - 1)
266
- await act(async () => button('Stream').click())
267
- const opening = FakeEventSource.last
268
-
269
- await pick(range.options.length - 2)
270
-
271
- expect(opening?.closed).toBe(true)
272
- expect(FakeEventSource.last).not.toBe(opening)
273
- })
274
-
275
- it('a range picked while a manually started stream opens does not cancel it', async () => {
276
- const fetchLogs = vi.fn(async () => ({ app: '' }))
277
- await renderViewer(fetchLogs, true)
278
- await act(async () => FakeEventSource.last?.emit('connected', {}))
279
- await act(async () => button('Stop').click())
280
- await act(async () => button('Stream').click())
281
- const opening = FakeEventSource.last
282
- const range = element.querySelector<HTMLSelectElement>('select')
283
- if (!range) throw new Error('range select not rendered')
284
-
285
- await act(async () => {
286
- range.value = range.options[range.options.length - 1].value
287
- range.dispatchEvent(new Event('change', { bubbles: true }))
288
- })
289
-
290
- expect(opening?.closed).toBe(false)
291
- })
292
-
293
237
  it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
294
238
  const fetchLogs = vi.fn(async () => ({ app: '' }))
295
239
  await renderViewer(fetchLogs, true)
@@ -367,3 +311,104 @@ it('still shows a Radar failure as an error', async () => {
367
311
  await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
368
312
  expect(messageClass(failure)).toMatch(/text-red/)
369
313
  })
314
+
315
+ function rangeSelect(): HTMLSelectElement {
316
+ const range = element.querySelector<HTMLSelectElement>('select')
317
+ if (!range) throw new Error('range select not rendered')
318
+ return range
319
+ }
320
+
321
+ const nextFrame = () => act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
322
+
323
+ it('opening another pod with the same container name streams it, even after the first pod\'s stream ended', async () => {
324
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
325
+ await renderViewer(fetchLogs, true, ['app'], 'web-0')
326
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
327
+ await act(async () => FakeEventSource.last?.emit('end', {}))
328
+ const ended = FakeEventSource.last
329
+
330
+ await renderViewer(fetchLogs, true, ['app'], 'web-1')
331
+
332
+ expect(FakeEventSource.last).not.toBe(ended)
333
+ expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
334
+ expect(fetchLogs).not.toHaveBeenCalled()
335
+ })
336
+
337
+ it('a manual Stop on one pod does not stop the next pod from streaming', async () => {
338
+ const fetchLogs = vi.fn(async () => ({ app: '' }))
339
+ await renderViewer(fetchLogs, true, ['app'], 'web-0')
340
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
341
+ await act(async () => button('Stop').click())
342
+
343
+ await renderViewer(fetchLogs, true, ['app'], 'web-1')
344
+
345
+ expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
346
+ })
347
+
348
+ it('Previous and the range are locked while an auto-stream opens', async () => {
349
+ await renderViewer(async () => ({ app: '' }), true)
350
+ expect(previousCheckbox().disabled).toBe(true)
351
+ expect(rangeSelect().disabled).toBe(true)
352
+
353
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
354
+ await act(async () => FakeEventSource.last?.emit('end', {}))
355
+ expect(previousCheckbox().disabled).toBe(false)
356
+ expect(rangeSelect().disabled).toBe(false)
357
+ })
358
+
359
+ it('Previous and the range are locked while a manually started stream opens', async () => {
360
+ await renderViewer(async () => ({ app: '' }))
361
+ expect(previousCheckbox().disabled).toBe(false)
362
+
363
+ await act(async () => button('Stream').click())
364
+
365
+ expect(previousCheckbox().disabled).toBe(true)
366
+ expect(rangeSelect().disabled).toBe(true)
367
+ })
368
+
369
+ it('a stream that is still opening can be stopped, which unlocks Previous', async () => {
370
+ await renderViewer(async () => ({ app: '' }))
371
+ await act(async () => button('Stream').click())
372
+ const opening = FakeEventSource.last
373
+
374
+ await act(async () => button('Stop').click())
375
+
376
+ expect(opening?.closed).toBe(true)
377
+ expect(previousCheckbox().disabled).toBe(false)
378
+ expect(button('Stream')).toBeTruthy()
379
+ })
380
+
381
+ it('a stream failure after lines arrived is shown above them', async () => {
382
+ await renderViewer(async () => ({ app: '' }))
383
+ await act(async () => button('Stream').click())
384
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
385
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'live line', container: 'app' }))
386
+ await nextFrame()
387
+ await act(async () => FakeEventSource.last?.emit('error', { error: 'Read error: unexpected EOF' }))
388
+
389
+ expect(element.querySelector('[role="alert"]')?.textContent).toBe('Read error: unexpected EOF · Previously loaded logs remain below.')
390
+ expect(element.textContent).toContain('OTHER 1')
391
+ })
392
+
393
+ const NOT_STARTED = 'This container has not started yet, so it has no logs. Kubernetes reports ContainerCreating.'
394
+
395
+ it('shows a container that has not started in the empty-pane style', async () => {
396
+ await renderViewer(async () => { throw new ApiError(NOT_STARTED, { error: NOT_STARTED, error_code: 'container_not_started' }) })
397
+ expect(messageClass(NOT_STARTED)).not.toMatch(/text-red/)
398
+ })
399
+
400
+ it('shows what the live stream is waiting for, and drops it once lines arrive', async () => {
401
+ const waiting = `${NOT_STARTED} Logs will show here once it starts.`
402
+ await renderViewer(async () => ({ app: '' }), true)
403
+ await act(async () => FakeEventSource.last?.emit('connected', {}))
404
+ await act(async () => FakeEventSource.last?.emit('notice', { notice: waiting, notice_code: 'container_not_started' }))
405
+ expect(messageClass(waiting)).not.toMatch(/text-red/)
406
+ expect(button('Stop')).toBeTruthy()
407
+
408
+ await act(async () => FakeEventSource.last?.emit('notice', { notice: '' }))
409
+ await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'started', container: 'app' }))
410
+ await nextFrame()
411
+
412
+ expect(element.textContent).not.toContain(waiting)
413
+ expect(element.textContent).toContain('OTHER 1')
414
+ })
@@ -43,7 +43,7 @@ export interface LogsViewerProps {
43
43
  // Kubernetes' answers that there are no logs to show, such as "no earlier run".
44
44
  // They describe the pod, not a failure in Radar, so they are shown like an
45
45
  // empty pane rather than as an error.
46
- const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable'])
46
+ const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable', 'container_not_started'])
47
47
 
48
48
  function isClusterAnswerCode(code: unknown): boolean {
49
49
  return typeof code === 'string' && CLUSTER_ANSWER_CODES.has(code)
@@ -56,7 +56,14 @@ function clusterAnswerOf(err: unknown): string | null {
56
56
  return isClusterAnswerCode(data.error_code) ? err.message : null
57
57
  }
58
58
 
59
- export function LogsViewer({
59
+ // Keyed by pod: everything the viewer remembers (the container, Previous, the
60
+ // auto-start latch, a manual Stop) belongs to one pod, and a host that swaps
61
+ // the pod without remounting would carry it over to the next one.
62
+ export function LogsViewer(props: LogsViewerProps) {
63
+ return <PodLogsViewer key={`${props.namespace}/${props.podName}`} {...props} />
64
+ }
65
+
66
+ function PodLogsViewer({
60
67
  namespace: _namespace,
61
68
  podName,
62
69
  containers,
@@ -74,6 +81,8 @@ export function LogsViewer({
74
81
  const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
75
82
  const [logRange, setLogRange] = useState('500')
76
83
  const [showPrevious, setShowPrevious] = useState(false)
84
+ // What an open stream says it is waiting for, e.g. a container to start.
85
+ const [streamNotice, setStreamNotice] = useState<string | null>(null)
77
86
  const { showError, showSuccess } = useToast()
78
87
 
79
88
  const { tailLines, sinceSeconds } = parseLogRange(logRange)
@@ -109,6 +118,7 @@ export function LogsViewer({
109
118
  setIsLoading(true)
110
119
  setFetchError(null)
111
120
  setEmptyMessage(null)
121
+ setStreamNotice(null)
112
122
  try {
113
123
  const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
114
124
  if (seq !== loadSeqRef.current) return
@@ -171,6 +181,7 @@ export function LogsViewer({
171
181
  setIsLoading(false)
172
182
  setFetchError(null)
173
183
  setEmptyMessage(null)
184
+ setStreamNotice(null)
174
185
  // The stream replays the last N lines (TailLines + Follow); clear first so
175
186
  // they don't duplicate lines already in the buffer (the snapshot on the
176
187
  // manual path, or an earlier stream on restart).
@@ -184,6 +195,10 @@ export function LogsViewer({
184
195
  content: data.content || '',
185
196
  container: data.container || selectedContainer,
186
197
  }),
198
+ onNotice: (data: unknown) => {
199
+ const notice = typeof data === 'object' && data !== null && 'notice' in data && typeof data.notice === 'string' ? data.notice : ''
200
+ setStreamNotice(notice || null)
201
+ },
187
202
  },
188
203
  'Log stream connection failed',
189
204
  )
@@ -191,6 +206,7 @@ export function LogsViewer({
191
206
 
192
207
  const handleStopStreaming = useCallback(() => {
193
208
  userStoppedRef.current = true
209
+ setStreamNotice(null)
194
210
  stopStreaming()
195
211
  }, [stopStreaming])
196
212
 
@@ -220,24 +236,28 @@ export function LogsViewer({
220
236
  }
221
237
  }, [podName, selectedContainer, overrideDownload, showError, showSuccess])
222
238
 
239
+ // Previous and the range describe what a snapshot loads, so they stay
240
+ // locked from the moment a stream starts opening, not only once it is open.
241
+ const streamBusy = isStreaming || connecting
242
+
223
243
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
224
244
  <>
225
245
  <ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
226
246
 
227
- <Tooltip content={isStreaming ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
228
- <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${isStreaming ? 'opacity-50 cursor-not-allowed' : ''}`}>
247
+ <Tooltip content={streamBusy ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
248
+ <label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${streamBusy ? 'opacity-50 cursor-not-allowed' : ''}`}>
229
249
  <input
230
250
  type="checkbox"
231
251
  checked={showPrevious}
232
252
  onChange={(e) => setShowPrevious(e.target.checked)}
233
- disabled={isStreaming}
253
+ disabled={streamBusy}
234
254
  className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
235
255
  />
236
256
  <span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
237
257
  </label>
238
258
  </Tooltip>
239
259
 
240
- <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={isStreaming} />
260
+ <LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={streamBusy} />
241
261
  </>
242
262
  )
243
263
 
@@ -260,13 +280,13 @@ export function LogsViewer({
260
280
  getRetainedEntries={getRetainedEntries}
261
281
  isLoading={isLoading || isConnecting}
262
282
  errorMessage={entries.length === 0 ? fetchError || (!emptyMessage && !streamAnswer ? streamError : null) : null}
263
- isStreaming={isStreaming}
283
+ isStreaming={streamBusy}
264
284
  onStartStream={createStream ? handleStartStreaming : undefined}
265
285
  onStopStream={handleStopStreaming}
266
286
  onRefresh={loadLogs}
267
287
  onDownload={downloadLogs}
268
288
  onClear={clear}
269
- emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
289
+ emptyMessage={emptyMessage ?? streamAnswer ?? streamNotice ?? undefined}
270
290
  toolbarExtra={renderToolbarExtra}
271
291
  forceDark={forceDark}
272
292
  defaultDark={defaultDark}
@@ -327,7 +327,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
327
327
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
328
328
  <>
329
329
  {/* Pod filter */}
330
- <fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50">
330
+ <fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50 phone:flex-wrap">
331
331
  <div className="relative">
332
332
  <button
333
333
  onClick={() => setShowPodFilter(v => !v)}