@skyhook-io/radar-app 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +10 -11
  2. package/src/App.tsx +168 -42
  3. package/src/RadarApp.tsx +9 -1
  4. package/src/api/client.ts +185 -6
  5. package/src/components/UserMenu.tsx +56 -10
  6. package/src/components/applications/ApplicationsView.tsx +27 -19
  7. package/src/components/audit/AuditSettingsDialog.tsx +1 -1
  8. package/src/components/audit/AuditView.tsx +23 -35
  9. package/src/components/gitops/GitOpsView.tsx +24 -2
  10. package/src/components/helm/HelmView.tsx +12 -8
  11. package/src/components/home/HomeView.tsx +1 -1
  12. package/src/components/home/mcpToolCatalog.ts +34 -0
  13. package/src/components/issues/IssuesPane.tsx +82 -28
  14. package/src/components/nav/PrimaryNavRail.tsx +282 -0
  15. package/src/components/resource/HPACharts.tsx +7 -2
  16. package/src/components/resource/RestartChart.tsx +8 -0
  17. package/src/components/resources/ResourcesView.tsx +54 -3
  18. package/src/components/resources/renderers/HPARenderer.tsx +4 -1
  19. package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
  20. package/src/components/settings/SettingsDialog.tsx +44 -7
  21. package/src/components/ui/CommandPalette.tsx +6 -215
  22. package/src/components/ui/Omnibar.tsx +493 -0
  23. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  24. package/src/components/ui/command-items.ts +178 -0
  25. package/src/components/workload/WorkloadView.tsx +3 -1
  26. package/src/context/NavCustomization.tsx +11 -0
  27. package/src/contexts/CapabilitiesContext.tsx +16 -5
  28. package/src/hooks/useMediaQuery.ts +21 -0
  29. package/src/hooks/useNavRailPinned.ts +46 -0
  30. package/src/hooks/useRecentResources.ts +49 -0
  31. package/src/utils/navigation.ts +11 -0
@@ -1,11 +1,23 @@
1
+ import { useMemo, useState } from 'react'
1
2
  import { useIssues } from '../../api/client'
2
3
  import type { SelectedResource } from '../../types'
3
- import { IssuesView, PaneLoader, type IssueResourceRef } from '@skyhook-io/k8s-ui'
4
- import { AlertTriangle, ArrowLeft } from 'lucide-react'
4
+ import {
5
+ IssuesView,
6
+ PaneLoader,
7
+ PageHeader,
8
+ SummaryTile,
9
+ ISSUE_SEVERITIES,
10
+ ISSUE_SEVERITY_LABEL,
11
+ type IssueResourceRef,
12
+ type IssueSeverity,
13
+ type SummaryTone,
14
+ } from '@skyhook-io/k8s-ui'
15
+ import { AlertTriangle } from 'lucide-react'
16
+
17
+ const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = { critical: 'error', warning: 'warning' }
5
18
 
6
19
  interface IssuesPaneProps {
7
20
  namespaces: string[]
8
- onBack: () => void
9
21
  onNavigateToResource: (resource: SelectedResource) => void
10
22
  }
11
23
 
