@skyhook-io/radar-app 1.6.1 → 1.7.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 +3 -2
- package/src/App.tsx +82 -56
- package/src/api/client.ts +29 -19
- package/src/components/gitops/GitOpsView.tsx +13 -6
- package/src/components/helm/HelmView.tsx +4 -4
- package/src/components/home/ClusterHealthCard.tsx +12 -10
- package/src/components/home/HomeView.tsx +213 -58
- package/src/components/resources/ResourcesView.tsx +46 -8
- package/src/context/NavCustomization.tsx +34 -9
- package/src/index.ts +1 -1
- package/src/main.tsx +6 -1
- package/src/monaco-setup.ts +17 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"vite": "^8.0.12"
|
|
76
76
|
},
|
|
77
77
|
"sideEffects": [
|
|
78
|
-
"*.css"
|
|
78
|
+
"*.css",
|
|
79
|
+
"./src/monaco-setup.ts"
|
|
79
80
|
]
|
|
80
81
|
}
|
package/src/App.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
|
|
|
29
29
|
import { ContextSwitcher } from './components/ContextSwitcher'
|
|
30
30
|
import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
|
|
31
31
|
import { useNavCustomization } from './context/NavCustomization'
|
|
32
|
+
import type { FleetTakeoverTarget } from './context/NavCustomization'
|
|
32
33
|
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
33
34
|
import { useNavRailPinned } from './hooks/useNavRailPinned'
|
|
34
35
|
import { useMediaQuery } from './hooks/useMediaQuery'
|
|
@@ -276,21 +277,46 @@ function AppInner() {
|
|
|
276
277
|
navigate({ pathname: path, search: newParams.toString() })
|
|
277
278
|
}, [navigate, searchParams])
|
|
278
279
|
|
|
279
|
-
// Cloud (embedded)
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
280
|
+
// Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
|
|
281
|
+
// own fleet pages scoped to this cluster — owned by the host's left rail — so
|
|
282
|
+
// Radar drops the matching pills (see the nav below) and any route into one
|
|
283
|
+
// of these views redirects to the host. Entry points that still land on the
|
|
284
|
+
// view — Home cards, ⌘K, WorkloadView's "view all" findings, bookmarks/deep
|
|
285
|
+
// links — all funnel through here. The view name doubles as the takeover
|
|
286
|
+
// target ('issues' | 'gitops' | 'checks'; 'audit' normalizes to 'checks' via
|
|
287
|
+
// getViewFromPath). `replace` (not assign) keeps the transient /<view> URL
|
|
288
|
+
// out of history so Back doesn't bounce off the redirect. Standalone OSS (no
|
|
289
|
+
// fleetTakeoverHref) is unaffected and renders the in-app view as before.
|
|
290
|
+
// Resolve every takeover target's host URL ONCE per render so the redirect
|
|
291
|
+
// effect, nav-pill filtering, inline-view gating, and the cert click handler
|
|
292
|
+
// all close over the SAME value — host callbacks aren't guaranteed idempotent
|
|
293
|
+
// (scope / flags / signed URLs can shift between calls). undefined = not taken
|
|
294
|
+
// over → Radar renders the view itself. `clusterChecksHref` is the deprecated
|
|
295
|
+
// pre-1.7 hook, folded into the 'checks' target for back-compat.
|
|
296
|
+
const fleetTakeoverHref = navCustomization.fleetTakeoverHref
|
|
288
297
|
const clusterChecksHref = navCustomization.clusterChecksHref
|
|
298
|
+
const takeover: Record<FleetTakeoverTarget, string | undefined> = {
|
|
299
|
+
issues: fleetTakeoverHref?.('issues'),
|
|
300
|
+
gitops: fleetTakeoverHref?.('gitops'),
|
|
301
|
+
checks: fleetTakeoverHref?.('checks') ?? clusterChecksHref?.(),
|
|
302
|
+
certs: fleetTakeoverHref?.('certs'),
|
|
303
|
+
}
|
|
304
|
+
// Has the host claimed this view? View-shaped targets only ('certs' has no
|
|
305
|
+
// Radar view — only its Home card consults `takeover`). Used to drop the nav
|
|
306
|
+
// pill and gate the inline view render in favor of the "Opening…" splash.
|
|
307
|
+
const isViewTakenOver = (view: ExtendedMainView): boolean =>
|
|
308
|
+
(view === 'issues' || view === 'gitops' || view === 'checks') && !!takeover[view]
|
|
309
|
+
// The host's URL for the CURRENT view, if taken over. Drives the redirect
|
|
310
|
+
// effect and the "Opening…" splash.
|
|
311
|
+
const viewTakeoverHref =
|
|
312
|
+
mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
|
|
313
|
+
? takeover[mainView]
|
|
314
|
+
: undefined
|
|
289
315
|
useEffect(() => {
|
|
290
|
-
if (
|
|
291
|
-
window.location.replace(
|
|
316
|
+
if (viewTakeoverHref) {
|
|
317
|
+
window.location.replace(viewTakeoverHref)
|
|
292
318
|
}
|
|
293
|
-
}, [
|
|
319
|
+
}, [viewTakeoverHref])
|
|
294
320
|
|
|
295
321
|
const [namespaces, setNamespaces] = useState<string[]>(getInitialState().namespaces)
|
|
296
322
|
// For large clusters: force SSE to reconnect with namespace filter
|
|
@@ -542,9 +568,11 @@ function AppInner() {
|
|
|
542
568
|
description: 'Show keyboard shortcuts',
|
|
543
569
|
category: 'General' as const,
|
|
544
570
|
scope: 'global' as const,
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
571
|
+
// Radar owns the shortcut registry even in a chromeless embed, so its `?`
|
|
572
|
+
// overlay is the one that actually lists the working shortcuts. The host
|
|
573
|
+
// (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
|
|
574
|
+
// it has no registry of its own to populate a competing overlay with.
|
|
575
|
+
handler: () => setShowHelp(prev => !prev),
|
|
548
576
|
},
|
|
549
577
|
{
|
|
550
578
|
id: 'command-palette',
|
|
@@ -1316,15 +1344,14 @@ function AppInner() {
|
|
|
1316
1344
|
// command palette (⌘K). Remove this comment to restore it.
|
|
1317
1345
|
{ view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
|
|
1318
1346
|
] as const)
|
|
1319
|
-
// In Cloud, Checks
|
|
1320
|
-
// left rail; the per-cluster view is just that fleet
|
|
1321
|
-
// to this cluster, so duplicating it as a peer pill here
|
|
1322
|
-
// second
|
|
1323
|
-
//
|
|
1324
|
-
// via the Home
|
|
1325
|
-
//
|
|
1326
|
-
|
|
1327
|
-
.filter(({ view }) => !(view === 'checks' && clusterChecksHref))
|
|
1347
|
+
// In Cloud, fleet-shaped views (Checks, Issues, GitOps) are owned by
|
|
1348
|
+
// the host's left rail; the per-cluster view is just that fleet page
|
|
1349
|
+
// filtered to this cluster, so duplicating it as a peer pill here
|
|
1350
|
+
// would be a second copy that teleports out of the cluster shell.
|
|
1351
|
+
// Drop any pill the host took over — cluster-scoped access stays
|
|
1352
|
+
// available via the Home cards (redirected by the takeover effect
|
|
1353
|
+
// above), ⌘K, and bookmarks. Standalone OSS keeps every pill.
|
|
1354
|
+
.filter(({ view }) => !isViewTakenOver(view))
|
|
1328
1355
|
.map(({ view, icon: Icon, label }) => (
|
|
1329
1356
|
<Tooltip key={view} content={label} delay={100} position="bottom">
|
|
1330
1357
|
<button
|
|
@@ -1380,8 +1407,6 @@ function AppInner() {
|
|
|
1380
1407
|
<div className="flex items-center gap-3 shrink-0">
|
|
1381
1408
|
<NamespaceSwitcher
|
|
1382
1409
|
ref={namespaceSwitcherRef}
|
|
1383
|
-
disabled={mainView === 'helm'}
|
|
1384
|
-
disabledTooltip="Helm view always shows all namespaces"
|
|
1385
1410
|
/>
|
|
1386
1411
|
|
|
1387
1412
|
|
|
@@ -1608,21 +1633,16 @@ function AppInner() {
|
|
|
1608
1633
|
console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
|
|
1609
1634
|
navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
|
|
1610
1635
|
}}
|
|
1611
|
-
onNavigateToResource={
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
} else {
|
|
1622
|
-
newParams.delete('apiGroup')
|
|
1623
|
-
}
|
|
1624
|
-
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1625
|
-
}}
|
|
1636
|
+
onNavigateToResource={navigateFromIssue}
|
|
1637
|
+
// Certs has no Radar view, so it can't ride the view-redirect effect
|
|
1638
|
+
// above — wire the Certificate Health card straight to the host's
|
|
1639
|
+
// fleet Certs page (scoped to this cluster) when claimed. `assign`
|
|
1640
|
+
// (not replace): the user is navigating forward from a card, so this
|
|
1641
|
+
// belongs in history. Omitted → the card falls back to Radar's own
|
|
1642
|
+
// TLS-secrets resource list.
|
|
1643
|
+
onNavigateToCerts={
|
|
1644
|
+
takeover.certs ? () => window.location.assign(takeover.certs!) : undefined
|
|
1645
|
+
}
|
|
1626
1646
|
/>
|
|
1627
1647
|
)}
|
|
1628
1648
|
|
|
@@ -1758,10 +1778,9 @@ function AppInner() {
|
|
|
1758
1778
|
/>
|
|
1759
1779
|
)}
|
|
1760
1780
|
|
|
1761
|
-
{/* Helm view - always show all namespaces since releases span multiple ns */}
|
|
1762
1781
|
{mainView === 'helm' && (
|
|
1763
1782
|
<HelmView
|
|
1764
|
-
|
|
1783
|
+
namespaces={namespaces}
|
|
1765
1784
|
selectedRelease={selectedHelmRelease}
|
|
1766
1785
|
onReleaseClick={(ns, name, storageNamespace) => {
|
|
1767
1786
|
setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
|
|
@@ -1777,8 +1796,9 @@ function AppInner() {
|
|
|
1777
1796
|
/>
|
|
1778
1797
|
)}
|
|
1779
1798
|
|
|
1780
|
-
{/* GitOps view
|
|
1781
|
-
|
|
1799
|
+
{/* GitOps view (inline only when the host hasn't taken it over — see
|
|
1800
|
+
the takeover splash below). */}
|
|
1801
|
+
{mainView === 'gitops' && !isViewTakenOver('gitops') && (
|
|
1782
1802
|
<GitOpsView
|
|
1783
1803
|
namespaces={namespaces}
|
|
1784
1804
|
onOpenResource={(resource) => {
|
|
@@ -1808,17 +1828,21 @@ function AppInner() {
|
|
|
1808
1828
|
<CostView onBack={() => setMainView('home')} />
|
|
1809
1829
|
)}
|
|
1810
1830
|
|
|
1811
|
-
{/*
|
|
1812
|
-
|
|
1813
|
-
splash instead of the
|
|
1814
|
-
nav lands.
|
|
1815
|
-
|
|
1831
|
+
{/* Takeover splash. When the host claims the current view via
|
|
1832
|
+
fleetTakeoverHref, the redirect effect above is mid-flight — render a
|
|
1833
|
+
brief splash instead of the inline view (which would flash + fire its
|
|
1834
|
+
own fetches) while the cross-document nav lands. Covers checks /
|
|
1835
|
+
issues / gitops with one block since only one view is active. */}
|
|
1836
|
+
{viewTakeoverHref && (
|
|
1816
1837
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
|
|
1817
1838
|
<img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
|
|
1818
|
-
<p className="text-sm text-theme-text-secondary">Opening
|
|
1839
|
+
<p className="text-sm text-theme-text-secondary">Opening…</p>
|
|
1819
1840
|
</div>
|
|
1820
1841
|
)}
|
|
1821
|
-
|
|
1842
|
+
|
|
1843
|
+
{/* Best practices detail view (inline only when the host hasn't taken
|
|
1844
|
+
Checks over — standalone OSS, or Cloud without a checks takeover). */}
|
|
1845
|
+
{mainView === 'checks' && !isViewTakenOver('checks') && (
|
|
1822
1846
|
<AuditView
|
|
1823
1847
|
namespaces={namespaces}
|
|
1824
1848
|
onNavigateToResource={navigateToResourceList}
|
|
@@ -1828,8 +1852,9 @@ function AppInner() {
|
|
|
1828
1852
|
{/* Issues — per-cluster live triage queue (hidden route: not yet in the
|
|
1829
1853
|
nav `views` list; reachable at /issues). Same shared <IssuesView> the
|
|
1830
1854
|
Hub fleet uses; a GitOps reconciler subject routes to its detail page,
|
|
1831
|
-
other resources open the standard resource view.
|
|
1832
|
-
|
|
1855
|
+
other resources open the standard resource view. Inline only when the
|
|
1856
|
+
host hasn't taken it over. */}
|
|
1857
|
+
{mainView === 'issues' && !isViewTakenOver('issues') && (
|
|
1833
1858
|
<IssuesPane
|
|
1834
1859
|
namespaces={namespaces}
|
|
1835
1860
|
onNavigateToResource={navigateFromIssue}
|
|
@@ -1972,8 +1997,9 @@ function AppInner() {
|
|
|
1972
1997
|
/>
|
|
1973
1998
|
<MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
|
|
1974
1999
|
|
|
1975
|
-
{/* Debug overlay
|
|
1976
|
-
|
|
2000
|
+
{/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
|
|
2001
|
+
own their own dev tooling; ours would collide with theirs bottom-right. */}
|
|
2002
|
+
{import.meta.env.DEV && showNavRail && <DebugOverlay />}
|
|
1977
2003
|
</div>
|
|
1978
2004
|
</div>
|
|
1979
2005
|
</PortForwardProvider>
|
package/src/api/client.ts
CHANGED
|
@@ -122,7 +122,7 @@ export interface DashboardProblem {
|
|
|
122
122
|
namespace: string
|
|
123
123
|
name: string
|
|
124
124
|
group?: string
|
|
125
|
-
severity: 'critical' | 'high' | 'medium'
|
|
125
|
+
severity: 'critical' | 'high' | 'medium' | 'warning' | 'info'
|
|
126
126
|
reason: string
|
|
127
127
|
message: string
|
|
128
128
|
age: string
|
|
@@ -341,8 +341,9 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
341
341
|
|
|
342
342
|
// Live cluster Issues — the grouped triage queue (radar's /api/issues =
|
|
343
343
|
// internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
|
|
344
|
-
// view fans the same shape across clusters.
|
|
345
|
-
//
|
|
344
|
+
// view fans the same shape across clusters. Do not carry old data across query
|
|
345
|
+
// keys: issues are scope-sensitive, so namespace changes must not show the
|
|
346
|
+
// previous scope's rows while the new scope fetches.
|
|
346
347
|
// total = rows returned (after the cap); total_matched = rows that matched
|
|
347
348
|
// before the cap. total_matched > total means the queue was truncated — surface
|
|
348
349
|
// that honestly rather than presenting a capped list as if it were complete.
|
|
@@ -365,7 +366,6 @@ export function useIssues(namespaces: string[] = []) {
|
|
|
365
366
|
queryFn: () => fetchJSON(`/issues${params}`),
|
|
366
367
|
staleTime: 30000,
|
|
367
368
|
refetchInterval: 30000,
|
|
368
|
-
placeholderData: (prev) => prev,
|
|
369
369
|
})
|
|
370
370
|
}
|
|
371
371
|
|
|
@@ -1136,6 +1136,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1136
1136
|
const p = new URLSearchParams()
|
|
1137
1137
|
p.set('namespace', namespace)
|
|
1138
1138
|
p.set('kind', singularKind)
|
|
1139
|
+
p.set('name', name)
|
|
1139
1140
|
p.set('include_managed', 'true')
|
|
1140
1141
|
p.set('since', since)
|
|
1141
1142
|
return p
|
|
@@ -1152,8 +1153,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1152
1153
|
const params = baseParams()
|
|
1153
1154
|
params.set('sources', 'k8s_event')
|
|
1154
1155
|
params.set('limit', '500')
|
|
1155
|
-
|
|
1156
|
-
return events.filter(e => e.name === name)
|
|
1156
|
+
return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
1157
1157
|
},
|
|
1158
1158
|
enabled,
|
|
1159
1159
|
refetchInterval: 15000,
|
|
@@ -1167,8 +1167,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1167
1167
|
const params = baseParams()
|
|
1168
1168
|
params.set('sources', 'informer,historical')
|
|
1169
1169
|
params.set('limit', '50')
|
|
1170
|
-
|
|
1171
|
-
return events.filter(e => e.name === name)
|
|
1170
|
+
return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
1172
1171
|
},
|
|
1173
1172
|
enabled,
|
|
1174
1173
|
refetchInterval: 15000,
|
|
@@ -1312,21 +1311,28 @@ export interface TopNodeMetrics {
|
|
|
1312
1311
|
memoryAllocatable: number // bytes
|
|
1313
1312
|
}
|
|
1314
1313
|
|
|
1315
|
-
// Fetch bulk metrics for
|
|
1316
|
-
export function useTopPodMetrics() {
|
|
1314
|
+
// Fetch bulk metrics for pods (for CPU/Memory columns in resource table)
|
|
1315
|
+
export function useTopPodMetrics(options?: { enabled?: boolean; namespaces?: string[] }) {
|
|
1316
|
+
const namespacesParam = options?.namespaces?.join(',') ?? ''
|
|
1317
|
+
const params = new URLSearchParams()
|
|
1318
|
+
if (namespacesParam) params.set('namespaces', namespacesParam)
|
|
1319
|
+
const queryString = params.toString()
|
|
1320
|
+
|
|
1317
1321
|
return useQuery<TopPodMetrics[]>({
|
|
1318
|
-
queryKey: ['top-pod-metrics'],
|
|
1319
|
-
queryFn: () => fetchJSON(
|
|
1322
|
+
queryKey: ['top-pod-metrics', namespacesParam],
|
|
1323
|
+
queryFn: () => fetchJSON(`/metrics/top/pods${queryString ? `?${queryString}` : ''}`),
|
|
1324
|
+
enabled: options?.enabled ?? true,
|
|
1320
1325
|
staleTime: 25000,
|
|
1321
1326
|
refetchInterval: 30000,
|
|
1322
1327
|
})
|
|
1323
1328
|
}
|
|
1324
1329
|
|
|
1325
1330
|
// Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
|
|
1326
|
-
export function useTopNodeMetrics() {
|
|
1331
|
+
export function useTopNodeMetrics(options?: { enabled?: boolean }) {
|
|
1327
1332
|
return useQuery<TopNodeMetrics[]>({
|
|
1328
1333
|
queryKey: ['top-node-metrics'],
|
|
1329
1334
|
queryFn: () => fetchJSON('/metrics/top/nodes'),
|
|
1335
|
+
enabled: options?.enabled ?? true,
|
|
1330
1336
|
staleTime: 25000,
|
|
1331
1337
|
refetchInterval: 30000,
|
|
1332
1338
|
})
|
|
@@ -2305,11 +2311,15 @@ export function useDrainNode() {
|
|
|
2305
2311
|
// Helm API hooks
|
|
2306
2312
|
// ============================================================================
|
|
2307
2313
|
|
|
2314
|
+
function helmNamespaceParams(namespaces: string[] = []) {
|
|
2315
|
+
return namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2308
2318
|
// List all Helm releases
|
|
2309
|
-
export function useHelmReleases(
|
|
2310
|
-
const params =
|
|
2319
|
+
export function useHelmReleases(namespaces: string[] = []) {
|
|
2320
|
+
const params = helmNamespaceParams(namespaces)
|
|
2311
2321
|
return useQuery<HelmRelease[]>({
|
|
2312
|
-
queryKey: ['helm-releases',
|
|
2322
|
+
queryKey: ['helm-releases', namespaces],
|
|
2313
2323
|
queryFn: () => fetchJSON(`/helm/releases${params}`),
|
|
2314
2324
|
staleTime: 30000, // 30 seconds
|
|
2315
2325
|
})
|
|
@@ -2387,10 +2397,10 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
|
|
|
2387
2397
|
}
|
|
2388
2398
|
|
|
2389
2399
|
// Batch check for upgrade availability (for list view)
|
|
2390
|
-
export function useHelmBatchUpgradeInfo(
|
|
2391
|
-
const params =
|
|
2400
|
+
export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
|
|
2401
|
+
const params = helmNamespaceParams(namespaces)
|
|
2392
2402
|
return useQuery<BatchUpgradeInfo>({
|
|
2393
|
-
queryKey: ['helm-batch-upgrade-info',
|
|
2403
|
+
queryKey: ['helm-batch-upgrade-info', namespaces],
|
|
2394
2404
|
queryFn: () => fetchJSON(`/helm/upgrade-check${params}`),
|
|
2395
2405
|
enabled,
|
|
2396
2406
|
staleTime: 30000, // 30 seconds - keep in sync with release list
|
|
@@ -83,6 +83,7 @@ const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
|
|
|
83
83
|
interface ResourceCountsResponse {
|
|
84
84
|
counts: Record<string, number>
|
|
85
85
|
forbidden?: string[]
|
|
86
|
+
unavailable?: string[]
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
interface GitOpsViewProps {
|
|
@@ -136,9 +137,15 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
136
137
|
initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
|
|
137
138
|
}, [apiResources])
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
const hasGitOpsRowResource = useMemo(() => (
|
|
141
|
+
hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
|
|
142
|
+
hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io') ||
|
|
143
|
+
hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
|
|
144
|
+
), [apiResources])
|
|
145
|
+
|
|
146
|
+
// Counts come from radar's /api/resource-counts. The extracted
|
|
147
|
+
// GitOpsTableView reads only the GitOps keys for mode tabs + empty-state
|
|
148
|
+
// checks.
|
|
142
149
|
const countsQuery = useQuery({
|
|
143
150
|
queryKey: ['gitops-resource-counts', namespacesParam],
|
|
144
151
|
queryFn: async () => {
|
|
@@ -217,6 +224,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
217
224
|
const [coldRetrying, setColdRetrying] = useState(false)
|
|
218
225
|
useEffect(() => { coldRetriesRef.current = 0; setColdRetrying(false) }, [apiResources, namespacesParam])
|
|
219
226
|
useEffect(() => {
|
|
227
|
+
if (!hasGitOpsRowResource || rowsQuery.error) { setColdRetrying(false); return }
|
|
220
228
|
if (apiResourcesLoading || rowsQuery.isFetching) return
|
|
221
229
|
if ((rowsQuery.data?.length ?? 0) > 0) { setColdRetrying(false); return }
|
|
222
230
|
if (coldRetriesRef.current >= 4) { setColdRetrying(false); return }
|
|
@@ -224,7 +232,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
224
232
|
const t = window.setTimeout(() => { coldRetriesRef.current += 1; refetchTable() }, 2000)
|
|
225
233
|
return () => window.clearTimeout(t)
|
|
226
234
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
227
|
-
}, [rowsQuery.data, rowsQuery.isFetching, apiResourcesLoading])
|
|
235
|
+
}, [rowsQuery.data, rowsQuery.isFetching, rowsQuery.error, apiResourcesLoading, hasGitOpsRowResource])
|
|
228
236
|
|
|
229
237
|
const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
|
|
230
238
|
const { kindName: kind, namespace, name, id } = row
|
|
@@ -271,6 +279,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
271
279
|
loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading || coldRetrying}
|
|
272
280
|
error={(rowsQuery.error as Error | null) ?? null}
|
|
273
281
|
counts={countsQuery.data?.counts ?? {}}
|
|
282
|
+
countsUnavailable={countsQuery.data?.unavailable}
|
|
274
283
|
onRefresh={() => rowsQuery.refetch()}
|
|
275
284
|
onRowClick={(row) => {
|
|
276
285
|
const ns = row.namespace || '_'
|
|
@@ -958,5 +967,3 @@ function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
|
|
|
958
967
|
</div>
|
|
959
968
|
)
|
|
960
969
|
}
|
|
961
|
-
|
|
962
|
-
|
|
@@ -15,23 +15,23 @@ import { InstallWizard } from './InstallWizard'
|
|
|
15
15
|
type ViewTab = 'releases' | 'charts'
|
|
16
16
|
|
|
17
17
|
interface HelmViewProps {
|
|
18
|
-
|
|
18
|
+
namespaces: string[]
|
|
19
19
|
selectedRelease?: SelectedHelmRelease | null
|
|
20
20
|
onReleaseClick?: (namespace: string, name: string, storageNamespace?: string) => void
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function HelmView({
|
|
23
|
+
export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmViewProps) {
|
|
24
24
|
const [activeTab, setActiveTab] = useState<ViewTab>('releases')
|
|
25
25
|
const [searchTerm, setSearchTerm] = useState('')
|
|
26
26
|
const [selectedChart, setSelectedChart] = useState<{ repo: string; chart: string; version: string; source: ChartSource } | null>(null)
|
|
27
27
|
|
|
28
|
-
const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(
|
|
28
|
+
const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespaces)
|
|
29
29
|
const isForbidden = isForbiddenError(releasesError)
|
|
30
30
|
const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
|
|
31
31
|
|
|
32
32
|
// Lazy load upgrade info after releases are loaded
|
|
33
33
|
const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
|
|
34
|
-
|
|
34
|
+
namespaces,
|
|
35
35
|
Boolean(releases && releases.length > 0)
|
|
36
36
|
)
|
|
37
37
|
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import type { DashboardResponse, DashboardMetrics, DashboardCRDCount
|
|
2
|
+
import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
|
|
3
3
|
import { HealthRing } from './HealthRing'
|
|
4
4
|
import {
|
|
5
5
|
AlertTriangle, CheckCircle, XCircle,
|
|
@@ -23,12 +23,13 @@ interface ClusterHealthCardProps {
|
|
|
23
23
|
metrics: DashboardMetrics | null
|
|
24
24
|
metricsServerAvailable: boolean
|
|
25
25
|
topCRDs?: DashboardCRDCount[] // Loaded lazily, may be undefined
|
|
26
|
-
|
|
26
|
+
issueCount: number
|
|
27
|
+
hasCriticalIssues: boolean
|
|
27
28
|
nodeVersionSkew: DashboardResponse['nodeVersionSkew']
|
|
28
29
|
onNavigateToKind: (kind: string, group?: string) => void
|
|
29
30
|
onNavigateToView: () => void
|
|
30
31
|
onWarningEventsClick?: () => void
|
|
31
|
-
|
|
32
|
+
onIssuesClick?: () => void
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function getMetricsInstallHint(platform: string): string {
|
|
@@ -113,12 +114,13 @@ export function ClusterHealthCard({
|
|
|
113
114
|
metrics,
|
|
114
115
|
metricsServerAvailable,
|
|
115
116
|
topCRDs: _topCRDs,
|
|
116
|
-
|
|
117
|
+
issueCount,
|
|
118
|
+
hasCriticalIssues,
|
|
117
119
|
nodeVersionSkew,
|
|
118
120
|
onNavigateToKind,
|
|
119
121
|
onNavigateToView,
|
|
120
122
|
onWarningEventsClick,
|
|
121
|
-
|
|
123
|
+
onIssuesClick,
|
|
122
124
|
}: ClusterHealthCardProps) {
|
|
123
125
|
void _topCRDs // Reserved for future CRD display
|
|
124
126
|
|
|
@@ -450,14 +452,14 @@ export function ClusterHealthCard({
|
|
|
450
452
|
<span><span className="font-mono">{health.warningEvents}</span> Warning Events</span>
|
|
451
453
|
</button>
|
|
452
454
|
)}
|
|
453
|
-
{
|
|
455
|
+
{issueCount > 0 && (
|
|
454
456
|
<button
|
|
455
|
-
onClick={
|
|
456
|
-
title="View
|
|
457
|
-
className=
|
|
457
|
+
onClick={onIssuesClick}
|
|
458
|
+
title="View grouped live operational issues"
|
|
459
|
+
className={clsx('badge w-fit gap-1.5 hover:opacity-80 transition-opacity', hasCriticalIssues ? 'status-unhealthy' : 'status-degraded')}
|
|
458
460
|
>
|
|
459
461
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
|
460
|
-
<span>
|
|
462
|
+
<span>{pluralize(issueCount, 'Active Issue')}</span>
|
|
461
463
|
</button>
|
|
462
464
|
)}
|
|
463
465
|
</div>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { useMemo, type ReactNode } from 'react'
|
|
2
|
-
import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
|
|
3
|
-
import type { DashboardResponse } from '../../api/client'
|
|
2
|
+
import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
|
|
4
3
|
import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
|
|
5
|
-
import { kindToPlural } from '../../utils/navigation'
|
|
6
4
|
import { TopologyPreview } from './TopologyPreview'
|
|
7
5
|
import { HelmSummary } from './HelmSummary'
|
|
8
6
|
import { ActivitySummary } from './ActivitySummary'
|
|
@@ -11,9 +9,18 @@ import { CertificateHealthCard } from './CertificateHealthCard'
|
|
|
11
9
|
import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
|
|
12
10
|
import { CostCard } from './CostCard'
|
|
13
11
|
import { GitOpsControllersCard } from './GitOpsControllersCard'
|
|
14
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
AuditCard,
|
|
14
|
+
PaneLoader,
|
|
15
|
+
StatusDot,
|
|
16
|
+
categoryLabel,
|
|
17
|
+
groupLabel,
|
|
18
|
+
subjectRef,
|
|
19
|
+
type Issue,
|
|
20
|
+
} from '@skyhook-io/k8s-ui'
|
|
21
|
+
import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
|
|
15
22
|
import { ClusterHealthCard } from './ClusterHealthCard'
|
|
16
|
-
import { AlertTriangle, Loader2, Shield } from 'lucide-react'
|
|
23
|
+
import { AlertTriangle, CheckCircle, Loader2, Shield } from 'lucide-react'
|
|
17
24
|
import { clsx } from 'clsx'
|
|
18
25
|
|
|
19
26
|
interface HomeViewProps {
|
|
@@ -22,10 +29,21 @@ interface HomeViewProps {
|
|
|
22
29
|
onNavigateToView: (view: ExtendedMainView, params?: Record<string, string>) => void
|
|
23
30
|
onNavigateToResourceKind: (kind: string, group?: string, filters?: Record<string, string[]>) => void
|
|
24
31
|
onNavigateToResource: (resource: SelectedResource) => void
|
|
32
|
+
/**
|
|
33
|
+
* Optional override for the Certificate Health card's click. When an embedded
|
|
34
|
+
* host (Radar Cloud) takes Certs over with its own fleet page, it passes this
|
|
35
|
+
* to route there instead of Radar's TLS-secrets resource list. Omitted in
|
|
36
|
+
* standalone OSS → the card drills into secrets as before.
|
|
37
|
+
*/
|
|
38
|
+
onNavigateToCerts?: () => void
|
|
25
39
|
}
|
|
26
40
|
|
|
27
|
-
export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
|
|
41
|
+
export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
|
|
28
42
|
const { data, isLoading, error } = useDashboard(namespaces)
|
|
43
|
+
const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
|
|
44
|
+
const issues = issuesData?.issues ?? []
|
|
45
|
+
const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
|
|
46
|
+
const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
|
|
29
47
|
|
|
30
48
|
// SSE is cluster-wide on small/medium clusters; the picker only narrows the
|
|
31
49
|
// dashboard summary, so re-apply the filter here or the legend disagrees.
|
|
@@ -73,8 +91,6 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
73
91
|
)
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
const hasProblems = data.problems && data.problems.length > 0
|
|
77
|
-
|
|
78
94
|
const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
|
|
79
95
|
|
|
80
96
|
return (
|
|
@@ -98,19 +114,17 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
98
114
|
metrics={data.metrics}
|
|
99
115
|
metricsServerAvailable={data.metricsServerAvailable}
|
|
100
116
|
topCRDs={crdsData?.topCRDs}
|
|
101
|
-
|
|
117
|
+
issueCount={issueCount}
|
|
118
|
+
hasCriticalIssues={hasCriticalIssues}
|
|
102
119
|
nodeVersionSkew={data.nodeVersionSkew}
|
|
103
120
|
onNavigateToKind={onNavigateToResourceKind}
|
|
104
121
|
onNavigateToView={() => onNavigateToView('resources')}
|
|
105
122
|
onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
|
|
106
|
-
|
|
123
|
+
onIssuesClick={() => onNavigateToView('issues')}
|
|
107
124
|
/>
|
|
108
125
|
|
|
109
|
-
{/* Row 2: Main content columns
|
|
110
|
-
<div className=
|
|
111
|
-
'grid gap-6',
|
|
112
|
-
hasProblems ? 'grid-cols-1 lg:grid-cols-[1fr_420px]' : 'grid-cols-1'
|
|
113
|
-
)}>
|
|
126
|
+
{/* Row 2: Main content columns - teasers left, issues right */}
|
|
127
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px]">
|
|
114
128
|
{/* Left column: teaser cards */}
|
|
115
129
|
<div className="flex flex-col gap-6 auto-rows-min">
|
|
116
130
|
{/* Live band — Topology + Timeline always render, so a fixed 2-up never strands.
|
|
@@ -158,7 +172,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
158
172
|
<BandItem>
|
|
159
173
|
<CertificateHealthCard
|
|
160
174
|
data={data.certificateHealth}
|
|
161
|
-
onNavigate={() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] })}
|
|
175
|
+
onNavigate={onNavigateToCerts ?? (() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] }))}
|
|
162
176
|
/>
|
|
163
177
|
</BandItem>
|
|
164
178
|
)}
|
|
@@ -190,14 +204,18 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
190
204
|
)}
|
|
191
205
|
</div>
|
|
192
206
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
<ProblemsPanel
|
|
208
|
+
issues={issues}
|
|
209
|
+
issueCount={issueCount}
|
|
210
|
+
visibility={issuesData?.visibility}
|
|
211
|
+
hasData={!!issuesData}
|
|
212
|
+
isLoading={issuesLoading && !issuesData}
|
|
213
|
+
isRefreshing={issuesFetching && !!issuesData}
|
|
214
|
+
error={issuesError}
|
|
215
|
+
totalReturned={issues.length}
|
|
216
|
+
onNavigateToIssues={() => onNavigateToView('issues')}
|
|
217
|
+
onResourceClick={onNavigateToResource}
|
|
218
|
+
/>
|
|
201
219
|
</div>
|
|
202
220
|
</div>
|
|
203
221
|
</div>
|
|
@@ -216,19 +234,56 @@ function BandItem({ children }: { children: ReactNode }) {
|
|
|
216
234
|
// ============================================================================
|
|
217
235
|
|
|
218
236
|
interface ProblemsPanelProps {
|
|
219
|
-
|
|
237
|
+
issues: Issue[]
|
|
238
|
+
issueCount: number
|
|
239
|
+
visibility?: IssuesResponse['visibility']
|
|
240
|
+
hasData: boolean
|
|
241
|
+
isLoading: boolean
|
|
242
|
+
isRefreshing: boolean
|
|
243
|
+
error: unknown
|
|
244
|
+
totalReturned: number
|
|
220
245
|
onNavigateToIssues: () => void
|
|
221
246
|
onResourceClick: (resource: SelectedResource) => void
|
|
222
247
|
}
|
|
223
248
|
|
|
224
249
|
|
|
225
|
-
function ProblemsPanel({
|
|
250
|
+
function ProblemsPanel({
|
|
251
|
+
issues,
|
|
252
|
+
issueCount,
|
|
253
|
+
visibility,
|
|
254
|
+
hasData,
|
|
255
|
+
isLoading,
|
|
256
|
+
isRefreshing,
|
|
257
|
+
error,
|
|
258
|
+
totalReturned,
|
|
259
|
+
onNavigateToIssues,
|
|
260
|
+
onResourceClick,
|
|
261
|
+
}: ProblemsPanelProps) {
|
|
262
|
+
const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
|
|
263
|
+
const hasIssues = issueCount > 0
|
|
264
|
+
const hasHardError = !!error && !hasData
|
|
265
|
+
const hasLimitedVisibility = !!visibility?.impact
|
|
266
|
+
const isTruncated = issueCount > totalReturned
|
|
267
|
+
const titleClass = hasCriticalIssues
|
|
268
|
+
? 'text-red-500'
|
|
269
|
+
: hasIssues || hasHardError || hasLimitedVisibility
|
|
270
|
+
? 'text-amber-500'
|
|
271
|
+
: 'text-theme-text-secondary'
|
|
272
|
+
const countClass = hasHardError
|
|
273
|
+
? 'status-unknown'
|
|
274
|
+
: hasCriticalIssues
|
|
275
|
+
? 'status-unhealthy'
|
|
276
|
+
: hasIssues || hasLimitedVisibility
|
|
277
|
+
? 'status-degraded'
|
|
278
|
+
: 'status-healthy'
|
|
279
|
+
const countLabel = isLoading ? '...' : hasHardError ? 'error' : String(issueCount)
|
|
280
|
+
|
|
226
281
|
return (
|
|
227
282
|
<div className="rounded-xl bg-theme-surface shadow-theme-sm flex flex-col lg:max-h-[calc(100vh-280px)] lg:sticky lg:top-0">
|
|
228
283
|
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
|
|
229
284
|
<div className="flex items-center gap-2">
|
|
230
|
-
<AlertTriangle className=
|
|
231
|
-
<span className=
|
|
285
|
+
<AlertTriangle className={clsx('w-4 h-4', titleClass)} />
|
|
286
|
+
<span className={clsx('text-xs font-semibold uppercase tracking-wider', titleClass)}>Active Issues</span>
|
|
232
287
|
</div>
|
|
233
288
|
<div className="flex items-center gap-2">
|
|
234
289
|
<button
|
|
@@ -238,41 +293,141 @@ function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: Proble
|
|
|
238
293
|
>
|
|
239
294
|
View all
|
|
240
295
|
</button>
|
|
241
|
-
|
|
296
|
+
{isRefreshing && !isLoading && (
|
|
297
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary" aria-label="Refreshing issues" />
|
|
298
|
+
)}
|
|
299
|
+
<span className={clsx('badge rounded-full', countClass)}>{countLabel}</span>
|
|
242
300
|
</div>
|
|
243
301
|
</div>
|
|
244
302
|
<div className="overflow-y-auto flex-1 min-h-0">
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
303
|
+
{isLoading ? (
|
|
304
|
+
<ProblemsPanelState
|
|
305
|
+
icon={<Loader2 className="h-5 w-5 animate-spin text-theme-text-tertiary" />}
|
|
306
|
+
title="Loading issues"
|
|
307
|
+
body="Checking live cluster issues for the selected scope."
|
|
308
|
+
/>
|
|
309
|
+
) : hasHardError ? (
|
|
310
|
+
<ProblemsPanelState
|
|
311
|
+
icon={<AlertTriangle className="h-5 w-5 text-amber-500" />}
|
|
312
|
+
title="Issues unavailable"
|
|
313
|
+
body={formatIssueError(error)}
|
|
314
|
+
/>
|
|
315
|
+
) : (
|
|
316
|
+
<>
|
|
317
|
+
{!!error && (
|
|
318
|
+
<ProblemsPanelNotice tone="warning">
|
|
319
|
+
Issue refresh failed. Showing the last successful result.
|
|
320
|
+
</ProblemsPanelNotice>
|
|
321
|
+
)}
|
|
322
|
+
{visibility?.impact && (
|
|
323
|
+
<ProblemsPanelNotice tone="warning">
|
|
324
|
+
Limited visibility - {visibility.impact} Results may be incomplete.
|
|
325
|
+
</ProblemsPanelNotice>
|
|
326
|
+
)}
|
|
327
|
+
{isTruncated && (
|
|
328
|
+
<ProblemsPanelNotice tone="neutral">
|
|
329
|
+
Showing {totalReturned} of {issueCount} issues. Narrow by namespace to see the rest.
|
|
330
|
+
</ProblemsPanelNotice>
|
|
331
|
+
)}
|
|
332
|
+
|
|
333
|
+
{issues.length === 0 ? (
|
|
334
|
+
<ProblemsPanelState
|
|
335
|
+
icon={
|
|
336
|
+
hasLimitedVisibility
|
|
337
|
+
? <AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
338
|
+
: <CheckCircle className="h-5 w-5 text-green-500" />
|
|
339
|
+
}
|
|
340
|
+
title={hasLimitedVisibility ? 'No visible active issues' : 'No active issues'}
|
|
341
|
+
body={
|
|
342
|
+
hasLimitedVisibility
|
|
343
|
+
? 'Radar could not read every workload source in this scope.'
|
|
344
|
+
: 'No critical or warning issues across the selected scope.'
|
|
345
|
+
}
|
|
346
|
+
/>
|
|
347
|
+
) : (
|
|
348
|
+
<div className="divide-y divide-theme-border">
|
|
349
|
+
{issues.map((issue) => {
|
|
350
|
+
const ref = subjectRef(issue)
|
|
351
|
+
const age = issue.first_seen
|
|
352
|
+
? issue.issue_timing === 'started_at_resource_creation'
|
|
353
|
+
? 'since deploy'
|
|
354
|
+
: formatCompactAge(issue.first_seen)
|
|
355
|
+
: ''
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<button
|
|
359
|
+
key={issue.id}
|
|
360
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
|
|
361
|
+
onClick={() => onResourceClick({
|
|
362
|
+
kind: ref.kind,
|
|
363
|
+
namespace: ref.namespace ?? '',
|
|
364
|
+
name: ref.name,
|
|
365
|
+
group: ref.group ?? '',
|
|
366
|
+
})}
|
|
367
|
+
>
|
|
368
|
+
<StatusDot tone={issue.severity === 'critical' ? 'unhealthy' : 'degraded'} className="shrink-0" />
|
|
369
|
+
<div className="min-w-0 flex-1">
|
|
370
|
+
<div className="flex items-center gap-1.5">
|
|
371
|
+
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
|
|
372
|
+
<span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
|
|
373
|
+
{age && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
|
|
374
|
+
</div>
|
|
375
|
+
<div className="flex items-center gap-1.5 mt-0.5">
|
|
376
|
+
<span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
|
|
377
|
+
<span className="text-[10px] text-theme-text-tertiary shrink-0">{groupLabel(issue.category_group)}</span>
|
|
378
|
+
{issue.namespace && <span className="text-[10px] text-theme-text-tertiary shrink-0">{issue.namespace}</span>}
|
|
379
|
+
</div>
|
|
380
|
+
{(issue.reason || issue.message) && (
|
|
381
|
+
<div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">
|
|
382
|
+
{issue.reason}
|
|
383
|
+
{issue.reason && issue.message ? ' - ' : ''}
|
|
384
|
+
{issue.message}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
</button>
|
|
389
|
+
)
|
|
390
|
+
})}
|
|
271
391
|
</div>
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
392
|
+
)}
|
|
393
|
+
</>
|
|
394
|
+
)}
|
|
275
395
|
</div>
|
|
276
396
|
</div>
|
|
277
397
|
)
|
|
278
398
|
}
|
|
399
|
+
|
|
400
|
+
function ProblemsPanelState({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
|
|
401
|
+
return (
|
|
402
|
+
<div className="flex min-h-[220px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
|
|
403
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-theme-elevated">
|
|
404
|
+
{icon}
|
|
405
|
+
</div>
|
|
406
|
+
<p className="text-sm font-medium text-theme-text-primary">{title}</p>
|
|
407
|
+
<p className="max-w-[280px] text-xs leading-5 text-theme-text-secondary">{body}</p>
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function ProblemsPanelNotice({ tone, children }: { tone: 'warning' | 'neutral'; children: ReactNode }) {
|
|
413
|
+
return (
|
|
414
|
+
<div className="px-3 pt-3">
|
|
415
|
+
<div
|
|
416
|
+
className={clsx(
|
|
417
|
+
'rounded-lg border px-3 py-2 text-xs leading-5',
|
|
418
|
+
tone === 'warning'
|
|
419
|
+
? 'border-amber-500/20 bg-amber-500/10 text-theme-text-secondary'
|
|
420
|
+
: 'border-theme-border bg-theme-elevated text-theme-text-secondary',
|
|
421
|
+
)}
|
|
422
|
+
>
|
|
423
|
+
{children}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function formatIssueError(error: unknown): string {
|
|
430
|
+
return error instanceof Error && error.message
|
|
431
|
+
? error.message
|
|
432
|
+
: 'Failed to load active issues.'
|
|
433
|
+
}
|
|
@@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
|
|
|
23
23
|
interface ResourceCountsResponse {
|
|
24
24
|
counts: Record<string, number>
|
|
25
25
|
forbidden?: string[]
|
|
26
|
+
unavailable?: string[]
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface ResourcesViewProps {
|
|
@@ -36,6 +37,14 @@ interface ResourcesViewProps {
|
|
|
36
37
|
|
|
37
38
|
type SelectedKindInfo = { name: string; kind: string; group: string } | null
|
|
38
39
|
|
|
40
|
+
const LARGE_RESOURCE_LIST_LIMIT = 25000
|
|
41
|
+
const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
|
|
42
|
+
'Pod',
|
|
43
|
+
'Event',
|
|
44
|
+
'apps/ReplicaSet',
|
|
45
|
+
'discovery.k8s.io/EndpointSlice',
|
|
46
|
+
])
|
|
47
|
+
|
|
39
48
|
const deniedWorkloadWrites: WorkloadWritePermissions = {
|
|
40
49
|
deployments: false,
|
|
41
50
|
daemonSets: false,
|
|
@@ -43,6 +52,10 @@ const deniedWorkloadWrites: WorkloadWritePermissions = {
|
|
|
43
52
|
rollouts: false,
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
|
|
56
|
+
return kind.group ? `${kind.group}/${kind.kind}` : kind.kind
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
|
|
47
60
|
const location = useLocation()
|
|
48
61
|
const navigate = useNavigate()
|
|
@@ -93,7 +106,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
93
106
|
|
|
94
107
|
// Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
|
|
95
108
|
const namespacesParam = namespaces.join(',')
|
|
96
|
-
const { data: countsData } = useQuery({
|
|
109
|
+
const { data: countsData, isError: countsIsError } = useQuery({
|
|
97
110
|
queryKey: ['resource-counts', namespacesParam],
|
|
98
111
|
queryFn: async () => {
|
|
99
112
|
const params = new URLSearchParams()
|
|
@@ -123,6 +136,29 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
123
136
|
return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
|
|
124
137
|
}, [selectedKind, apiResources])
|
|
125
138
|
|
|
139
|
+
const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
|
|
140
|
+
const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
|
|
141
|
+
const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
|
|
142
|
+
const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
|
|
143
|
+
const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
|
|
144
|
+
const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT)
|
|
145
|
+
const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
|
|
146
|
+
const podCount = countsData?.counts.Pod
|
|
147
|
+
const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
|
|
148
|
+
const podCountAllowsBulkMetrics = countsData != null && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
|
|
149
|
+
const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
|
|
150
|
+
const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
|
|
151
|
+
const topNodeMetricsEnabled = selectedKindName === 'nodes' && namespaces.length === 0 && podCountAllowsBulkMetrics
|
|
152
|
+
const largeListGuard = selectedKind && largeListBlocked
|
|
153
|
+
? {
|
|
154
|
+
kind: selectedKind.name,
|
|
155
|
+
count: selectedCountUnavailable ? undefined : selectedCount,
|
|
156
|
+
reason: selectedCountUnavailable ? 'count-unavailable' as const : 'too-many' as const,
|
|
157
|
+
limit: LARGE_RESOURCE_LIST_LIMIT,
|
|
158
|
+
namespaces,
|
|
159
|
+
}
|
|
160
|
+
: null
|
|
161
|
+
|
|
126
162
|
// Fetch full data only for the selected kind
|
|
127
163
|
const selectedKindQuery = useQuery({
|
|
128
164
|
queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
|
|
@@ -156,7 +192,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
156
192
|
}
|
|
157
193
|
return res.json()
|
|
158
194
|
},
|
|
159
|
-
enabled: !!selectedKind,
|
|
195
|
+
enabled: !!selectedKind && !selectedKindQueryBlocked,
|
|
160
196
|
staleTime: 30000,
|
|
161
197
|
refetchInterval: 120000, // Safety net — SSE k8s_event drives near-real-time invalidation
|
|
162
198
|
retry: (failureCount: number, error: Error) => {
|
|
@@ -169,17 +205,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
169
205
|
const selectedKindQueryResult: ResourceQueryResult | undefined = useMemo(() => {
|
|
170
206
|
if (!selectedKind) return undefined
|
|
171
207
|
return {
|
|
172
|
-
data: selectedKindQuery.data as any[] | undefined,
|
|
173
|
-
isLoading: selectedKindQuery.isLoading,
|
|
174
|
-
error: selectedKindQuery.error,
|
|
208
|
+
data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
|
|
209
|
+
isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
|
|
210
|
+
error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
|
|
175
211
|
refetch: selectedKindQuery.refetch,
|
|
176
212
|
dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
|
|
177
213
|
}
|
|
178
|
-
}, [selectedKind, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
|
|
214
|
+
}, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
|
|
179
215
|
|
|
180
216
|
// Metrics
|
|
181
|
-
const { data: topPodMetrics } = useTopPodMetrics()
|
|
182
|
-
const { data: topNodeMetrics } = useTopNodeMetrics()
|
|
217
|
+
const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
|
|
218
|
+
const { data: topNodeMetrics } = useTopNodeMetrics({ enabled: topNodeMetricsEnabled })
|
|
183
219
|
|
|
184
220
|
// Certificate expiry
|
|
185
221
|
const { data: certExpiry, isError: certExpiryError } = useSecretCertExpiry()
|
|
@@ -248,7 +284,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
248
284
|
// Lightweight counts for sidebar (replaces 233 parallel queries)
|
|
249
285
|
resourceCounts={countsData?.counts}
|
|
250
286
|
resourceForbidden={countsData?.forbidden}
|
|
287
|
+
resourceUnavailable={countsData?.unavailable}
|
|
251
288
|
selectedKindQuery={selectedKindQueryResult}
|
|
289
|
+
largeListGuard={largeListGuard}
|
|
252
290
|
onSelectedKindChange={setSelectedKind}
|
|
253
291
|
topPodMetrics={topPodMetrics}
|
|
254
292
|
topNodeMetrics={topNodeMetrics}
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
import { createContext, useContext } from 'react';
|
|
14
14
|
import type { ReactNode } from 'react';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Per-cluster destinations an embedded host can take over with its own
|
|
18
|
+
* fleet-scoped pages. See `fleetTakeoverHref`. 'issues' | 'gitops' | 'checks'
|
|
19
|
+
* are also Radar view names (so route entry redirects too); 'certs' is
|
|
20
|
+
* card-only (Radar has no certs view).
|
|
21
|
+
*/
|
|
22
|
+
export type FleetTakeoverTarget = 'issues' | 'gitops' | 'checks' | 'certs';
|
|
23
|
+
|
|
16
24
|
interface NavCustomizationBase {
|
|
17
25
|
/** Replaces Radar's Skyhook/radar logo + wordmark. */
|
|
18
26
|
brandSlot?: ReactNode;
|
|
@@ -32,15 +40,32 @@ interface NavCustomizationBase {
|
|
|
32
40
|
group?: string;
|
|
33
41
|
}) => string;
|
|
34
42
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
43
|
+
* Lets an embedded host (e.g. Radar Cloud) take over selected per-cluster
|
|
44
|
+
* destinations with its OWN fleet pages scoped to this cluster, instead of
|
|
45
|
+
* Radar rendering them inline. Given a semantic target the host returns the
|
|
46
|
+
* URL to navigate to, or `undefined`/omits the hook to let Radar render its
|
|
47
|
+
* own view as usual (standalone OSS does the latter for everything).
|
|
48
|
+
*
|
|
49
|
+
* This is how the Home dashboard's "fleet-shaped" cards reach the host's
|
|
50
|
+
* canonical surfaces rather than a second, diverging per-cluster copy:
|
|
51
|
+
* - 'issues' → the Active Issues panel + cluster-health issues count
|
|
52
|
+
* - 'gitops' → the GitOps controllers card
|
|
53
|
+
* - 'checks' → the Cluster Audit card (and any route to /audit; legacy
|
|
54
|
+
* `clusterChecksHref` folded in here)
|
|
55
|
+
* - 'certs' → the Certificate Health card
|
|
56
|
+
*
|
|
57
|
+
* View-shaped targets (issues / gitops / checks) are also honored for any
|
|
58
|
+
* entry into that view — ⌘K, bookmarks, deep links — via a redirect effect
|
|
59
|
+
* in App.tsx, using window.location.replace so the transient /<view> URL
|
|
60
|
+
* stays out of history. 'certs' has no Radar view, so only the card consults
|
|
61
|
+
* it (window.location.assign — a real forward navigation the user initiated).
|
|
62
|
+
*/
|
|
63
|
+
fleetTakeoverHref?: (target: FleetTakeoverTarget) => string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* @deprecated Superseded by `fleetTakeoverHref('checks')`. Kept so consumers
|
|
66
|
+
* still on the pre-1.7 hook keep working (App.tsx folds it into the 'checks'
|
|
67
|
+
* target) — this makes adding `fleetTakeoverHref` an additive, non-breaking
|
|
68
|
+
* change. Remove in a major release once all consumers have migrated.
|
|
44
69
|
*/
|
|
45
70
|
clusterChecksHref?: () => string;
|
|
46
71
|
/**
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ export {
|
|
|
14
14
|
getAuthHeaders,
|
|
15
15
|
getCredentialsMode,
|
|
16
16
|
} from './api/config';
|
|
17
|
-
export type { NavCustomization } from './context/NavCustomization';
|
|
17
|
+
export type { NavCustomization, FleetTakeoverTarget } from './context/NavCustomization';
|
|
18
18
|
export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
|
|
19
19
|
|
|
20
20
|
// Shared cluster-switcher primitive — re-exported from @skyhook-io/k8s-ui so
|
package/src/main.tsx
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
|
-
import './monaco-setup'
|
|
3
|
+
import { configureBundledMonaco } from './monaco-setup'
|
|
4
4
|
import { RadarApp } from './RadarApp'
|
|
5
5
|
import { openExternal } from './utils/navigation'
|
|
6
6
|
import './index.css'
|
|
7
7
|
|
|
8
|
+
// Keep this as an explicit call: side-effect-only imports of the Monaco setup can
|
|
9
|
+
// be dropped by production tree-shaking, which makes offline desktop builds fall
|
|
10
|
+
// back to Monaco's CDN loader.
|
|
11
|
+
configureBundledMonaco()
|
|
12
|
+
|
|
8
13
|
// Intercept external link clicks in the Wails desktop app.
|
|
9
14
|
// <a target="_blank"> is swallowed by WKWebView/WebView2 — route through openExternal()
|
|
10
15
|
// which calls the backend /api/desktop/open-url endpoint to open in the system browser.
|
package/src/monaco-setup.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// over the network, so the YAML editor never loads in airgapped / offline
|
|
4
4
|
// deployments. Bundling makes the binary fully self-contained.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Called from main.tsx (Radar's binary entry) only — library consumers
|
|
7
|
+
// (e.g. Radar Hub) keep the default CDN loader unless they opt in.
|
|
8
8
|
//
|
|
9
9
|
// Import the editor API + YAML grammar directly rather than the `monaco-editor`
|
|
10
10
|
// barrel: the barrel pulls in the JSON/CSS/HTML/TypeScript language services,
|
|
@@ -15,12 +15,19 @@ import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
|
15
15
|
import { loader } from '@monaco-editor/react'
|
|
16
16
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
}
|
|
18
|
+
let configured = false
|
|
19
|
+
|
|
20
|
+
export function configureBundledMonaco() {
|
|
21
|
+
if (configured) return
|
|
22
|
+
configured = true
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
// YAML has no dedicated Monaco language worker — the base editor worker covers
|
|
25
|
+
// everything we use, so route every label to it.
|
|
26
|
+
;(globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker(): Worker } }).MonacoEnvironment = {
|
|
27
|
+
getWorker() {
|
|
28
|
+
return new EditorWorker()
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
loader.config({ monaco })
|
|
33
|
+
}
|