@skyhook-io/radar-app 1.6.0 → 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 +19 -22
- package/src/api/client.ts +29 -16
- package/src/components/gitops/GitOpsView.tsx +13 -6
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/RoleGatedPanel.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +12 -10
- package/src/components/home/HomeView.tsx +204 -56
- package/src/components/resources/ResourceDetailDrawer.tsx +3 -0
- package/src/components/resources/ResourcesView.tsx +46 -8
- package/src/components/timeline/TimelineList.tsx +6 -1
- package/src/components/timeline/TimelineView.tsx +9 -0
- package/src/components/ui/Omnibar.tsx +225 -115
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +6 -1
- package/src/monaco-setup.ts +17 -10
|
@@ -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
|
+
}
|
|
@@ -18,6 +18,9 @@ interface ResourceDetailDrawerProps {
|
|
|
18
18
|
onExpand?: (resource: SelectedResource) => void
|
|
19
19
|
/** Navigate to another resource within expanded WorkloadView */
|
|
20
20
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
21
|
+
/** Top offset for the drawer (px). Defaults to Radar's 49px header height;
|
|
22
|
+
* pass 0 in chromeless embeds where there's no Radar header above it. */
|
|
23
|
+
headerHeight?: number
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
|
|
@@ -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}
|
|
@@ -15,9 +15,11 @@ interface TimelineListProps {
|
|
|
15
15
|
onResourceClick?: NavigateToResource
|
|
16
16
|
initialFilter?: ActivityTypeFilter
|
|
17
17
|
initialTimeRange?: TimeRange
|
|
18
|
+
showDeleted: boolean
|
|
19
|
+
onShowDeletedChange: (showDeleted: boolean) => void
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
|
|
22
|
+
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange }: TimelineListProps) {
|
|
21
23
|
const hasLimitedAccess = useHasLimitedAccess()
|
|
22
24
|
const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kind?: string }>({
|
|
23
25
|
timeRange: initialTimeRange ?? '1h',
|
|
@@ -32,6 +34,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
32
34
|
kind: queryParams.kind,
|
|
33
35
|
timeRange: queryParams.timeRange,
|
|
34
36
|
includeK8sEvents: true,
|
|
37
|
+
includeDeleted: showDeleted,
|
|
35
38
|
limit: 500,
|
|
36
39
|
})
|
|
37
40
|
|
|
@@ -64,6 +67,8 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
64
67
|
onResourceClick={onResourceClick}
|
|
65
68
|
initialFilter={initialFilter}
|
|
66
69
|
initialTimeRange={initialTimeRange}
|
|
70
|
+
showDeleted={showDeleted}
|
|
71
|
+
onShowDeletedChange={onShowDeletedChange}
|
|
67
72
|
/>
|
|
68
73
|
)
|
|
69
74
|
}
|
|
@@ -42,6 +42,10 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
42
42
|
// Force list view on large clusters without namespace filter
|
|
43
43
|
const effectiveInitialMode = requiresNamespaceFilter ? 'list' : (initialViewMode ?? 'swimlane')
|
|
44
44
|
const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
|
|
45
|
+
// Shared across list + swimlane so the toggle carries across the view switch,
|
|
46
|
+
// and so the swimlane fetch can exclude deletes server-side (before LIMIT)
|
|
47
|
+
// rather than only hiding them client-side after the 10k cap.
|
|
48
|
+
const [showDeleted, setShowDeleted] = useState(true)
|
|
45
49
|
|
|
46
50
|
// Only fetch heavy swimlane data when actually showing swimlanes
|
|
47
51
|
const showSwimlanes = viewMode === 'swimlane' && !requiresNamespaceFilter
|
|
@@ -53,6 +57,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
53
57
|
timeRange: 'all',
|
|
54
58
|
includeK8sEvents: true,
|
|
55
59
|
includeManaged: true,
|
|
60
|
+
includeDeleted: showDeleted,
|
|
56
61
|
limit: 10000,
|
|
57
62
|
enabled: showSwimlanes,
|
|
58
63
|
})
|
|
@@ -119,6 +124,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
119
124
|
onViewModeChange={setViewMode}
|
|
120
125
|
topology={stableTopology}
|
|
121
126
|
namespaces={namespaces}
|
|
127
|
+
showDeleted={showDeleted}
|
|
128
|
+
onShowDeletedChange={setShowDeleted}
|
|
122
129
|
/>
|
|
123
130
|
)
|
|
124
131
|
}
|
|
@@ -131,6 +138,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
131
138
|
onResourceClick={onResourceClick}
|
|
132
139
|
initialFilter={initialFilter}
|
|
133
140
|
initialTimeRange={initialTimeRange}
|
|
141
|
+
showDeleted={showDeleted}
|
|
142
|
+
onShowDeletedChange={setShowDeleted}
|
|
134
143
|
/>
|
|
135
144
|
)
|
|
136
145
|
}
|