@@ -13,9 +25,28 @@ interface IssuesPaneProps {
13
25
  // (IssuesView) the Hub fleet view uses — single cluster here, so no cluster
14
26
  // label and in-app (client-side) resource navigation. Classification +
15
27
  // owner-grouping come pre-computed from radar's /api/issues
16
- // (internal/issues.Compose → Classify → Group).
17
- export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesPaneProps) {
28
+ // (internal/issues.Compose → Classify → Group). Filtering is the host's job
29
+ // (IssuesView is a pure list); single-cluster gets a light severity filter via
30
+ // the header status tiles (clickable → filter), matching the Applications /
31
+ // GitOps header-tile pattern rather than Hub's fleet facet sidebar.
32
+ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps) {
18
33
  const { data, isLoading, error } = useIssues(namespaces)
34
+ const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
35
+
36
+ const allIssues = data?.issues ?? []
37
+ const totals = useMemo(() => {
38
+ const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
39
+ for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
40
+ return t
41
+ }, [allIssues])
42
+ const shown = severityFilter.size ? allIssues.filter((i) => severityFilter.has(i.severity)) : allIssues
43
+
44
+ const toggleSeverity = (s: IssueSeverity) =>
45
+ setSeverityFilter((prev) => {
46
+ const next = new Set(prev)
47
+ next.has(s) ? next.delete(s) : next.add(s)
48
+ return next
49
+ })
19
50
 
20
51
  const onResourceClick = (ref: IssueResourceRef) =>
21
52
  onNavigateToResource({ kind: ref.kind, namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' })
@@ -33,30 +64,37 @@ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesP
33
64
  }
34
65
 
35
66
  return (
36
- <div className="flex-1 flex flex-col min-h-0 p-6 gap-6 overflow-auto">
37
- <div className="flex items-center gap-4">
38
- <button
39
- onClick={onBack}
40
- className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors"
41
- >
42
- <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
43
- </button>
44
- <div className="flex-1">
45
- <div className="flex items-center gap-2">
46
- <AlertTriangle className="w-5 h-5 text-theme-text-secondary" />
47
- <h1 className="text-lg font-semibold text-theme-text-primary">Issues</h1>
48
- </div>
49
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7">
50
- Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect.
51
- </p>
52
- </div>
53
- </div>
67
+ <div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
68
+ <PageHeader
69
+ icon={AlertTriangle}
70
+ title="Issues"
71
+ description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
72
+ actions={
73
+ allIssues.length > 0 ? (
74
+ <>
75
+ <SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
76
+ {ISSUE_SEVERITIES.map((s) =>
77
+ totals[s] > 0 || severityFilter.has(s) ? (
78
+ <SummaryTile
79
+ key={s}
80
+ label={ISSUE_SEVERITY_LABEL[s]}
81
+ value={totals[s]}
82
+ tone={SEVERITY_TONE[s]}
83
+ active={severityFilter.has(s)}
84
+ onClick={() => toggleSeverity(s)}
85
+ />
86
+ ) : null,
87
+ )}
88
+ </>
89
+ ) : undefined
90
+ }
91
+ />
54
92
 
55
93
  {/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
56
94
  mean "can't see" rather than "nothing broken" — say so up front so the
57
95
  empty state isn't mistaken for a clean bill of health. */}
58
96
  {data?.visibility?.impact && (
59
- <div className="-mt-3 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
97
+ <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">
60
98
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
61
99
  <span>Limited visibility — {data.visibility.impact} Results may be incomplete.</span>
62
100
  </div>
@@ -65,14 +103,30 @@ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesP
65
103
  {/* Truncation honesty: when more issues matched than were returned, say
66
104
  so — don't present a capped list as the complete picture. */}
67
105
  {data?.total_matched != null && data.total_matched > (data.issues?.length ?? 0) && (
68
- <p className="-mt-3 text-xs text-theme-text-tertiary">
106
+ <p className="text-xs text-theme-text-tertiary">
69
107
  Showing {data.issues?.length ?? 0} of {data.total_matched} issues (capped) — narrow by namespace to see the rest.
70
108
  </p>
71
109
  )}
72
110
 
73
- {/* anyData = the query resolved, i.e. the cluster is reachable; an empty
74
- list then means "nothing broken" rather than "not connected". */}
75
- <IssuesView issues={data?.issues ?? []} anyData={!!data} onResourceClick={onResourceClick} />
111
+ {/* Filtered-empty is NOT the healthy empty state: when a severity filter
112
+ hides every row but issues still exist, say "no matches" rather than
113
+ letting IssuesView render its "nothing broken" terminal state. */}
114
+ {severityFilter.size > 0 && allIssues.length > 0 && shown.length === 0 ? (
115
+ <div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-theme-text-secondary">
116
+ <p>No issues match the selected severity.</p>
117
+ <button
118
+ type="button"
119
+ onClick={() => setSeverityFilter(new Set())}
120
+ className="text-xs text-skyhook-600 hover:text-skyhook-500 dark:text-skyhook-400"
121
+ >
122
+ Clear filter
123
+ </button>
124
+ </div>
125
+ ) : (
126
+ /* anyData = the query resolved, i.e. the cluster is reachable; an empty
127
+ list then means "nothing broken" rather than "not connected". */
128
+ <IssuesView issues={shown} anyData={!!data} onResourceClick={onResourceClick} />
129
+ )}
76
130
  </div>
77
131
  )
78
132
  }
@@ -0,0 +1,282 @@
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
+
7
+ // The views the rail can navigate to. Broader than k8s-ui's ExtendedMainView
8
+ // (which omits 'applications') — it mirrors the navigable subset of App.tsx's
9
+ // own view union, so onNavigate accepts App's setMainView directly.
10
+ type NavRailView = MainView | 'issues' | 'traffic' | 'gitops' | 'applications' | 'cost' | 'checks'
11
+
12
+ // Primary left nav rail for standalone (non-embedded) Radar.
13
+ //
14
+ // Ported from radar-hub-web's src/shell/LeftRail.tsx, simplified for OSS:
15
+ // Radar's window is desktop-only (App.tsx outer `min-w-[800px]`), so there's
16
+ // no mobile slide-in sheet — just two committed states driven by a persisted
17
+ // pin (see useNavRailPinned):
18
+ // - pinned → labeled w-48 sidebar.
19
+ // - unpinned → slim w-14 icon rail; labels surface as `left-full` fly-outs
20
+ // on hover (NOT a whole-rail hover-expand, which would reflow
21
+ // content under the cursor).
22
+ //
23
+ // In embedded mode (Radar Hub) this rail is not rendered at all — the host
24
+ // owns the left chrome via its own fleet LeftRail, and Radar falls back to
25
+ // its top-bar pill nav. That keeps the @skyhook-io/radar-app surface
26
+ // non-breaking and avoids triple-stacked left chrome.
27
+
28
+ interface NavItemDef {
29
+ view: NavRailView
30
+ icon: ComponentType<{ className?: string }>
31
+ label: string
32
+ }
33
+
34
+ // The full standalone view set, flat (no group dividers). Order descends by
35
+ // day-to-day frequency: Home, then the Resources/Issues/Topology core ("what's
36
+ // running / what's wrong / how's it wired"), then app + temporal views,
37
+ // delivery, and finally the periodic posture/spend pair. The rail's vertical
38
+ // room lets us surface the views the 8-slot pill bar dropped (Issues,
39
+ // Applications, Cost).
40
+ const NAV_ITEMS: NavItemDef[] = [
41
+ { view: 'home', icon: Home, label: 'Home' },
42
+ { view: 'resources', icon: List, label: 'Resources' },
43
+ { view: 'issues', icon: AlertTriangle, label: 'Issues' },
44
+ { view: 'topology', icon: Network, label: 'Topology' },
45
+ { view: 'applications', icon: Boxes, label: 'Applications' },
46
+ { view: 'timeline', icon: Clock, label: 'Timeline' },
47
+ { view: 'traffic', icon: Activity, label: 'Traffic' },
48
+ { view: 'helm', icon: Package, label: 'Helm' },
49
+ { view: 'gitops', icon: GitBranch, label: 'GitOps' },
50
+ { view: 'checks', icon: ShieldCheck, label: 'Checks' },
51
+ { view: 'cost', icon: DollarSign, label: 'Cost' },
52
+ ]
53
+
54
+ interface PrimaryNavRailProps {
55
+ // `string`, not ExtendedMainView: App.tsx's mainView is a superset
56
+ // (adds 'applications'/'workload'/'compare') that isn't in k8s-ui's
57
+ // ExtendedMainView. Active state only needs equality, so accept any
58
+ // view id and compare against our NAV_ITEMS views.
59
+ activeView: string
60
+ onNavigate: (view: NavRailView) => void
61
+ pinned: boolean
62
+ onTogglePinned: () => void
63
+ // Hidden on narrow windows where the rail is responsively forced slim — an
64
+ // expand control there would just re-breach the content floor.
65
+ showPinToggle?: boolean
66
+ // Rail-bottom "me & my tools" cluster. onOpenSettings opens the Settings
67
+ // dialog; accountSlot is the account control (App passes <UserMenu variant=…>,
68
+ // which self-nulls without auth so the row vanishes in no-auth OSS).
69
+ onOpenSettings?: () => void
70
+ accountSlot?: ReactNode
71
+ }
72
+
73
+ export function PrimaryNavRail({ activeView, onNavigate, pinned, onTogglePinned, showPinToggle = true, onOpenSettings, accountSlot }: PrimaryNavRailProps) {
74
+ return (
75
+ <aside
76
+ aria-label="Primary navigation"
77
+ className={clsx(
78
+ // Dedicated sidebar token = the DEEPEST layer of the elevation scale, so
79
+ // the rail reads as chrome on EVERY view — not just ones where a `surface`
80
+ // facet pane happens to sit next to it (the content floor is `base`, which
81
+ // a `base` rail blends into). Neutral by design: the brand accent lives on
82
+ // the active item, not the nav background.
83
+ '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)]',
84
+ // w-44 (176px): OSS labels are short (longest is "Applications"), so the
85
+ // rail is trimmer than Radar Cloud's w-60 (which carries long cluster
86
+ // names). Keep in sync with the minWidth content-floor calc in App.tsx.
87
+ pinned ? 'w-44' : 'w-14',
88
+ )}
89
+ >
90
+ <BrandRow pinned={pinned} onNavigate={onNavigate} />
91
+
92
+ {/* Pinned: the nav owns the flex space and scrolls internally on short
93
+ windows, so the rail-bottom account/settings/pin row stays reachable.
94
+ Slim: keep the nav at natural height with a spacer below — an
95
+ overflow-y container would clip the `left-full` fly-out labels
96
+ (overflow-y:auto forces overflow-x to clip), and those ARE the labels
97
+ in slim mode. The slim short-window case is doubly-rare (slim is
98
+ width-triggered) and yields to keeping fly-outs intact. */}
99
+ <nav
100
+ className={clsx(
101
+ 'flex flex-col gap-0.5 pt-3 px-2',
102
+ pinned && 'flex-1 min-h-0 overflow-y-auto',
103
+ )}
104
+ >
105
+ {NAV_ITEMS.map((item) => (
106
+ <NavRailItem
107
+ key={item.view}
108
+ item={item}
109
+ active={activeView === item.view}
110
+ pinned={pinned}
111
+ onNavigate={onNavigate}
112
+ />
113
+ ))}
114
+ </nav>
115
+
116
+ {!pinned && <div className="flex-1" />}
117
+
118
+ {/* "Me & my tools" — account + settings, the conventional rail-bottom
119
+ cluster (VS Code / Discord / ArgoCD / Radar Cloud's own UserOrgMenu).
120
+ accountSlot self-nulls without auth, so no-auth OSS shows only Settings. */}
121
+ <nav className="flex flex-col gap-0.5 px-2 pt-1 border-t border-theme-border/50">
122
+ {accountSlot}
123
+ {onOpenSettings && (
124
+ <RailActionRow icon={Settings} label="Settings" pinned={pinned} onClick={onOpenSettings} />
125
+ )}
126
+ </nav>
127
+
128
+ {/* Pin / unpin toggle — anchored at the bottom (VS Code / Linear pattern).
129
+ The icon points the direction the rail will move: close-panel when
130
+ expanded, open-panel when slim. Hidden when the rail is responsively
131
+ forced slim (showPinToggle=false) — expanding there isn't available. */}
132
+ {showPinToggle && (
133
+ <div className="px-2 pb-2 pt-1 border-t border-theme-border/50">
134
+ <button
135
+ type="button"
136
+ onClick={onTogglePinned}
137
+ aria-label={pinned ? 'Collapse navigation' : 'Expand navigation'}
138
+ title={pinned ? 'Collapse navigation' : 'Expand navigation'}
139
+ 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"
140
+ >
141
+ <span className="flex w-10 shrink-0 items-center justify-center">
142
+ {pinned ? <PanelLeftClose className="w-[18px] h-[18px]" /> : <PanelLeftOpen className="w-[18px] h-[18px]" />}
143
+ </span>
144
+ {/* `hidden` (not sr-only) when collapsed: the button's aria-label is
145
+ the accessible name; leaving an "Collapse" label in the a11y tree
146
+ would contradict the "Expand navigation" label. */}
147
+ <span className={clsx('text-[13px] font-medium', !pinned && 'hidden')}>Collapse</span>
148
+ </button>
149
+ </div>
150
+ )}
151
+ </aside>
152
+ )
153
+ }
154
+
155
+ function BrandRow({ pinned, onNavigate }: { pinned: boolean; onNavigate: (view: NavRailView) => void }) {
156
+ // Clickable brand = secondary home affordance (logo→home convention). The
157
+ // Home nav item below still carries the active state; the brand just navigates.
158
+ return (
159
+ <button
160
+ type="button"
161
+ onClick={() => onNavigate('home')}
162
+ aria-label="Radar — go to home"
163
+ title="Home"
164
+ // Height matches the top bar header (App.tsx — items-center + py-2 = 51px)
165
+ // so the rail's brand divider and the header's bottom border form one line.
166
+ className="flex h-[51px] w-full items-center border-b border-theme-border/50 shrink-0 transition-opacity hover:opacity-80"
167
+ >
168
+ <span className="flex w-14 shrink-0 items-center justify-center">
169
+ <span className="relative w-7 h-7 rounded-lg overflow-hidden bg-emerald-500/10 border border-emerald-500/20">
170
+ <img
171
+ src="/images/radar/radar-icon.svg"
172
+ alt=""
173
+ aria-hidden
174
+ className="w-full h-full p-0.5"
175
+ onError={(e) => console.error('Radar logo asset failed to load:', (e.currentTarget as HTMLImageElement).src)}
176
+ />
177
+ </span>
178
+ </span>
179
+ <span className={clsx('flex flex-col leading-none text-left', !pinned && 'opacity-0 pointer-events-none')}>
180
+ <span className="font-semibold text-[15px] tracking-tight text-theme-text-primary">Radar</span>
181
+ <span className="text-[9px] mt-0.5 tracking-wide uppercase text-theme-text-tertiary">by Skyhook</span>
182
+ </span>
183
+ </button>
184
+ )
185
+ }
186
+
187
+ function NavRailItem({
188
+ item,
189
+ active,
190
+ pinned,
191
+ onNavigate,
192
+ }: {
193
+ item: NavItemDef
194
+ active: boolean
195
+ pinned: boolean
196
+ onNavigate: (view: NavRailView) => void
197
+ }) {
198
+ const { icon: Icon, label, view } = item
199
+ return (
200
+ <div className={clsx('group/item relative', !pinned && 'w-10')}>
201
+ <button
202
+ type="button"
203
+ onClick={() => onNavigate(view)}
204
+ aria-current={active ? 'page' : undefined}
205
+ className={clsx(
206
+ 'relative flex h-9 w-full items-center rounded-md text-sm font-medium transition-colors',
207
+ // Slim mode: clip the hit area to the icon column so the (opacity-0)
208
+ // label can't capture clicks meant for content to the right.
209
+ !pinned && 'max-w-10 overflow-hidden',
210
+ active
211
+ ? 'bg-skyhook-600/10 dark:bg-skyhook-500/15 text-skyhook-700 dark:text-skyhook-300'
212
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
213
+ )}
214
+ >
215
+ {/* Left-edge accent bar on active — reads even when the row tint is soft. */}
216
+ <span
217
+ aria-hidden
218
+ className={clsx(
219
+ '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',
220
+ active ? 'opacity-100' : 'opacity-0',
221
+ )}
222
+ />
223
+ <span className="flex w-10 shrink-0 items-center justify-center">
224
+ <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')} />
225
+ </span>
226
+ <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{label}</span>
227
+ </button>
228
+
229
+ {/* Slim-mode fly-out label — sibling of the button so it escapes the
230
+ button's overflow clip; pointer-events-none so it never eats clicks. */}
231
+ {!pinned && (
232
+ <span
233
+ aria-hidden
234
+ 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"
235
+ >
236
+ {label}
237
+ </span>
238
+ )}
239
+ </div>
240
+ )
241
+ }
242
+
243
+ // A rail-bottom action row — same icon-column + fly-out treatment as NavRailItem
244
+ // but it's an action (onClick), not navigation, so no active-state/accent bar.
245
+ // Exported so the account control (UserMenu rail variant) can match the look.
246
+ export function RailActionRow({
247
+ icon: Icon,
248
+ label,
249
+ pinned,
250
+ onClick,
251
+ }: {
252
+ icon: ComponentType<{ className?: string }>
253
+ label: string
254
+ pinned: boolean
255
+ onClick: () => void
256
+ }) {
257
+ return (
258
+ <div className={clsx('group/item relative', !pinned && 'w-10')}>
259
+ <button
260
+ type="button"
261
+ onClick={onClick}
262
+ className={clsx(
263
+ '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',
264
+ !pinned && 'max-w-10 overflow-hidden',
265
+ )}
266
+ >
267
+ <span className="flex w-10 shrink-0 items-center justify-center">
268
+ <Icon className="w-[18px] h-[18px] text-theme-text-tertiary group-hover/item:text-theme-text-secondary" />
269
+ </span>
270
+ <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{label}</span>
271
+ </button>
272
+ {!pinned && (
273
+ <span
274
+ aria-hidden
275
+ 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"
276
+ >
277
+ {label}
278
+ </span>
279
+ )}
280
+ </div>
281
+ )
282
+ }
@@ -86,8 +86,13 @@ interface FlatPoint { timestamp: number; value: number }
86
86
 
87
87
  function extractFirstSeries(series: PrometheusSeries[]): FlatPoint[] | null {
88
88
  for (const s of series) {
89
- if (s.dataPoints.length > 0) {
90
- return s.dataPoints.map(dp => ({ timestamp: dp.timestamp, value: dp.value }))
89
+ // Drop gap points (null/undefined value) so the line never plots NaN or a
90
+ // false 0. Replica-count series realistically never gap, so the survivors
91
+ // are joined into one continuous line (this bridges a gap, if one occurred —
92
+ // unlike AreaChart, which breaks the path)
93
+ const finite = s.dataPoints.filter((dp): dp is FlatPoint => dp.value != null)
94
+ if (finite.length > 0) {
95
+ return finite.map(dp => ({ timestamp: dp.timestamp, value: dp.value }))
91
96
  }
92
97
  }
93
98
  return null
@@ -94,6 +94,14 @@ function collectRestartEvents(series: PrometheusSeries[] | undefined): RestartEv
94
94
  // window — and use the increase as the marker's restart count.
95
95
  let prev: number | null = null
96
96
  for (const dp of s.dataPoints) {
97
+ // Skip gap samples and drop the previous reference, so the next finite
98
+ // sample isn't diffed across the gap — a delta across a gap is
99
+ // meaningless and could fabricate a restart count. We compare only
100
+ // consecutive finite samples.
101
+ if (dp.value == null) {
102
+ prev = null
103
+ continue
104
+ }
97
105
  if (prev !== null) {
98
106
  // Only count positive deltas — restarts that entered the rolling 1h
99
107
  // window during the chart range. The first sample's value covers
@@ -1,17 +1,20 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads } from '../../api/client'
5
5
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
6
  import { useAPIResources } from '../../api/apiResources'
7
7
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
8
8
  import { usePinnedKinds } from '../../hooks/useFavorites'
9
9
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
10
10
  import {
11
+ canBulkRestartKind,
12
+ canBulkScaleKind,
11
13
  ResourcesView as BaseResourcesView,
12
14
  CORE_RESOURCES,
15
+ intersectWorkloadWrites,
13
16
  } from '@skyhook-io/k8s-ui'
14
- import type { ResourceQueryResult } from '@skyhook-io/k8s-ui'
17
+ import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
15
18
  import type { SelectedResource } from '../../types'
16
19
  import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
17
20
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
@@ -31,10 +34,45 @@ interface ResourcesViewProps {
31
34
  onClearNamespaces?: () => void
32
35
  }
33
36
 
37
+ type SelectedKindInfo = { name: string; kind: string; group: string } | null
38
+
39
+ const deniedWorkloadWrites: WorkloadWritePermissions = {
40
+ deployments: false,
41
+ daemonSets: false,
42
+ statefulSets: false,
43
+ rollouts: false,
44
+ }
45
+
34
46
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
35
47
  const location = useLocation()
36
48
  const navigate = useNavigate()
37
49
 
50
+ const { data: capabilities } = useCapabilities()
51
+ const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined
52
+ const { data: namespaceCapabilities } = useNamespaceCapabilities(namespaceForCapabilities, capabilities)
53
+ const namespaceCapabilityNames = useMemo(() => namespaces.length > 1 ? [...namespaces].sort() : [], [namespaces])
54
+ const { data: namespaceCapabilitiesList } = useQuery<Array<Pick<Capabilities, 'workloadWrites'>>>({
55
+ queryKey: ['capabilities', 'namespaces', namespaceCapabilityNames],
56
+ queryFn: async () => {
57
+ const results = await Promise.allSettled(
58
+ namespaceCapabilityNames.map(async ns => ({
59
+ namespace: ns,
60
+ capabilities: await fetchJSON<Capabilities>(`/capabilities?namespace=${encodeURIComponent(ns)}`),
61
+ }))
62
+ )
63
+ return results.map((result, index) => {
64
+ if (result.status === 'fulfilled') {
65
+ return { workloadWrites: result.value.capabilities.workloadWrites }
66
+ }
67
+ console.warn(`Failed to fetch namespace capabilities for ${namespaceCapabilityNames[index]}, withholding workload writes:`, result.reason)
68
+ return { workloadWrites: deniedWorkloadWrites }
69
+ })
70
+ },
71
+ enabled: namespaceCapabilityNames.length > 1 && capabilities != null,
72
+ staleTime: 60000,
73
+ })
74
+ const multiNamespaceWorkloadWrites = useMemo(() => intersectWorkloadWrites(namespaceCapabilitiesList), [namespaceCapabilitiesList])
75
+
38
76
  // API resources discovery
39
77
  const { data: apiResources } = useAPIResources()
40
78
 
@@ -44,7 +82,14 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
44
82
  }, [apiResources])
45
83
 
46
84
  // Track the selected kind from the k8s-ui component
47
- const [selectedKind, setSelectedKind] = useState<{ name: string; kind: string; group: string } | null>(null)
85
+ const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(null)
86
+ const workloadWrites = namespaces.length === 0
87
+ ? capabilities?.workloadWrites
88
+ : namespaces.length === 1
89
+ ? namespaceCapabilities?.workloadWrites
90
+ : multiNamespaceWorkloadWrites
91
+ const canBulkRestartSelectedKind = useMemo(() => canBulkRestartKind(selectedKind, workloadWrites), [selectedKind, workloadWrites])
92
+ const canBulkScaleSelectedKind = useMemo(() => canBulkScaleKind(selectedKind, workloadWrites), [selectedKind, workloadWrites])
48
93
 
49
94
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
50
95
  const namespacesParam = namespaces.join(',')
@@ -148,6 +193,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
148
193
 
149
194
  // Bulk delete
150
195
  const bulkDeleteMutation = useBulkDeleteResources()
196
+ const bulkRestartMutation = useBulkRestartWorkloads()
197
+ const bulkScaleMutation = useBulkScaleWorkloads()
151
198
 
152
199
  // Navigation adapter. k8s-ui constructs paths from `basePath` (which
153
200
  // includes the router basename so they line up with window.location.pathname
@@ -230,6 +277,10 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
230
277
  // Bulk operations
231
278
  onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
232
279
  isBulkDeleting={bulkDeleteMutation.isPending}
280
+ onBulkRestart={canBulkRestartSelectedKind ? (items, options) => bulkRestartMutation.mutate({ items }, { onSuccess: options?.onSuccess }) : undefined}
281
+ isBulkRestarting={canBulkRestartSelectedKind && bulkRestartMutation.isPending}
282
+ onBulkScale={canBulkScaleSelectedKind ? (items, replicas, options) => bulkScaleMutation.mutate({ items, replicas }, { onSuccess: options?.onSuccess }) : undefined}
283
+ isBulkScaling={canBulkScaleSelectedKind && bulkScaleMutation.isPending}
233
284
  />
234
285
  <CreateResourceDialog
235
286
  open={createDialogOpen}
@@ -1,16 +1,19 @@
1
1
  import { HPARenderer as BaseHPARenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/HPARenderer'
2
2
  import { HPACharts } from '../../resource/HPACharts'
3
+ import type { HPADiagnosis } from '@skyhook-io/k8s-ui'
3
4
 
4
5
  interface HPARendererProps {
5
6
  data: any
6
7
  onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
8
+ hpaDiagnosis?: HPADiagnosis
7
9
  }
8
10
 
9
- export function HPARenderer({ data, onNavigate }: HPARendererProps) {
11
+ export function HPARenderer({ data, onNavigate, hpaDiagnosis }: HPARendererProps) {
10
12
  return (
11
13
  <BaseHPARenderer
12
14
  data={data}
13
15
  onNavigate={onNavigate}
16
+ hpaDiagnosis={hpaDiagnosis}
14
17
  extraSections={<HPACharts data={data} />}
15
18
  />
16
19
  )
@@ -1,9 +1,11 @@
1
1
  import { WorkloadRenderer as BaseWorkloadRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/WorkloadRenderer'
2
2
  import { useNavigate } from 'react-router-dom'
3
- import { useScaleWorkload } from '../../../api/client'
3
+ import { useScaleWorkload, fetchJSON } from '../../../api/client'
4
4
  import { useRBACSubject } from '../../../api/rbac'
5
- import { useQueryClient } from '@tanstack/react-query'
6
- import type { Relationships, ResourceRef } from '../../../types'
5
+ import { useQueries, useQueryClient } from '@tanstack/react-query'
6
+ import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
7
+ import type { Relationships, ResourceRef, ResourceWithRelationships } from '../../../types'
8
+ import type { ScalerDiagnosis } from '@skyhook-io/k8s-ui/components/resources/renderers/WorkloadRenderer'
7
9
 
8
10
  // Map plural lowercase kind to singular PascalCase for ownerReferences matching
9
11
  function getOwnerKind(kind: string): string {
@@ -40,6 +42,34 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
40
42
  const { data: rbacData, isLoading: rbacLoading, error: rbacError } = useRBACSubject(
41
43
  'ServiceAccount', namespace, saName, !!namespace,
42
44
  )
45
+ const hpaRefs = (scaleBlockedBy ?? []).filter(ref => {
46
+ const refKind = ref.kind.toLowerCase()
47
+ return refKind === 'horizontalpodautoscaler' || refKind === 'hpa'
48
+ })
49
+ const hpaQueries = useQueries({
50
+ queries: hpaRefs.map(ref => ({
51
+ queryKey: ['resource', kindToPlural(ref.kind), ref.namespace, ref.name, ref.group],
52
+ queryFn: () => {
53
+ const ns = ref.namespace || '_'
54
+ const params = new URLSearchParams()
55
+ if (ref.group) params.set('group', ref.group)
56
+ const query = params.toString()
57
+ return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPlural(ref.kind)}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
58
+ },
59
+ enabled: Boolean(ref.kind && ref.name),
60
+ staleTime: 10000,
61
+ retry: false,
62
+ })),
63
+ })
64
+ const scalerDiagnostics: ScalerDiagnosis[] = hpaRefs.map((ref, index) => {
65
+ const query = hpaQueries[index]
66
+ return {
67
+ ref,
68
+ diagnosis: query.data?.hpaDiagnosis,
69
+ loading: query.isLoading,
70
+ error: query.isError ? (query.error instanceof Error ? query.error.message : 'Failed to fetch HPA') : undefined,
71
+ }
72
+ })
43
73
 
44
74
  return (
45
75
  <BaseWorkloadRenderer
@@ -51,6 +81,7 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
51
81
  rbacLoading={rbacLoading}
52
82
  rbacError={rbacError as Error | null}
53
83
  scaleBlockedBy={scaleBlockedBy}
84
+ scalerDiagnostics={scalerDiagnostics}
54
85
  onScale={async (replicas) => {
55
86
  await scaleMutation.mutateAsync({
56
87
  kind,