@skyhook-io/radar-app 1.14.5 → 1.14.6

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 (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -2,7 +2,8 @@ import { useState } from 'react'
2
2
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
3
  import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
- import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
5
+ import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo, useCapabilities } from '../../api/client'
6
+ import { OperatorManagedNotice } from '../settings/OperatorManagedNotice'
6
7
  import { Input } from '@skyhook-io/k8s-ui'
7
8
  import type { UpgradeInfo } from '../../types'
8
9
 
@@ -15,12 +16,14 @@ interface TrackChartSourceDialogProps {
15
16
  sourceError?: string
16
17
  }
17
18
 
18
- function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError?: string) {
19
+ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError?: string, operatorManaged = false) {
19
20
  switch (sourceIssue) {
20
21
  case 'repo_index_error':
21
22
  return {
22
23
  title: 'A Helm repo index failed',
23
- body: 'Fix, refresh, or remove the broken Helm repo index. If this release came from OCI, add its registry prefix and Radar will check OCI before reporting that repo error.',
24
+ body: operatorManaged
25
+ ? 'Ask the operator to fix the Helm repo index, or add the chart’s OCI registry prefix to helm.ociSources if it came from OCI.'
26
+ : 'Fix, refresh, or remove the broken Helm repo index. If this release came from OCI, add its registry prefix and Radar will check OCI before reporting that repo error.',
24
27
  }
25
28
  case 'ambiguous_repository':
26
29
  return {
@@ -30,7 +33,9 @@ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError
30
33
  case 'untracked':
31
34
  return {
32
35
  title: 'Source not tracked',
33
- body: 'Helm does not record the install source. Add the OCI prefix that contains this chart.',
36
+ body: operatorManaged
37
+ ? 'Helm does not record the install source. Ask the operator to add the OCI prefix that contains this chart to helm.ociSources.'
38
+ : 'Helm does not record the install source. Add the OCI prefix that contains this chart.',
34
39
  }
35
40
  default:
36
41
  return sourceError
@@ -50,6 +55,9 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
50
55
  const { data: clusterInfo } = useClusterInfo()
51
56
  const addSource = useAddOCISource()
52
57
  const removeSource = useRemoveOCISource()
58
+ const { data: capabilities } = useCapabilities()
59
+ const operatorManaged = capabilities?.configManagement === 'operator'
60
+ const canEdit = capabilities != null && !operatorManaged
53
61
 
54
62
  // In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
55
63
  // points at an empty /tmp), so private registries can't authenticate — only
@@ -62,7 +70,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
62
70
  const normalizedChartName = chartName?.trim().replace(/^\/+|\/+$/g, '') ?? ''
63
71
  const probedRef = normalizedInput && normalizedChartName ? `${normalizedInput}/${normalizedChartName}` : ''
64
72
  const looksLikeFullChartRef = Boolean(normalizedInput && normalizedChartName && normalizedInput.endsWith(`/${normalizedChartName}`))
65
- const sourceIssueCopy = getSourceIssueCopy(sourceIssue, sourceError)
73
+ const sourceIssueCopy = getSourceIssueCopy(sourceIssue, sourceError, operatorManaged)
66
74
 
67
75
  const handleAdd = () => {
68
76
  if (!trimmed || invalid) return
@@ -78,8 +86,8 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
78
86
  <div className="flex-1 min-w-0">
79
87
  <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
80
88
  <p className="text-sm text-theme-text-secondary mt-1">
81
- Helm doesn&apos;t record where a chart was installed from. Register your OCI
82
- registry prefix and Radar will check it for newer versions of your charts.
89
+ Helm doesn&apos;t record where a chart was installed from. Radar checks registered OCI
90
+ prefixes for newer versions of your charts.
83
91
  </p>
84
92
  </div>
85
93
  <button
@@ -91,6 +99,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
91
99
  </div>
92
100
 
93
101
  <div className="p-4 space-y-4">
102
+ {operatorManaged && <OperatorManagedNotice helmValue="helm.ociSources" />}
94
103
  {sourceIssueCopy && (
95
104
  <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-sm">
96
105
  <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-theme-text-secondary" />
@@ -101,7 +110,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
101
110
  </div>
102
111
  )}
103
112
 
104
- <div>
113
+ {canEdit && <div>
105
114
  <label className="block text-sm font-medium text-theme-text-secondary mb-2">
106
115
  OCI registry prefix
107
116
  </label>
@@ -135,7 +144,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
135
144
  ? `Radar will probe "${probedRef || `<prefix>/${normalizedChartName}`}".`
136
145
  : 'Radar probes <prefix>/<chartName> for each untracked release.'}
137
146
  </p>
138
- </div>
147
+ </div>}
139
148
 
140
149
  {sources && sources.length > 0 && (
141
150
  <div>
@@ -149,14 +158,14 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
149
158
  className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
150
159
  >
151
160
  <span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
152
- <button
161
+ {canEdit && <button
153
162
  onClick={() => removeSource.mutate(src)}
154
163
  disabled={removeSource.isPending}
155
164
  className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
156
165
  aria-label={`Remove ${src}`}
157
166
  >
158
167
  <Trash2 className="w-4 h-4" />
159
- </button>
168
+ </button>}
160
169
  </li>
161
170
  ))}
162
171
  </ul>
@@ -176,6 +176,15 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
176
176
  }
177
177
  }, null, 2)
