@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.
- package/package.json +10 -11
- package/src/App.tsx +168 -42
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +185 -6
- package/src/components/UserMenu.tsx +56 -10
- package/src/components/applications/ApplicationsView.tsx +27 -19
- package/src/components/audit/AuditSettingsDialog.tsx +1 -1
- package/src/components/audit/AuditView.tsx +23 -35
- package/src/components/gitops/GitOpsView.tsx +24 -2
- package/src/components/helm/HelmView.tsx +12 -8
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +34 -0
- package/src/components/issues/IssuesPane.tsx +82 -28
- package/src/components/nav/PrimaryNavRail.tsx +282 -0
- package/src/components/resource/HPACharts.tsx +7 -2
- package/src/components/resource/RestartChart.tsx +8 -0
- package/src/components/resources/ResourcesView.tsx +54 -3
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +44 -7
- package/src/components/ui/CommandPalette.tsx +6 -215
- package/src/components/ui/Omnibar.tsx +493 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +3 -1
- package/src/context/NavCustomization.tsx +11 -0
- package/src/contexts/CapabilitiesContext.tsx +16 -5
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- 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 {
|
|
4
|
-
|
|
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
|
-
|
|
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-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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="
|
|
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="
|
|
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
|
-
{/*
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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<
|
|
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
|
|
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,
|