@skyhook-io/radar-app 0.2.2 → 0.3.1

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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,151 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { useIssues } from '../../api/client'
3
+ import { useConnection } from '../../context/ConnectionContext'
4
+ import type { SelectedResource } from '../../types'
5
+ import {
6
+ IssuesView,
7
+ PaneLoader,
8
+ PageHeader,
9
+ SummaryTile,
10
+ FreshnessControl,
11
+ ISSUE_SEVERITIES,
12
+ ISSUE_SEVERITY_LABEL,
13
+ type IssueResourceRef,
14
+ type IssueSeverity,
15
+ type SummaryTone,
16
+ } from '@skyhook-io/k8s-ui'
17
+ import { AlertTriangle } from 'lucide-react'
18
+ import { IssueDiagnoseButton } from '../diagnose/LocalDiagnoseAction'
19
+
20
+ const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = { critical: 'error', warning: 'warning' }
21
+
22
+ interface IssuesPaneProps {
23
+ namespaces: string[]
24
+ onNavigateToResource: (resource: SelectedResource) => void
25
+ }
26
+
27
+ // The per-cluster Issues surface. Renders the same shared triage queue
28
+ // (IssuesView) the Hub fleet view uses — single cluster here, so no cluster
29
+ // label and in-app (client-side) resource navigation. Classification +
30
+ // owner-grouping come pre-computed from radar's /api/issues
31
+ // (internal/issues.Compose → Classify → Group). Filtering is the host's job
32
+ // (IssuesView is a pure list); single-cluster gets a light severity filter via
33
+ // the header status tiles (clickable → filter), matching the Applications /
34
+ // GitOps header-tile pattern rather than Hub's fleet facet sidebar.
35
+ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps) {
36
+ const { data, isLoading, error, dataUpdatedAt, refetch } = useIssues(namespaces)
37
+ const { connection } = useConnection()
38
+ const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
39
+
40
+ const allIssues = useMemo(() => data?.issues ?? [], [data])
41
+ const totals = useMemo(() => {
42
+ const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
43
+ for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
44
+ return t
45
+ }, [allIssues])
46
+ const shown = severityFilter.size ? allIssues.filter((i) => severityFilter.has(i.severity)) : allIssues
47
+
48
+ const toggleSeverity = (s: IssueSeverity) =>
49
+ setSeverityFilter((prev) => {
50
+ const next = new Set(prev)
51
+ if (next.has(s)) next.delete(s); else next.add(s)
52
+ return next
53
+ })
54
+
55
+ const onResourceClick = (ref: IssueResourceRef) =>
56
+ onNavigateToResource({ kind: ref.kind, namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' })
57
+
58
+ if (isLoading) {
59
+ return <PaneLoader label="Loading issues…" className="flex-1" />
60
+ }
61
+
62
+ if (error) {
63
+ return (
64
+ <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
65
+ <p>Failed to load issues</p>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ return (
71
+ <div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
72
+ <PageHeader
73
+ icon={AlertTriangle}
74
+ title="Issues"
75
+ description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
76
+ actions={
77
+ <>
78
+ <FreshnessControl
79
+ mode="auto"
80
+ dataUpdatedAt={dataUpdatedAt}
81
+ onRefresh={() => refetch()}
82
+ connectionState={connection.state}
83
+ />
84
+ {allIssues.length > 0 && (
85
+ <>
86
+ <SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
87
+ {ISSUE_SEVERITIES.map((s) =>
88
+ totals[s] > 0 || severityFilter.has(s) ? (
89
+ <SummaryTile
90
+ key={s}
91
+ label={ISSUE_SEVERITY_LABEL[s]}
92
+ value={totals[s]}
93
+ tone={SEVERITY_TONE[s]}
94
+ active={severityFilter.has(s)}
95
+ onClick={() => toggleSeverity(s)}
96
+ />
97
+ ) : null,
98
+ )}
99
+ </>
100
+ )}
101
+ </>
102
+ }
103
+ />
104
+
105
+ {/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
106
+ mean "can't see" rather than "nothing broken" — say so up front so the
107
+ empty state isn't mistaken for a clean bill of health. */}
108
+ {data?.visibility?.impact && (
109
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
110
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
111
+ <span>Limited visibility — {data.visibility.impact} Results may be incomplete.</span>
112
+ </div>
113
+ )}
114
+
115
+ {/* Truncation honesty: when more issues matched than were returned, say
116
+ so — don't present a capped list as the complete picture. */}
117
+ {data?.total_matched != null && data.total_matched > (data.issues?.length ?? 0) && (
118
+ <p className="text-xs text-theme-text-tertiary">
119
+ Showing {data.issues?.length ?? 0} of {data.total_matched} issues (capped) — narrow by namespace to see the rest.
120
+ </p>
121
+ )}
122
+
123
+ {/* Filtered-empty is NOT the healthy empty state: when a severity filter
124
+ hides every row but issues still exist, say "no matches" rather than
125
+ letting IssuesView render its "nothing broken" terminal state. */}
126
+ {severityFilter.size > 0 && allIssues.length > 0 && shown.length === 0 ? (
127
+ <div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-theme-text-secondary">
128
+ <p>No issues match the selected severity.</p>
129
+ <button
130
+ type="button"
131
+ onClick={() => setSeverityFilter(new Set())}
132
+ className="text-xs text-skyhook-600 hover:text-skyhook-500 dark:text-skyhook-400"
133
+ >
134
+ Clear filter
135
+ </button>
136
+ </div>
137
+ ) : (
138
+ /* anyData = the query resolved, i.e. the cluster is reachable; an empty
139
+ list then means "nothing broken" rather than "not connected". */
140
+ <IssuesView
141
+ issues={shown}
142
+ anyData={!!data}
143
+ onResourceClick={onResourceClick}
144
+ renderActions={({ issue }) => (
145
+ <IssueDiagnoseButton kind={issue.kind} namespace={issue.namespace ?? ''} name={issue.name} />
146
+ )}
147
+ />
148
+ )}
149
+ </div>
150
+ )
151
+ }
@@ -10,9 +10,11 @@ interface LogsViewerProps {
10
10
  podName: string
11
11
  containers: string[]
12
12
  initialContainer?: string
13
+ /** Start streaming on mount. Default true — callers pass false for terminal (completed/failed) pods. */
14
+ autoStream?: boolean
13
15
  }
14
16
 
15
- export function LogsViewer({ namespace, podName, containers, initialContainer }: LogsViewerProps) {
17
+ export function LogsViewer({ namespace, podName, containers, initialContainer, autoStream = true }: LogsViewerProps) {
16
18
  const desktopDownload = useDesktopDownload()
17
19
  const { theme } = useTheme()
18
20
 
@@ -42,6 +44,7 @@ export function LogsViewer({ namespace, podName, containers, initialContainer }:
42
44
  createStream={makeStream}
43
45
  overrideDownload={desktopDownload}
44
46
  forceDark={theme === 'dark' ? true : undefined}
47
+ autoStream={autoStream}
45
48
  />
46
49
  )
47
50
  }
@@ -0,0 +1,135 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { Loader2, Terminal } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { useWorkloadRuns, type WorkloadRun } from '../../api/client'
5
+ import { WorkloadLogsViewer } from './WorkloadLogsViewer'
6
+ import { pickDefaultRun, workloadRunKey } from '../execution/BatchExecutionView'
7
+
8
+ interface ScheduledWorkloadLogsViewerProps {
9
+ kind: string
10
+ namespace: string
11
+ name: string
12
+ selectedRunKey?: string
13
+ onSelectRun?: (runKey: string) => void
14
+ }
15
+
16
+ const EMPTY_RUNS: WorkloadRun[] = []
17
+
18
+ export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRunKey, onSelectRun }: ScheduledWorkloadLogsViewerProps) {
19
+ const clusterScoped = kind === 'ClusterWorkflowTemplate' || kind === 'clusterworkflowtemplates'
20
+ const runsQuery = useWorkloadRuns(kind, namespace, name, true, { clusterScoped, refetchActive: true })
21
+ const runs = runsQuery.data?.runs ?? EMPTY_RUNS
22
+ const defaultRun = useMemo(() => pickDefaultRun(runs), [runs])
23
+ const [localRunKey, setLocalRunKey] = useState('')
24
+ const effectiveRunKey = selectedRunKey ?? localRunKey
25
+ const selectRun = onSelectRun ?? setLocalRunKey
26
+
27
+ useEffect(() => {
28
+ if (!runsQuery.data) return
29
+ if (runs.length === 0) {
30
+ if (effectiveRunKey) selectRun('')
31
+ return
32
+ }
33
+ if (!runs.some(run => workloadRunKey(run) === effectiveRunKey)) {
34
+ selectRun(workloadRunKey(defaultRun ?? runs[0]))
35
+ }
36
+ }, [runsQuery.data, runs, effectiveRunKey, defaultRun, selectRun])
37
+
38
+ const selectedRun = runs.find(run => workloadRunKey(run) === effectiveRunKey) ?? defaultRun
39
+
40
+ if (runsQuery.isLoading) {
41
+ return (
42
+ <div className="flex h-full items-center justify-center text-theme-text-tertiary">
43
+ <div className="flex items-center gap-2">
44
+ <Loader2 className="h-4 w-4 animate-spin" />
45
+ <span>Loading runs...</span>
46
+ </div>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ if (runsQuery.error) {
52
+ return (
53
+ <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
54
+ <Terminal className="h-8 w-8" />
55
+ <span>{runsQuery.error instanceof Error ? runsQuery.error.message : 'Failed to load runs'}</span>
56
+ </div>
57
+ )
58
+ }
59
+
60
+ if (!selectedRun) {
61
+ return (
62
+ <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
63
+ <Terminal className="h-8 w-8" />
64
+ <span>No retained runs found</span>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ return (
70
+ <div className="flex h-full min-h-0 flex-col">
71
+ <div className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2">
72
+ <div className="flex flex-wrap items-center gap-2">
73
+ <span className="text-xs font-medium text-theme-text-secondary">Run</span>
74
+ <select
75
+ value={workloadRunKey(selectedRun)}
76
+ onChange={(event) => selectRun(event.target.value)}
77
+ className="min-w-0 max-w-full rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-sm text-theme-text-primary"
78
+ >
79
+ {runs.map(run => (
80
+ <option key={workloadRunKey(run)} value={workloadRunKey(run)}>
81
+ {formatRunOption(run, clusterScoped)}
82
+ </option>
83
+ ))}
84
+ </select>
85
+ <span className={clsx('badge-sm', phaseBadgeClass(selectedRun.phase))}>
86
+ {selectedRun.phase}
87
+ </span>
88
+ <span className="text-xs text-theme-text-tertiary">
89
+ {formatRunTime(selectedRun)}
90
+ </span>
91
+ </div>
92
+ </div>
93
+ <div className="min-h-0 flex-1">
94
+ <WorkloadLogsViewer
95
+ key={`${selectedRun.kind}/${selectedRun.namespace}/${selectedRun.name}`}
96
+ kind={selectedRun.kind}
97
+ namespace={selectedRun.namespace}
98
+ name={selectedRun.name}
99
+ autoStream={selectedRun.active}
100
+ />
101
+ </div>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ function phaseBadgeClass(phase: string): string {
107
+ switch (phase) {
108
+ case 'Succeeded':
109
+ return 'status-healthy'
110
+ case 'Running':
111
+ return 'status-neutral'
112
+ case 'Failed':
113
+ case 'Error':
114
+ return 'status-unhealthy'
115
+ case 'Pending':
116
+ return 'status-degraded'
117
+ default:
118
+ return 'status-unknown'
119
+ }
120
+ }
121
+
122
+ function formatRunTime(run: WorkloadRun): string {
123
+ const raw = run.startedAt || run.scheduledAt || run.finishedAt
124
+ if (!raw) return ''
125
+ return new Date(raw).toLocaleString()
126
+ }
127
+
128
+ function formatRunOption(run: WorkloadRun, showNamespace: boolean): string {
129
+ const bits = [showNamespace ? `${run.namespace}/${run.name}` : run.name, run.phase]
130
+ if (run.progress) bits.push(run.progress)
131
+ else if (run.desired) bits.push(`${run.succeeded ?? 0}/${run.desired}`)
132
+ const work = run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} pods` : ''
133
+ if (work) bits.push(work)
134
+ return bits.join(' · ')
135
+ }
@@ -9,9 +9,11 @@ interface WorkloadLogsViewerProps {
9
9
  kind: string
10
10
  namespace: string
11
11
  name: string
12
+ /** Start streaming on mount. Default true — workload logs are aggregated from live pods. */
13
+ autoStream?: boolean
12
14
  }
13
15
 
14
- export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewerProps) {
16
+ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }: WorkloadLogsViewerProps) {
15
17
  const desktopDownload = useDesktopDownload()
16
18
  const { theme } = useTheme()
17
19
 
@@ -38,6 +40,7 @@ export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewer
38
40
  createStream={makeStream}
39
41
  overrideDownload={desktopDownload}
40
42
  forceDark={theme === 'dark' ? true : undefined}
43
+ autoStream={autoStream}
41
44
  />
42
45
  )
43
46
  }
@@ -0,0 +1,285 @@
1
+ import type { ComponentType } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { Home, Network, List, Clock, AlertTriangle, Package, GitBranch, Boxes, Activity, DollarSign, ShieldCheck, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import type { MainView } from '../../types'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+
8
+ // The views the rail can navigate to. Broader than k8s-ui's ExtendedMainView
9
+ // (which omits 'applications') — it mirrors the navigable subset of App.tsx's
10
+ // own view union, so onNavigate accepts App's setMainView directly.
11
+ type NavRailView = MainView | 'issues' | 'traffic' | 'gitops' | 'applications' | 'cost' | 'checks'
12
+
13
+ // Primary left nav rail for standalone (non-embedded) Radar.
14
+ //
15
+ // Ported from radar-hub-web's src/shell/LeftRail.tsx, simplified for OSS:
16
+ // Radar's window is desktop-only (App.tsx outer `min-w-[800px]`), so there's
17
+ // no mobile slide-in sheet — just two committed states driven by a persisted
18
+ // pin (see useNavRailPinned):
19
+ // - pinned → labeled w-48 sidebar.
20
+ // - unpinned → slim w-14 icon rail; labels surface as `left-full` fly-outs
21
+ // on hover (NOT a whole-rail hover-expand, which would reflow
22
+ // content under the cursor).
23
+ //
24
+ // In embedded mode (Radar Hub) this rail is not rendered at all — the host
25
+ // owns the left chrome via its own fleet LeftRail, and Radar falls back to
26
+ // its top-bar pill nav. That keeps the @skyhook-io/radar-app surface
27
+ // non-breaking and avoids triple-stacked left chrome.
28
+
29
+ interface NavItemDef {
30
+ view: NavRailView
31
+ icon: ComponentType<{ className?: string }>
32
+ label: string
33
+ }
34
+
35
+ // The full standalone view set, flat (no group dividers). Order descends by
36
+ // day-to-day frequency: Home, then the Resources/Issues/Topology core ("what's
37
+ // running / what's wrong / how's it wired"), then app + temporal views,
38
+ // delivery, and finally the periodic posture/spend pair. The rail's vertical
39
+ // room lets us surface the views the 8-slot pill bar dropped (Issues,
40
+ // Applications, Cost).
41
+ const NAV_ITEMS: NavItemDef[] = [
42
+ { view: 'home', icon: Home, label: 'Home' },
43
+ { view: 'resources', icon: List, label: 'Resources' },
44
+ { view: 'issues', icon: AlertTriangle, label: 'Issues' },
45
+ { view: 'topology', icon: Network, label: 'Topology' },
46
+ { view: 'applications', icon: Boxes, label: 'Applications' },
47
+ { view: 'timeline', icon: Clock, label: 'Timeline' },
48
+ { view: 'traffic', icon: Activity, label: 'Live Traffic' },
49
+ { view: 'helm', icon: Package, label: 'Helm' },
50
+ { view: 'gitops', icon: GitBranch, label: 'GitOps' },
51
+ { view: 'checks', icon: ShieldCheck, label: 'Checks' },
52
+ { view: 'cost', icon: DollarSign, label: 'Cost' },
53
+ ]
54
+
55
+ interface PrimaryNavRailProps {
56
+ // `string`, not ExtendedMainView: App.tsx's mainView is a superset
57
+ // (adds 'applications'/'workload'/'compare') that isn't in k8s-ui's
58
+ // ExtendedMainView. Active state only needs equality, so accept any
59
+ // view id and compare against our NAV_ITEMS views.
60
+ activeView: string
61
+ onNavigate: (view: NavRailView) => void
62
+ pinned: boolean
63
+ onTogglePinned: () => void
64
+ // Hidden on narrow windows where the rail is responsively forced slim — an
65
+ // expand control there would just re-breach the content floor.
66
+ showPinToggle?: boolean
67
+ // Rail-bottom "me & my tools" cluster. onOpenSettings opens the Settings
68
+ // dialog; accountSlot is the account control (App passes <UserMenu variant=…>,
69
+ // which self-nulls without auth so the row vanishes in no-auth OSS).
70
+ onOpenSettings?: () => void
71
+ accountSlot?: ReactNode
72
+ }
73
+
74
+ export function PrimaryNavRail({ activeView, onNavigate, pinned, onTogglePinned, showPinToggle = true, onOpenSettings, accountSlot }: PrimaryNavRailProps) {
75
+ return (
76
+ <aside
77
+ aria-label="Primary navigation"
78
+ className={clsx(
79
+ // Dedicated sidebar token = the DEEPEST layer of the elevation scale, so
80
+ // the rail reads as chrome on EVERY view — not just ones where a `surface`
81
+ // facet pane happens to sit next to it (the content floor is `base`, which
82
+ // a `base` rail blends into). Neutral by design: the brand accent lives on
83
+ // the active item, not the nav background.
84
+ 'shrink-0 flex flex-col bg-theme-sidebar border-r border-theme-border h-full transition-[width] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)]',
85
+ // w-44 (176px): OSS labels are short (longest is "Applications"), so the
86
+ // rail is trimmer than Radar Cloud's w-60 (which carries long cluster
87
+ // names). Keep in sync with the minWidth content-floor calc in App.tsx.
88
+ pinned ? 'w-44' : 'w-14',
89
+ )}
90
+ >
91
+ <BrandRow pinned={pinned} onNavigate={onNavigate} />
92
+
93
+ {/* Pinned: the nav owns the flex space and scrolls internally on short
94
+ windows, so the rail-bottom account/settings/pin row stays reachable.
95
+ Slim: keep the nav at natural height with a spacer below — an
96
+ overflow-y container would clip the `left-full` fly-out labels
97
+ (overflow-y:auto forces overflow-x to clip), and those ARE the labels
98
+ in slim mode. The slim short-window case is doubly-rare (slim is
99
+ width-triggered) and yields to keeping fly-outs intact. */}
100
+ <nav
101
+ className={clsx(
102
+ 'flex flex-col gap-0.5 pt-3 px-2',
103
+ pinned && 'flex-1 min-h-0 overflow-y-auto',
104
+ )}
105
+ >
106
+ {NAV_ITEMS.map((item) => (
107
+ <NavRailItem
108
+ key={item.view}
109
+ item={item}
110
+ active={activeView === item.view}
111
+ pinned={pinned}
112
+ onNavigate={onNavigate}
113
+ />
114
+ ))}
115
+ </nav>
116
+
117
+ {!pinned && <div className="flex-1" />}
118
+
119
+ {/* "Me & my tools" — account + settings, the conventional rail-bottom
120
+ cluster (VS Code / Discord / ArgoCD / Radar Cloud's own UserOrgMenu).
121
+ accountSlot self-nulls without auth, so no-auth OSS shows only Settings. */}
122
+ <nav className="flex flex-col gap-0.5 px-2 pt-1 border-t border-theme-border/50">
123
+ {accountSlot}
124
+ {onOpenSettings && (
125
+ <RailActionRow icon={Settings} label="Settings" pinned={pinned} onClick={onOpenSettings} />
126
+ )}
127
+ </nav>
128
+
129
+ {/* Pin / unpin toggle — anchored at the bottom (VS Code / Linear pattern).
130
+ The icon points the direction the rail will move: close-panel when
131
+ expanded, open-panel when slim. Hidden when the rail is responsively
132
+ forced slim (showPinToggle=false) — expanding there isn't available. */}
133
+ {showPinToggle && (
134
+ <div className="px-2 pb-2 pt-1 border-t border-theme-border/50">
135
+ <Tooltip content={pinned ? 'Collapse navigation' : 'Expand navigation'} position="right" wrapperClassName="!block w-full shrink-0">
136
+ <button
137
+ type="button"
138
+ onClick={onTogglePinned}
139
+ aria-label={pinned ? 'Collapse navigation' : 'Expand navigation'}
140
+ className="group/pin relative flex h-9 w-full items-center rounded-md text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary transition-colors"
141
+ >
142
+ <span className="flex w-10 shrink-0 items-center justify-center">
143
+ {pinned ? <PanelLeftClose className="w-[18px] h-[18px]" /> : <PanelLeftOpen className="w-[18px] h-[18px]" />}
144
+ </span>
145
+ {/* `hidden` (not sr-only) when collapsed: the button's aria-label is
146
+ the accessible name; leaving an "Collapse" label in the a11y tree
147
+ would contradict the "Expand navigation" label. */}
148
+ <span className={clsx('text-[13px] font-medium', !pinned && 'hidden')}>Collapse</span>
149
+ </button>
150
+ </Tooltip>
151
+ </div>
152
+ )}
153
+ </aside>
154
+ )
155
+ }
156
+
157
+ function BrandRow({ pinned, onNavigate }: { pinned: boolean; onNavigate: (view: NavRailView) => void }) {
158
+ // Clickable brand = secondary home affordance (logo→home convention). The
159
+ // Home nav item below still carries the active state; the brand just navigates.
160
+ return (
161
+ <Tooltip content="Home" position="right" wrapperClassName="!block w-full shrink-0">
162
+ <button
163
+ type="button"
164
+ onClick={() => onNavigate('home')}
165
+ aria-label="Radar — go to home"
166
+ // Height matches the top bar header (App.tsx — items-center + py-2 = 51px)
167
+ // so the rail's brand divider and the header's bottom border form one line.
168
+ className="flex h-[51px] w-full items-center border-b border-theme-border/50 shrink-0 transition-opacity hover:opacity-80"
169
+ >
170
+ <span className="flex w-14 shrink-0 items-center justify-center">
171
+ <span className="relative w-7 h-7 rounded-lg overflow-hidden bg-emerald-500/10 border border-emerald-500/20">
172
+ <img
173
+ src="/images/radar/radar-icon.svg"
174
+ alt=""
175
+ aria-hidden
176
+ className="w-full h-full p-0.5"
177
+ onError={(e) => console.error('Radar logo asset failed to load:', (e.currentTarget as HTMLImageElement).src)}
178
+ />
179
+ </span>
180
+ </span>
181
+ <span className={clsx('flex flex-col leading-none text-left', !pinned && 'opacity-0 pointer-events-none')}>
182
+ <span className="font-semibold text-[15px] tracking-tight text-theme-text-primary">Radar</span>
183
+ <span className="text-[9px] mt-0.5 tracking-wide uppercase text-theme-text-tertiary">by Skyhook</span>
184
+ </span>
185
+ </button>
186
+ </Tooltip>
187
+ )
188
+ }
189
+
190
+ function NavRailItem({
191
+ item,
192
+ active,
193
+ pinned,
194
+ onNavigate,
195
+ }: {
196
+ item: NavItemDef
197
+ active: boolean
198
+ pinned: boolean
199
+ onNavigate: (view: NavRailView) => void
200
+ }) {
201
+ const { icon: Icon, label, view } = item
202
+ return (
203
+ <div className={clsx('group/item relative', !pinned && 'w-10')}>
204
+ <button
205
+ type="button"
206
+ onClick={() => onNavigate(view)}
207
+ aria-current={active ? 'page' : undefined}
208
+ className={clsx(
209
+ 'relative flex h-9 w-full items-center rounded-md text-sm font-medium transition-colors',
210
+ // Slim mode: clip the hit area to the icon column so the (opacity-0)
211
+ // label can't capture clicks meant for content to the right.
212
+ !pinned && 'max-w-10 overflow-hidden',
213
+ active
214
+ ? 'bg-skyhook-600/10 dark:bg-skyhook-500/15 text-skyhook-700 dark:text-skyhook-300'
215
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
216
+ )}
217
+ >
218
+ {/* Left-edge accent bar on active — reads even when the row tint is soft. */}
219
+ <span
220
+ aria-hidden
221
+ className={clsx(
222
+ 'absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-skyhook-600 dark:bg-skyhook-400 transition-opacity',
223
+ active ? 'opacity-100' : 'opacity-0',
224
+ )}
225
+ />
226
+ <span className="flex w-10 shrink-0 items-center justify-center">
227
+ <Icon className={clsx('w-[18px] h-[18px]', active ? 'text-skyhook-700 dark:text-skyhook-300' : 'text-theme-text-tertiary group-hover/item:text-theme-text-secondary')} />
228
+ </span>
229
+ <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{label}</span>
230
+ </button>
231
+
232
+ {/* Slim-mode fly-out label — sibling of the button so it escapes the
233
+ button's overflow clip; pointer-events-none so it never eats clicks. */}
234
+ {!pinned && (
235
+ <span
236
+ aria-hidden
237
+ className="pointer-events-none absolute left-full top-1/2 z-50 ml-1 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-theme-border bg-theme-hover px-2.5 py-1 text-[13px] font-medium text-theme-text-primary opacity-0 shadow-lg shadow-black/30 transition-opacity duration-75 group-hover/item:block group-hover/item:opacity-100"
238
+ >
239
+ {label}
240
+ </span>
241
+ )}
242
+ </div>
243
+ )
244
+ }
245
+
246
+ // A rail-bottom action row — same icon-column + fly-out treatment as NavRailItem
247
+ // but it's an action (onClick), not navigation, so no active-state/accent bar.
248
+ // Exported so the account control (UserMenu rail variant) can match the look.
249
+ export function RailActionRow({
250
+ icon: Icon,
251
+ label,
252
+ pinned,
253
+ onClick,
254
+ }: {
255
+ icon: ComponentType<{ className?: string }>
256
+ label: string
257
+ pinned: boolean
258
+ onClick: () => void
259
+ }) {
260
+ return (
261
+ <div className={clsx('group/item relative', !pinned && 'w-10')}>
262
+ <button
263
+ type="button"
264
+ onClick={onClick}
265
+ className={clsx(
266
+ 'relative flex h-9 w-full items-center rounded-md text-sm font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary transition-colors',
267
+ !pinned && 'max-w-10 overflow-hidden',
268
+ )}
269
+ >
270
+ <span className="flex w-10 shrink-0 items-center justify-center">
271
+ <Icon className="w-[18px] h-[18px] text-theme-text-tertiary group-hover/item:text-theme-text-secondary" />
272
+ </span>
273
+ <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{label}</span>
274
+ </button>
275
+ {!pinned && (
276
+ <span
277
+ aria-hidden
278
+ className="pointer-events-none absolute left-full top-1/2 z-50 ml-1 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-theme-border bg-theme-hover px-2.5 py-1 text-[13px] font-medium text-theme-text-primary opacity-0 shadow-lg shadow-black/30 transition-opacity duration-75 group-hover/item:block group-hover/item:opacity-100"
279
+ >
280
+ {label}
281
+ </span>
282
+ )}
283
+ </div>
284
+ )
285
+ }