178
178
 
179
+ const opencodeConfig = JSON.stringify({
180
+ mcp: {
181
+ radar: {
182
+ type: "remote",
183
+ url: mcpUrl,
184
+ }
185
+ }
186
+ }, null, 2)
187
+
179
188
  return (
180
189
  <div className="fixed inset-0 z-50 flex items-center justify-center">
181
190
  <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
@@ -283,6 +292,7 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
283
292
  { icon: Code2, name: 'VS Code Copilot', path: '.vscode/mcp.json', config: vsCodeConfig },
284
293
  { icon: Code2, name: 'Cline', path: 'Cline MCP settings (via UI)', config: clineConfig },
285
294
  { icon: Code2, name: 'JetBrains AI', path: 'Settings → Tools → AI Assistant → MCP', config: jetbrainsConfig },
295
+ { icon: Terminal, name: 'OpenCode', path: '~/.config/opencode/opencode.json', config: opencodeConfig },
286
296
  { icon: Terminal, name: 'OpenAI Codex', path: '~/.codex/config.toml', config: codexConfig },
287
297
  { icon: Terminal, name: 'Gemini CLI', path: '~/.gemini/settings.json', config: geminiConfig },
288
298
  ].map((agent) => (
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
2
  import { Loader2, Terminal } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { useWorkloadRuns, type WorkloadRun } from '../../api/client'
4
+ import { useWorkloadRuns, useResource, type WorkloadRun } from '../../api/client'
5
5
  import { WorkloadLogsViewer } from './WorkloadLogsViewer'
6
- import { pickDefaultRun, workloadRunKey } from '../execution/BatchExecutionView'
6
+ import { pickDefaultRun, selectedRunMissing, workloadRunKey } from '../execution/BatchExecutionView'
7
7
 
8
8
  interface ScheduledWorkloadLogsViewerProps {
9
9
  kind: string
@@ -15,34 +15,61 @@ interface ScheduledWorkloadLogsViewerProps {
15
15
 
16
16
  const EMPTY_RUNS: WorkloadRun[] = []
17
17
 
18
- export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRunKey, onSelectRun }: ScheduledWorkloadLogsViewerProps) {
18
+ export function ScheduledWorkloadLogsViewer(props: ScheduledWorkloadLogsViewerProps) {
19
+ return <ScheduledWorkloadLogsContent key={JSON.stringify([props.kind, props.namespace, props.name])} {...props} />
20
+ }
21
+
22
+ function ScheduledWorkloadLogsContent({ kind, namespace, name, selectedRunKey, onSelectRun }: ScheduledWorkloadLogsViewerProps) {
19
23
  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])
24
+ const jobSet = kind.toLowerCase() === 'jobset' || kind.toLowerCase() === 'jobsets'
23
25
  const [localRunKey, setLocalRunKey] = useState('')
24
26
  const effectiveRunKey = selectedRunKey ?? localRunKey
25
27
  const selectRun = onSelectRun ?? setLocalRunKey
28
+ const [scope, setScope] = useState('selected')
29
+ const [role, setRole] = useState('')
30
+ const rootQuery = useResource<any>('jobsets', namespace, name, 'jobset.x-k8s.io', { enabled: jobSet })
31
+ const runsQuery = useWorkloadRuns(kind, namespace, name, true, { clusterScoped, refetchActive: true, ...(jobSet ? { selected: effectiveRunKey } : {}) })
32
+ const memberCollection = runsQuery.data?.collection === 'members'
33
+ const runs = runsQuery.data?.runs ?? EMPTY_RUNS
34
+ const selectedRunFromResponse = runsQuery.data?.selected
35
+ const resolvedRuns = useMemo(
36
+ () => selectedRunFromResponse && !runs.some(run => workloadRunKey(run) === workloadRunKey(selectedRunFromResponse))
37
+ ? [...runs, selectedRunFromResponse]
38
+ : runs,
39
+ [runs, selectedRunFromResponse],
40
+ )
41
+ const defaultRun = useMemo(() => memberCollection ? runs[0] : pickDefaultRun(runs), [memberCollection, runs])
42
+ const roles: string[] = [...new Set<string>([...(rootQuery.data?.spec?.replicatedJobs?.map((r: { name: string }) => r.name) ?? []), ...(role ? [role] : [])])]
43
+ const effectiveRole = role || roles[0] || ''
44
+ const aggregate = memberCollection && scope !== 'selected'
45
+ const scopeControls = memberCollection && <div className="flex flex-wrap items-center gap-2 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs">
46
+ <label htmlFor="jobset-log-scope" className="text-theme-text-secondary">Log scope</label>
47
+ <select id="jobset-log-scope" value={scope} onChange={event => { if (event.target.value === 'role') setRole(effectiveRole); setScope(event.target.value) }} className="rounded border border-theme-border bg-theme-elevated px-2 py-1 text-theme-text-primary"><option value="selected">Selected Job</option><option value="role" disabled={roles.length === 0}>Role</option><option value="all">All current members</option></select>
48
+ {scope === 'role' && <select aria-label="Log role" value={effectiveRole} onChange={event => setRole(event.target.value)} className="rounded border border-theme-border bg-theme-elevated px-2 py-1 text-theme-text-primary">{roles.map(r => <option key={r} value={r}>{r}</option>)}</select>}
49
+ {aggregate && <span className="text-theme-text-secondary">Snapshot · Refresh for new logs · up to 40 container sources, 1,000 lines and 64 KiB per source · retained Pods only</span>}
50
+ </div>
51
+
52
+ const selectionMissing = memberCollection && selectedRunMissing(resolvedRuns, effectiveRunKey)
26
53
 
27
54
  useEffect(() => {
28
- if (!runsQuery.data) return
29
- if (runs.length === 0) {
55
+ if (!runsQuery.data || runsQuery.isPlaceholderData || selectionMissing) return
56
+ if (resolvedRuns.length === 0) {
30
57
  if (effectiveRunKey) selectRun('')
31
58
  return
32
59
  }
33
- if (!runs.some(run => workloadRunKey(run) === effectiveRunKey)) {
60
+ if (!resolvedRuns.some(run => workloadRunKey(run) === effectiveRunKey)) {
34
61
  selectRun(workloadRunKey(defaultRun ?? runs[0]))
35
62
  }
36
- }, [runsQuery.data, runs, effectiveRunKey, defaultRun, selectRun])
63
+ }, [runsQuery.data, runsQuery.isPlaceholderData, runs, resolvedRuns, effectiveRunKey, defaultRun, selectRun, selectionMissing])
37
64
 
38
- const selectedRun = runs.find(run => workloadRunKey(run) === effectiveRunKey) ?? defaultRun
65
+ const selectedRun = selectionMissing ? undefined : resolvedRuns.find(run => workloadRunKey(run) === effectiveRunKey) ?? defaultRun
39
66
 
40
67
  if (runsQuery.isLoading) {
41
68
  return (
42
69
  <div className="flex h-full items-center justify-center text-theme-text-tertiary">
43
70
  <div className="flex items-center gap-2">
44
71
  <Loader2 className="h-4 w-4 animate-spin" />
45
- <span>Loading runs...</span>
72
+ <span>{kind === 'JobSet' || kind === 'jobsets' ? 'Loading member Jobs...' : 'Loading runs...'}</span>
46
73
  </div>
47
74
  </div>
48
75
  )
@@ -52,7 +79,21 @@ export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRun
52
79
  return (
53
80
  <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
54
81
  <Terminal className="h-8 w-8" />
55
- <span>{runsQuery.error instanceof Error ? runsQuery.error.message : 'Failed to load runs'}</span>
82
+ <span>{runsQuery.error instanceof Error ? runsQuery.error.message : kind === 'JobSet' || kind === 'jobsets' ? 'Failed to load member Jobs' : 'Failed to load runs'}</span>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ if (aggregate) return <div className="flex h-full min-h-0 flex-col">{scopeControls}<div className="min-h-0 flex-1"><WorkloadLogsViewer key={JSON.stringify([rootQuery.data?.metadata?.uid, scope, effectiveRole])} kind="jobsets" namespace={namespace} name={name} snapshotOnly role={scope === 'role' ? effectiveRole : undefined} autoStream={false} /></div></div>
88
+
89
+ if (selectionMissing) {
90
+ return (
91
+ <div className="space-y-3 p-4">{scopeControls}
92
+ <p className="text-sm text-theme-text-secondary">Selected Job is currently unavailable. It may have been removed or be waiting for recreation. Your selection is preserved if it reappears; choose another shown Job to switch.</p>
93
+ {runs.length > 0 && <select aria-label="Select a shown member Job" value={effectiveRunKey} onChange={(event) => selectRun(event.target.value)} className="max-w-full rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-sm text-theme-text-primary">
94
+ <option value={effectiveRunKey} disabled>Choose a shown Job</option>
95
+ {runs.map((run) => <option key={workloadRunKey(run)} value={workloadRunKey(run)}>{formatRunOption(run, false, true)}</option>)}
96
+ </select>}
56
97
  </div>
57
98
  )
58
99
  }
@@ -61,38 +102,42 @@ export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRun
61
102
  return (
62
103
  <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
63
104
  <Terminal className="h-8 w-8" />
64
- <span>No retained runs found</span>
105
+ <span>{memberCollection ? 'No child Jobs found' : 'No retained runs found'}</span>
65
106
  </div>
66
107
  )
67
108
  }
68
109
 
69
110
  return (
70
111
  <div className="flex h-full min-h-0 flex-col">
112
+ {scopeControls}
71
113
  <div className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2">
72
114
  <div className="flex flex-wrap items-center gap-2">
73
- <span className="text-xs font-medium text-theme-text-secondary">Run</span>
115
+ <span className="text-xs font-medium text-theme-text-secondary">{memberCollection ? 'Job' : 'Run'}</span>
74
116
  <select
75
117
  value={workloadRunKey(selectedRun)}
76
118
  onChange={(event) => selectRun(event.target.value)}
77
119
  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
120
  >
79
- {runs.map(run => (
121
+ {resolvedRuns.map(run => (
80
122
  <option key={workloadRunKey(run)} value={workloadRunKey(run)}>
81
- {formatRunOption(run, clusterScoped)}
123
+ {formatRunOption(run, clusterScoped, memberCollection)}
82
124
  </option>
83
125
  ))}
84
126
  </select>
85
127
  <span className={clsx('badge-sm', phaseBadgeClass(selectedRun.phase))}>
86
- {selectedRun.phase}
128
+ {selectedRun.phase}{selectedRun.deleting ? ' · deleting' : ''}
87
129
  </span>
88
130
  <span className="text-xs text-theme-text-tertiary">
89
131
  {formatRunTime(selectedRun)}
90
132
  </span>
133
+ {memberCollection && runsQuery.data?.truncated && (
134
+ <span className="text-xs text-theme-text-tertiary">Showing {runs.length} of {runsQuery.data.total} Jobs</span>
135
+ )}
91
136
  </div>
92
137
  </div>
93
138
  <div className="min-h-0 flex-1">
94
139
  <WorkloadLogsViewer
95
- key={`${selectedRun.kind}/${selectedRun.namespace}/${selectedRun.name}`}
140
+ key={workloadRunLogsKey(selectedRun)}
96
141
  kind={selectedRun.kind}
97
142
  namespace={selectedRun.namespace}
98
143
  name={selectedRun.name}
@@ -125,11 +170,19 @@ function formatRunTime(run: WorkloadRun): string {
125
170
  return new Date(raw).toLocaleString()
126
171
  }
127
172
 
128
- function formatRunOption(run: WorkloadRun, showNamespace: boolean): string {
129
- const bits = [showNamespace ? `${run.namespace}/${run.name}` : run.name, run.phase]
173
+ function formatRunOption(run: WorkloadRun, showNamespace: boolean, memberCollection: boolean): string {
174
+ const bits = [showNamespace ? `${run.namespace}/${run.name}` : run.name, `${run.phase}${run.deleting ? ' · deleting' : ''}`]
175
+ if (memberCollection && run.jobset?.replicatedJob) {
176
+ bits.push(`${run.jobset?.replicatedJob}${run.jobset?.jobIndex ? ` #${run.jobset?.jobIndex}` : ''}`)
177
+ }
130
178
  if (run.progress) bits.push(run.progress)
131
179
  else if (run.desired) bits.push(`${run.succeeded ?? 0}/${run.desired}`)
132
- const work = run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} pods` : ''
180
+ const work = run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} completed Pods` : ''
133
181
  if (work) bits.push(work)
134
182
  return bits.join(' · ')
135
183
  }
184
+
185
+ // A JobSet retry can replace a child Job at the same resource address.
186
+ export function workloadRunLogsKey(run: WorkloadRun): string {
187
+ return JSON.stringify([run.group, workloadRunKey(run), run.jobset?.restartAttempt, run.jobset?.jobRestartAttempt])
188
+ }
@@ -10,24 +10,27 @@ interface WorkloadLogsViewerProps {
10
10
  namespace: string
11
11
  name: string
12
12
  /** Start streaming on mount. Default true — workload logs are aggregated from live pods. */
13
+ snapshotOnly?: boolean
14
+ role?: string
13
15
  autoStream?: boolean
14
16
  }
15
17
 
16
- export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }: WorkloadLogsViewerProps) {
18
+ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true, snapshotOnly = false, role }: WorkloadLogsViewerProps) {
17
19
  const desktopDownload = useDesktopDownload()
18
20
  const { theme } = useTheme()
19
21
 
20
22
  const fetchAll = useCallback(async (params: WorkloadLogsFetchParams): Promise<WorkloadLogsResult> => {
21
23
  const query = new URLSearchParams()
22
24
  if (params.container) query.set('container', params.container)
25
+ if (role) query.set('role', role)
23
26
  if (params.tailLines) query.set('tailLines', String(params.tailLines))
24
27
  if (params.sinceSeconds) query.set('sinceSeconds', String(params.sinceSeconds))
25
28
  const qs = query.toString()
26
29
  const data = await fetchJSON<WorkloadLogsResult>(
27
- `/workloads/${kind}/${namespace}/${name}/logs${qs ? `?${qs}` : ''}`
30
+ `${snapshotOnly ? `/jobsets/${namespace}/${name}` : `/workloads/${kind}/${namespace}/${name}`}/logs${qs ? `?${qs}` : ''}`, { signal: params.signal }
28
31
  )
29
32
  return data
30
- }, [kind, namespace, name])
33
+ }, [kind, namespace, name, snapshotOnly, role])
31
34
 
32
35
  const makeStream = useCallback((params: WorkloadLogsFetchParams) => {
33
36
  return createWorkloadLogStream(kind, namespace, name, params)
@@ -37,7 +40,7 @@ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }:
37
40
  <SharedWorkloadLogsViewer
38
41
  name={name}
39
42
  fetchAll={fetchAll}
40
- createStream={makeStream}
43
+ createStream={snapshotOnly ? undefined : makeStream}
41
44
  overrideDownload={desktopDownload}
42
45
  forceDark={theme === 'dark' ? true : undefined}
43
46
  autoStream={autoStream}
@@ -49,9 +49,7 @@ type NavRailView =
49
49
  // content under the cursor).
50
50
  //
51
51
  // In embedded mode (Radar Hub) this rail is not rendered at all — the host
52
- // owns the left chrome via its own fleet LeftRail, and Radar falls back to
53
- // its top-bar pill nav. That keeps the @skyhook-io/radar-app surface
54
- // non-breaking and avoids triple-stacked left chrome.
52
+ // owns primary view navigation via its own fleet LeftRail.
55
53
 
56
54
  interface NavItemDef {
57
55
  view: NavRailView;
@@ -66,8 +64,7 @@ interface NavItemDef {
66
64
  // sits there — next to Cost, its spend-lever sibling. It renders on every
67
65
  // cluster (like Issues/Topology): the view itself reads the cluster's capacity
68
66
  // posture across all node managers, not just Karpenter. The rail's vertical
69
- // room lets us surface the views the 8-slot pill bar dropped (Issues,
70
- // Applications, Cost).
67
+ // room lets us surface every supported standalone destination.
71
68
  const NAV_ITEMS: NavItemDef[] = [
72
69
  { view: "home", icon: Home, label: "Home" },
73
70
  { view: "resources", icon: List, label: "Resources" },
@@ -0,0 +1,168 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { NavCustomizationProvider } from '../../context/NavCustomization'
4
+ import { ApiError, type PrometheusPVCUsage } from '../../api/client'
5
+
6
+ let canConfigure = true
7
+ let roleLoading = false
8
+ let statusResult: { data?: { connected: boolean; discovering?: boolean }; error?: Error }
9
+ let usageResult: { data?: PrometheusPVCUsage; error?: Error }
10
+ vi.mock('../../api/client', async (importActual) => ({
11
+ ...(await importActual<typeof import('../../api/client')>()),
12
+ usePrometheusPVCUsage: () => usageResult,
13
+ usePrometheusStatus: () => statusResult,
14
+ useAutoPromConnect: () => undefined,
15
+ useCloudRole: () => ({ canAtLeast: () => canConfigure, isLoading: roleLoading }),
16
+ }))
17
+ const { PVCUsageBar } = await import('./PVCUsageBar')
18
+ const measured: PrometheusPVCUsage = { namespace: 'demo', name: 'disk', used: 0, capacity: 1024, ratio: 0, hasData: true, status: 'available' }
19
+ const render = () => renderToString(<PVCUsageBar namespace="demo" name="disk" />)
20
+
21
+ describe('PVC usage availability', () => {
22
+ beforeEach(() => {
23
+ canConfigure = true
24
+ roleLoading = false
25
+ statusResult = { data: { connected: true } }
26
+ usageResult = {}
27
+ })
28
+ it.each([
29
+ ['no_series', 'No usage measurements reported for this volume'],
30
+ ['invalid_data', 'Volume usage measurements are invalid'],
31
+ ['query_failed', 'The usage query failed'],
32
+ ] as const)('explains %s without a gauge', (status, message) => {
33
+ usageResult = { data: { ...measured, hasData: false, status } }
34
+ expect(render()).toContain(message)
35
+ expect(render()).not.toContain('width:')
36
+ })
37
+ it('reports discovery as in progress', () => {
38
+ statusResult = { data: { connected: false, discovering: true } }
39
+ expect(render()).toContain('Discovering Prometheus')
40
+ expect(render()).not.toContain('not connected')
41
+ })
42
+ it('does not attribute status denial to this PVC', () => {
43
+ statusResult = { error: new ApiError('forbidden', 403) }
44
+ expect(render()).toContain('Metrics access denied')
45
+ })
46
+ it('keeps older agent unavailable responses from becoming zero gauges', () => {
47
+ usageResult = { data: { ...measured, status: undefined, hasData: false, capacity: 0 } }
48
+ expect(render()).toContain('Usage measurements are unavailable')
49
+ expect(render()).not.toContain('width:')
50
+ })
51
+ it('retains older agent valid measurements', () => {
52
+ usageResult = { data: { ...measured, status: undefined } }
53
+ expect(render()).toContain('width:0%')
54
+ })
55
+ it('renders valid zero usage as a measurement', () => {
56
+ usageResult = { data: measured }
57
+ expect(render().replaceAll('<!-- -->', '')).toContain('(0%)')
58
+ expect(render()).toContain('width:0%')
59
+ })
60
+ it('shows loading instead of an empty row', () => {
61
+ expect(render()).toContain('Loading usage measurements')
62
+ })
63
+ it('does not present stale usage after a refresh failure', () => {
64
+ usageResult = { data: measured, error: new ApiError('failed', 500) }
65
+ expect(render()).toContain('The usage query failed')
66
+ expect(render()).not.toContain('width:')
67
+ })
68
+ it('does not present stale usage when disconnected', () => {
69
+ statusResult = { data: { connected: false } }
70
+ usageResult = { data: measured }
71
+ expect(render()).toContain('Prometheus is not connected')
72
+ expect(render()).not.toContain('width:')
73
+ })
74
+ it('distinguishes denied access from unavailable measurements', () => {
75
+ usageResult = { data: measured, error: new ApiError('forbidden', 403) }
76
+ expect(render()).toContain('access to usage metrics for this PVC')
77
+ expect(render()).not.toContain('width:')
78
+ })
79
+ it('does not reuse metrics if connection status cannot be checked', () => {
80
+ statusResult = { data: { connected: true }, error: new Error('failed') }
81
+ usageResult = { data: measured }
82
+ expect(render()).toContain('Could not check the metrics connection')
83
+ expect(render()).not.toContain('width:')
84
+ })
85
+ })
86
+
87
+
88
+ describe('PVC metrics next step', () => {
89
+ beforeEach(() => {
90
+ canConfigure = true
91
+ roleLoading = false
92
+ statusResult = { data: { connected: true } }
93
+ usageResult = { data: { ...measured, hasData: false, status: 'no_series' } }
94
+ })
95
+ it.each(['no_series', 'invalid_data', 'query_failed'] as const)('offers the existing settings flow for %s', status => {
96
+ usageResult = { data: { ...measured, hasData: false, status } }
97
+ expect(render()).toContain('Configure metrics')
98
+ expect(render()).toContain('type="button"')
99
+ })
100
+ it('offers configuration when disconnected', () => {
101
+ statusResult = { data: { connected: false } }
102
+ expect(render()).toContain('Configure metrics')
103
+ })
104
+ it('offers configuration after a status query failure', () => {
105
+ statusResult = { error: new Error('status failed') }
106
+ expect(render()).toContain('Configure metrics')
107
+ })
108
+ it('offers configuration for older-agent unavailable measurements', () => {
109
+ usageResult = { data: { ...measured, status: undefined, hasData: false } }
110
+ expect(render()).toContain('Configure metrics')
111
+ })
112
+ it('gives a non-owner factual guidance without a locked-settings link', () => {
113
+ canConfigure = false
114
+ expect(render()).not.toContain('Configure metrics')
115
+ expect(render()).toContain('check metrics availability for this volume')
116
+ expect(render()).not.toContain('check the metrics connection')
117
+ })
118
+ it('gives a non-owner connection guidance only when disconnected', () => {
119
+ canConfigure = false
120
+ statusResult = { data: { connected: false } }
121
+ expect(render()).toContain('check the metrics connection for this cluster')
122
+ expect(render()).not.toContain('Configure metrics')
123
+ })
124
+ it('does not offer inaccessible standalone settings in an embedded host', () => {
125
+ const text = renderToString(<NavCustomizationProvider value={{ embedded: true }}><PVCUsageBar namespace="demo" name="disk" /></NavCustomizationProvider>)
126
+ expect(text).not.toContain('Configure metrics')
127
+ expect(text).toContain('check metrics availability')
128
+ })
129
+ it('does not flash an action while permissions load', () => {
130
+ roleLoading = true
131
+ expect(render()).not.toContain('Configure metrics')
132
+ expect(render()).not.toContain('Ask your operator')
133
+ })
134
+ it.each(['usage', 'status'])('guides %s denial toward access rather than connection settings', source => {
135
+ if (source === 'usage') usageResult = { error: new ApiError('forbidden', 403) }
136
+ else statusResult = { error: new ApiError('forbidden', 403) }
137
+ expect(render()).not.toContain('Configure metrics')
138
+ expect(render()).toContain('review your metrics access')
139
+ })
140
+ it.each([true, false])('keeps discovery quiet with a retained usage error (owner=%s)', owner => {
141
+ canConfigure = owner
142
+ statusResult = { data: { connected: false, discovering: true } }
143
+ usageResult = { error: new ApiError('retained query failure', 500) }
144
+ expect(render()).toContain('Discovering Prometheus')
145
+ expect(render()).not.toContain('Configure metrics')
146
+ expect(render()).not.toContain('Ask your operator')
147
+ })
148
+ it('keeps status loading quiet with a retained usage error', () => {
149
+ statusResult = {}
150
+ usageResult = { error: new ApiError('retained query failure', 500) }
151
+ expect(render()).toContain('Checking metrics availability')
152
+ expect(render()).not.toContain('Configure metrics')
153
+ })
154
+ it('retains access guidance when denied during discovery', () => {
155
+ statusResult = { data: { connected: false, discovering: true } }
156
+ usageResult = { error: new ApiError('forbidden', 403) }
157
+ expect(render()).toContain('review your metrics access')
158
+ expect(render()).not.toContain('Configure metrics')
159
+ })
160
+ it.each(['status', 'discovery', 'usage', 'available'])('does not add an action during %s', state => {
161
+ if (state === 'status') statusResult = {}
162
+ if (state === 'discovery') statusResult = { data: { connected: false, discovering: true } }
163
+ if (state === 'usage') usageResult = {}
164
+ if (state === 'available') usageResult = { data: measured }
165
+ expect(render()).not.toContain('Configure metrics')
166
+ expect(render()).not.toContain('Ask your operator')
167
+ })
168
+ })
@@ -1,26 +1,72 @@
1
1
  import { formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
2
- import { useAutoPromConnect, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
3
-
4
- /**
5
- * PVCUsageBar — single-line capacity gauge derived from kubelet_volume_stats_*.
6
- *
7
- * Hidden silently when:
8
- * - Prometheus isn't connected
9
- * - The CSI driver doesn't implement NodeGetVolumeStats
10
- * - Prometheus isn't scraping kubelet endpoints (notably GMP default config)
11
- *
12
- * Operators get nothing rather than a "no data" message that'd look like Radar
13
- * is broken — the absence is information enough.
14
- */
2
+ import { isForbiddenError, useAutoPromConnect, useCloudRole, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
3
+ import { useNavCustomization } from '../../context/NavCustomization'
4
+
15
5
  export function PVCUsageBar({ namespace, name }: { namespace: string; name: string }) {
16
6
  // PVC detail can be the first Prometheus-backed surface a user opens; without
17
7
  // this, the gauge silently stays hidden until they open a workload metrics tab.
18
8
  useAutoPromConnect()
19
- const { data: status } = usePrometheusStatus()
9
+ const { canAtLeast, isLoading: roleLoading } = useCloudRole()
10
+ const settingsAvailable = !useNavCustomization().embedded
11
+ const canConfigure = settingsAvailable && !roleLoading && canAtLeast('owner')
12
+ const { data: status, error: statusError } = usePrometheusStatus()
20
13
  const isConnected = status?.connected === true
21
- const { data: usage } = usePrometheusPVCUsage(namespace, name, isConnected)
14
+ const { data: usage, error } = usePrometheusPVCUsage(namespace, name, isConnected)
15
+
16
+ let unavailable: string | undefined
17
+ if (isForbiddenError(error)) {
18
+ unavailable = "You don't have access to usage metrics for this PVC."
19
+ } else if (isForbiddenError(statusError)) {
20
+ unavailable = 'Metrics access denied.'
21
+ } else if (statusError) {
22
+ unavailable = 'Could not check the metrics connection.'
23
+ } else if (!status) {
24
+ unavailable = 'Checking metrics availability…'
25
+ } else if (status.discovering) {
26
+ unavailable = 'Discovering Prometheus…'
27
+ } else if (!isConnected) {
28
+ unavailable = 'Prometheus is not connected. Used space is unknown.'
29
+ } else if (error || usage?.status === 'query_failed') {
30
+ unavailable = 'The usage query failed. Used space is unknown.'
31
+ } else if (!usage) {
32
+ unavailable = 'Loading usage measurements…'
33
+ } else if (usage.status === 'no_series') {
34
+ unavailable = 'No usage measurements reported for this volume.'
35
+ } else if (usage.status === 'invalid_data') {
36
+ unavailable = 'Volume usage measurements are invalid. Used space is unknown.'
37
+ } else if (!usage.hasData) {
38
+ unavailable = 'Usage measurements are unavailable.'
39
+ }
40
+
41
+ const denied = isForbiddenError(error) || isForbiddenError(statusError)
42
+ const waiting = !denied && !statusError && (!status || status.discovering || (isConnected && !usage && !error))
43
+ const guidance = denied
44
+ ? 'Ask your operator to review your metrics access.'
45
+ : !canConfigure && !roleLoading
46
+ ? !isConnected || statusError
47
+ ? 'Ask your operator to check the metrics connection for this cluster.'
48
+ : 'Ask your operator to check metrics availability for this volume.'
49
+ : undefined
22
50
 
23
- if (!usage || !usage.hasData) return null
51
+ if (unavailable || !usage) {
52
+ return (
53
+ <section aria-label="PVC usage" className="rounded-lg border border-theme-border bg-theme-surface/30 p-3">
54
+ <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide mb-1">Usage</div>
55
+ <p className="text-sm text-theme-text-tertiary">{unavailable}</p>
56
+ {!waiting && (
57
+ canConfigure && !denied ? (
58
+ <button
59
+ type="button"
60
+ className="mt-2 text-xs text-accent hover:underline"
61
+ onClick={() => window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'prometheus' } }))}
62
+ >Configure metrics</button>
63
+ ) : guidance ? (
64
+ <p className="mt-2 text-xs text-theme-text-tertiary">{guidance}</p>
65
+ ) : null
66
+ )}
67
+ </section>
68
+ )
69
+ }
24
70
 
25
71
  const pct = Math.max(0, Math.min(1, usage.ratio))
26
72
  const usedLabel = formatMemoryBytes(usage.used)
@@ -30,7 +30,7 @@ vi.mock('@skyhook-io/k8s-ui/components/charts', async (importActual) => ({
30
30
  const { PrometheusChartsGrid } = await import('./PrometheusChartsGrid')
31
31
  const resource = { spec: { template: { spec: { containers: [{ resources: { requests: { cpu: '100m' }, limits: { cpu: '200m' } } }] } } } }
32
32
  const render = (url = '/workloads/Deployment/demo/web?tab=metrics', embedded = false) => renderToString(
33
- <MemoryRouter initialEntries={[url]}><NavCustomizationProvider value={embedded ? { embedded: true, rightExtras: null } : {}}>
33
+ <MemoryRouter initialEntries={[url]}><NavCustomizationProvider value={embedded ? { embedded: true } : {}}>
34
34
  <PrometheusChartsGrid kind="Deployment" namespace="demo" name="web" resource={resource} />
35
35
  </NavCustomizationProvider></MemoryRouter>,
36
36
  )