@skyhook-io/radar-app 1.5.0 → 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.
@@ -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,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,
@@ -5,7 +5,7 @@ import { clsx } from 'clsx'
5
5
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
6
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
8
- import { useCloudRole } from '../../api/client'
8
+ import { useCloudRole, useVersionCheck } from '../../api/client'
9
9
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
10
10
  import type { DeploymentMode } from '../../types'
11
11
 
@@ -38,6 +38,7 @@ interface SettingsDialogProps {
38
38
  export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsDialogProps) {
39
39
  const dialogRef = useRef<HTMLDivElement>(null)
40
40
  const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
41
+ const { data: versionInfo } = useVersionCheck()
41
42
  // Radar configuration (kubeconfig, port, integrations…) is host-level and
42
43
  // affects every user of this instance, so it's gated to owners. Personal
43
44
  // sections (My permissions) stay visible to everyone. Non-Cloud callers
@@ -262,6 +263,22 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
262
263
  </button>
263
264
  </div>
264
265
  )}
266
+
267
+ {/* About — muted version footer (canonical "Settings → About"). */}
268
+ <div className="flex items-center justify-between gap-2 px-4 py-2 border-t border-theme-border/60 text-[11px] text-theme-text-tertiary shrink-0">
269
+ <span>
270
+ Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
271
+ <span className="text-theme-text-disabled"> · by Skyhook</span>
272
+ </span>
273
+ <a
274
+ href="https://github.com/skyhook-io/radar"
275
+ target="_blank"
276
+ rel="noopener noreferrer"
277
+ className="text-accent-text hover:underline"
278
+ >
279
+ GitHub
280
+ </a>
281
+ </div>
265
282
  </div>
266
283
  </div>,
267
284
  document.body