@skyhook-io/radar-app 1.6.1 → 1.6.2
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 +9 -20
- package/src/api/client.ts +19 -13
- package/src/components/gitops/GitOpsView.tsx +13 -6
- package/src/components/home/ClusterHealthCard.tsx +12 -10
- package/src/components/home/HomeView.tsx +204 -56
- package/src/components/resources/ResourcesView.tsx +46 -8
- 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.6.
|
|
3
|
+
"version": "1.6.2",
|
|
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
|
@@ -542,9 +542,11 @@ function AppInner() {
|
|
|
542
542
|
description: 'Show keyboard shortcuts',
|
|
543
543
|
category: 'General' as const,
|
|
544
544
|
scope: 'global' as const,
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
545
|
+
// Radar owns the shortcut registry even in a chromeless embed, so its `?`
|
|
546
|
+
// overlay is the one that actually lists the working shortcuts. The host
|
|
547
|
+
// (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
|
|
548
|
+
// it has no registry of its own to populate a competing overlay with.
|
|
549
|
+
handler: () => setShowHelp(prev => !prev),
|
|
548
550
|
},
|
|
549
551
|
{
|
|
550
552
|
id: 'command-palette',
|
|
@@ -1608,21 +1610,7 @@ function AppInner() {
|
|
|
1608
1610
|
console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
|
|
1609
1611
|
navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
|
|
1610
1612
|
}}
|
|
1611
|
-
onNavigateToResource={
|
|
1612
|
-
// Switch to resources view and open the resource detail drawer
|
|
1613
|
-
setSelectedResource(resource)
|
|
1614
|
-
const newParams = new URLSearchParams(searchParams)
|
|
1615
|
-
newParams.delete('kind') // kind is now in the path
|
|
1616
|
-
newParams.delete('mode')
|
|
1617
|
-
newParams.delete('group')
|
|
1618
|
-
newParams.delete('resource')
|
|
1619
|
-
if (resource.group) {
|
|
1620
|
-
newParams.set('apiGroup', resource.group)
|
|
1621
|
-
} else {
|
|
1622
|
-
newParams.delete('apiGroup')
|
|
1623
|
-
}
|
|
1624
|
-
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1625
|
-
}}
|
|
1613
|
+
onNavigateToResource={navigateFromIssue}
|
|
1626
1614
|
/>
|
|
1627
1615
|
)}
|
|
1628
1616
|
|
|
@@ -1972,8 +1960,9 @@ function AppInner() {
|
|
|
1972
1960
|
/>
|
|
1973
1961
|
<MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
|
|
1974
1962
|
|
|
1975
|
-
{/* Debug overlay
|
|
1976
|
-
|
|
1963
|
+
{/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
|
|
1964
|
+
own their own dev tooling; ours would collide with theirs bottom-right. */}
|
|
1965
|
+
{import.meta.env.DEV && showNavRail && <DebugOverlay />}
|
|
1977
1966
|
</div>
|
|
1978
1967
|
</div>
|
|
1979
1968
|
</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
|
})
|
|
@@ -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
|
-
|
|
@@ -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 {
|
|
@@ -26,6 +33,10 @@ interface HomeViewProps {
|
|
|
26
33
|
|
|
27
34
|
export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
|
|
28
35
|
const { data, isLoading, error } = useDashboard(namespaces)
|
|
36
|
+
const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
|
|
37
|
+
const issues = issuesData?.issues ?? []
|
|
38
|
+
const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
|
|
39
|
+
const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
|
|
29
40
|
|
|
30
41
|
// SSE is cluster-wide on small/medium clusters; the picker only narrows the
|
|
31
42
|
// dashboard summary, so re-apply the filter here or the legend disagrees.
|
|
@@ -73,8 +84,6 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
73
84
|
)
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
const hasProblems = data.problems && data.problems.length > 0
|
|
77
|
-
|
|
78
87
|
const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
|
|
79
88
|
|
|
80
89
|
return (
|
|
@@ -98,19 +107,17 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
98
107
|
metrics={data.metrics}
|
|
99
108
|
metricsServerAvailable={data.metricsServerAvailable}
|
|
100
109
|
topCRDs={crdsData?.topCRDs}
|
|
101
|
-
|
|
110
|
+
issueCount={issueCount}
|
|
111
|
+
hasCriticalIssues={hasCriticalIssues}
|
|
102
112
|
nodeVersionSkew={data.nodeVersionSkew}
|
|
103
113
|
onNavigateToKind={onNavigateToResourceKind}
|
|
104
114
|
onNavigateToView={() => onNavigateToView('resources')}
|
|
105
115
|
onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
|
|
106
|
-
|
|
116
|
+
onIssuesClick={() => onNavigateToView('issues')}
|
|
107
117
|
/>
|
|
108
118
|
|
|
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
|
-
)}>
|
|
119
|
+
{/* Row 2: Main content columns - teasers left, issues right */}
|
|
120
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px]">
|
|
114
121
|
{/* Left column: teaser cards */}
|
|
115
122
|
<div className="flex flex-col gap-6 auto-rows-min">
|
|
116
123
|
{/* Live band — Topology + Timeline always render, so a fixed 2-up never strands.
|
|
@@ -190,14 +197,18 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
190
197
|
)}
|
|
191
198
|
</div>
|
|
192
199
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
<ProblemsPanel
|
|
201
|
+
issues={issues}
|
|
202
|
+
issueCount={issueCount}
|
|
203
|
+
visibility={issuesData?.visibility}
|
|
204
|
+
hasData={!!issuesData}
|
|
205
|
+
isLoading={issuesLoading && !issuesData}
|
|
206
|
+
isRefreshing={issuesFetching && !!issuesData}
|
|
207
|
+
error={issuesError}
|
|
208
|
+
totalReturned={issues.length}
|
|
209
|
+
onNavigateToIssues={() => onNavigateToView('issues')}
|
|
210
|
+
onResourceClick={onNavigateToResource}
|
|
211
|
+
/>
|
|
201
212
|
</div>
|
|
202
213
|
</div>
|
|
203
214
|
</div>
|
|
@@ -216,19 +227,56 @@ function BandItem({ children }: { children: ReactNode }) {
|
|
|
216
227
|
// ============================================================================
|
|
217
228
|
|
|
218
229
|
interface ProblemsPanelProps {
|
|
219
|
-
|
|
230
|
+
issues: Issue[]
|
|
231
|
+
issueCount: number
|
|
232
|
+
visibility?: IssuesResponse['visibility']
|
|
233
|
+
hasData: boolean
|
|
234
|
+
isLoading: boolean
|
|
235
|
+
isRefreshing: boolean
|
|
236
|
+
error: unknown
|
|
237
|
+
totalReturned: number
|
|
220
238
|
onNavigateToIssues: () => void
|
|
221
239
|
onResourceClick: (resource: SelectedResource) => void
|
|
222
240
|
}
|
|
223
241
|
|
|
224
242
|
|
|
225
|
-
function ProblemsPanel({
|
|
243
|
+
function ProblemsPanel({
|
|
244
|
+
issues,
|
|
245
|
+
issueCount,
|
|
246
|
+
visibility,
|
|
247
|
+
hasData,
|
|
248
|
+
isLoading,
|
|
249
|
+
isRefreshing,
|
|
250
|
+
error,
|
|
251
|
+
totalReturned,
|
|
252
|
+
onNavigateToIssues,
|
|
253
|
+
onResourceClick,
|
|
254
|
+
}: ProblemsPanelProps) {
|
|
255
|
+
const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
|
|
256
|
+
const hasIssues = issueCount > 0
|
|
257
|
+
const hasHardError = !!error && !hasData
|
|
258
|
+
const hasLimitedVisibility = !!visibility?.impact
|
|
259
|
+
const isTruncated = issueCount > totalReturned
|
|
260
|
+
const titleClass = hasCriticalIssues
|
|
261
|
+
? 'text-red-500'
|
|
262
|
+
: hasIssues || hasHardError || hasLimitedVisibility
|
|
263
|
+
? 'text-amber-500'
|
|
264
|
+
: 'text-theme-text-secondary'
|
|
265
|
+
const countClass = hasHardError
|
|
266
|
+
? 'status-unknown'
|
|
267
|
+
: hasCriticalIssues
|
|
268
|
+
? 'status-unhealthy'
|
|
269
|
+
: hasIssues || hasLimitedVisibility
|
|
270
|
+
? 'status-degraded'
|
|
271
|
+
: 'status-healthy'
|
|
272
|
+
const countLabel = isLoading ? '...' : hasHardError ? 'error' : String(issueCount)
|
|
273
|
+
|
|
226
274
|
return (
|
|
227
275
|
<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
276
|
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
|
|
229
277
|
<div className="flex items-center gap-2">
|
|
230
|
-
<AlertTriangle className=
|
|
231
|
-
<span className=
|
|
278
|
+
<AlertTriangle className={clsx('w-4 h-4', titleClass)} />
|
|
279
|
+
<span className={clsx('text-xs font-semibold uppercase tracking-wider', titleClass)}>Active Issues</span>
|
|
232
280
|
</div>
|
|
233
281
|
<div className="flex items-center gap-2">
|
|
234
282
|
<button
|
|
@@ -238,41 +286,141 @@ function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: Proble
|
|
|
238
286
|
>
|
|
239
287
|
View all
|
|
240
288
|
</button>
|
|
241
|
-
|
|
289
|
+
{isRefreshing && !isLoading && (
|
|
290
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary" aria-label="Refreshing issues" />
|
|
291
|
+
)}
|
|
292
|
+
<span className={clsx('badge rounded-full', countClass)}>{countLabel}</span>
|
|
242
293
|
</div>
|
|
243
294
|
</div>
|
|
244
295
|
<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
|
-
|
|
296
|
+
{isLoading ? (
|
|
297
|
+
<ProblemsPanelState
|
|
298
|
+
icon={<Loader2 className="h-5 w-5 animate-spin text-theme-text-tertiary" />}
|
|
299
|
+
title="Loading issues"
|
|
300
|
+
body="Checking live cluster issues for the selected scope."
|
|
301
|
+
/>
|
|
302
|
+
) : hasHardError ? (
|
|
303
|
+
<ProblemsPanelState
|
|
304
|
+
icon={<AlertTriangle className="h-5 w-5 text-amber-500" />}
|
|
305
|
+
title="Issues unavailable"
|
|
306
|
+
body={formatIssueError(error)}
|
|
307
|
+
/>
|
|
308
|
+
) : (
|
|
309
|
+
<>
|
|
310
|
+
{!!error && (
|
|
311
|
+
<ProblemsPanelNotice tone="warning">
|
|
312
|
+
Issue refresh failed. Showing the last successful result.
|
|
313
|
+
</ProblemsPanelNotice>
|
|
314
|
+
)}
|
|
315
|
+
{visibility?.impact && (
|
|
316
|
+
<ProblemsPanelNotice tone="warning">
|
|
317
|
+
Limited visibility - {visibility.impact} Results may be incomplete.
|
|
318
|
+
</ProblemsPanelNotice>
|
|
319
|
+
)}
|
|
320
|
+
{isTruncated && (
|
|
321
|
+
<ProblemsPanelNotice tone="neutral">
|
|
322
|
+
Showing {totalReturned} of {issueCount} issues. Narrow by namespace to see the rest.
|
|
323
|
+
</ProblemsPanelNotice>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{issues.length === 0 ? (
|
|
327
|
+
<ProblemsPanelState
|
|
328
|
+
icon={
|
|
329
|
+
hasLimitedVisibility
|
|
330
|
+
? <AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
331
|
+
: <CheckCircle className="h-5 w-5 text-green-500" />
|
|
332
|
+
}
|
|
333
|
+
title={hasLimitedVisibility ? 'No visible active issues' : 'No active issues'}
|
|
334
|
+
body={
|
|
335
|
+
hasLimitedVisibility
|
|
336
|
+
? 'Radar could not read every workload source in this scope.'
|
|
337
|
+
: 'No critical or warning issues across the selected scope.'
|
|
338
|
+
}
|
|
339
|
+
/>
|
|
340
|
+
) : (
|
|
341
|
+
<div className="divide-y divide-theme-border">
|
|
342
|
+
{issues.map((issue) => {
|
|
343
|
+
const ref = subjectRef(issue)
|
|
344
|
+
const age = issue.first_seen
|
|
345
|
+
? issue.issue_timing === 'started_at_resource_creation'
|
|
346
|
+
? 'since deploy'
|
|
347
|
+
: formatCompactAge(issue.first_seen)
|
|
348
|
+
: ''
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<button
|
|
352
|
+
key={issue.id}
|
|
353
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
|
|
354
|
+
onClick={() => onResourceClick({
|
|
355
|
+
kind: ref.kind,
|
|
356
|
+
namespace: ref.namespace ?? '',
|
|
357
|
+
name: ref.name,
|
|
358
|
+
group: ref.group ?? '',
|
|
359
|
+
})}
|
|
360
|
+
>
|
|
361
|
+
<StatusDot tone={issue.severity === 'critical' ? 'unhealthy' : 'degraded'} className="shrink-0" />
|
|
362
|
+
<div className="min-w-0 flex-1">
|
|
363
|
+
<div className="flex items-center gap-1.5">
|
|
364
|
+
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
|
|
365
|
+
<span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
|
|
366
|
+
{age && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
|
|
367
|
+
</div>
|
|
368
|
+
<div className="flex items-center gap-1.5 mt-0.5">
|
|
369
|
+
<span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
|
|
370
|
+
<span className="text-[10px] text-theme-text-tertiary shrink-0">{groupLabel(issue.category_group)}</span>
|
|
371
|
+
{issue.namespace && <span className="text-[10px] text-theme-text-tertiary shrink-0">{issue.namespace}</span>}
|
|
372
|
+
</div>
|
|
373
|
+
{(issue.reason || issue.message) && (
|
|
374
|
+
<div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">
|
|
375
|
+
{issue.reason}
|
|
376
|
+
{issue.reason && issue.message ? ' - ' : ''}
|
|
377
|
+
{issue.message}
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
</button>
|
|
382
|
+
)
|
|
383
|
+
})}
|
|
271
384
|
</div>
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
385
|
+
)}
|
|
386
|
+
</>
|
|
387
|
+
)}
|
|
275
388
|
</div>
|
|
276
389
|
</div>
|
|
277
390
|
)
|
|
278
391
|
}
|
|
392
|
+
|
|
393
|
+
function ProblemsPanelState({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
|
|
394
|
+
return (
|
|
395
|
+
<div className="flex min-h-[220px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
|
|
396
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-theme-elevated">
|
|
397
|
+
{icon}
|
|
398
|
+
</div>
|
|
399
|
+
<p className="text-sm font-medium text-theme-text-primary">{title}</p>
|
|
400
|
+
<p className="max-w-[280px] text-xs leading-5 text-theme-text-secondary">{body}</p>
|
|
401
|
+
</div>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function ProblemsPanelNotice({ tone, children }: { tone: 'warning' | 'neutral'; children: ReactNode }) {
|
|
406
|
+
return (
|
|
407
|
+
<div className="px-3 pt-3">
|
|
408
|
+
<div
|
|
409
|
+
className={clsx(
|
|
410
|
+
'rounded-lg border px-3 py-2 text-xs leading-5',
|
|
411
|
+
tone === 'warning'
|
|
412
|
+
? 'border-amber-500/20 bg-amber-500/10 text-theme-text-secondary'
|
|
413
|
+
: 'border-theme-border bg-theme-elevated text-theme-text-secondary',
|
|
414
|
+
)}
|
|
415
|
+
>
|
|
416
|
+
{children}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function formatIssueError(error: unknown): string {
|
|
423
|
+
return error instanceof Error && error.message
|
|
424
|
+
? error.message
|
|
425
|
+
: 'Failed to load active issues.'
|
|
426
|
+
}
|
|
@@ -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}
|
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
|
+
}
|