@skyhook-io/k8s-ui 1.8.0 → 1.8.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 +1 -1
- package/src/components/applications/AppTooltips.tsx +35 -8
- package/src/components/applications/ApplicationsList.tsx +17 -570
- package/src/components/applications/ApplicationsView.tsx +602 -0
- package/src/components/applications/applications-view.test.tsx +147 -0
- package/src/components/applications/index.ts +4 -0
- package/src/components/checks/ChecksView.tsx +2 -2
- package/src/components/dock/LocalTerminalTab.tsx +22 -1
- package/src/components/dock/TerminalClipboardToolbar.tsx +35 -0
- package/src/components/dock/TerminalTab.tsx +22 -1
- package/src/components/dock/terminalClipboard.test.ts +221 -0
- package/src/components/dock/terminalClipboard.ts +122 -0
- package/src/components/dock/useMultilinePasteConfirm.tsx +75 -0
- package/src/components/gitops/GitOpsTableView.tsx +31 -8
- package/src/components/gitops/detail-helpers.test.ts +1 -1
- package/src/components/resources/ResourcesSidebar.tsx +16 -13
- package/src/components/resources/ResourcesView.tsx +50 -4
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/resource-utils.ts +4 -0
- package/src/components/timeline/TimelineList.tsx +31 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +27 -5
- package/src/components/ui/SearchPillInput.tsx +45 -4
- package/src/hooks/useKeyboardShortcuts.tsx +2 -1
- package/src/utils/applications.test.ts +38 -1
- package/src/utils/applications.ts +204 -9
- package/src/utils/platform.ts +4 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
3
|
+
import type { PasteConfirmInfo, PasteConfirmer } from './terminalClipboard'
|
|
4
|
+
|
|
5
|
+
// Session-scoped "Don't ask again" — app-wide for the page session (shared across
|
|
6
|
+
// terminal tabs), reset on reload. Deliberately not persisted: a multi-line paste
|
|
7
|
+
// warning re-appearing once per session is cheap, and it avoids a safety net the
|
|
8
|
+
// user silently disabled long ago and forgot.
|
|
9
|
+
let warningSuppressedThisSession = false
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Owns the themed confirmation shown before a risky multi-line paste runs in a
|
|
13
|
+
* terminal. Returns a `confirmPaste` callback to hand to setupTerminalClipboard
|
|
14
|
+
* and a `pasteDialog` node the host must render. Once the user opts out via
|
|
15
|
+
* "Don't ask again", pastes proceed without prompting for the rest of the session.
|
|
16
|
+
*/
|
|
17
|
+
export function useMultilinePasteConfirm(): { confirmPaste: PasteConfirmer; pasteDialog: React.ReactNode } {
|
|
18
|
+
const [pending, setPending] = useState<PasteConfirmInfo | null>(null)
|
|
19
|
+
const [dontAskAgain, setDontAskAgain] = useState(false)
|
|
20
|
+
const resolverRef = useRef<((ok: boolean) => void) | null>(null)
|
|
21
|
+
|
|
22
|
+
const confirmPaste = useCallback<PasteConfirmer>(
|
|
23
|
+
(info) => {
|
|
24
|
+
if (warningSuppressedThisSession) return Promise.resolve(true)
|
|
25
|
+
// A paste arriving while a dialog is still open supersedes it: decline the
|
|
26
|
+
// pending one so its promise resolves (the superseded paste is dropped)
|
|
27
|
+
// rather than leaking, then show the new prompt.
|
|
28
|
+
resolverRef.current?.(false)
|
|
29
|
+
return new Promise<boolean>((resolve) => {
|
|
30
|
+
resolverRef.current = resolve
|
|
31
|
+
setDontAskAgain(false)
|
|
32
|
+
setPending(info)
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
[],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const settle = useCallback(
|
|
39
|
+
(ok: boolean) => {
|
|
40
|
+
// Only suppress when the user actually proceeds — cancelling shouldn't
|
|
41
|
+
// quietly disable the warning.
|
|
42
|
+
if (ok && dontAskAgain) warningSuppressedThisSession = true
|
|
43
|
+
resolverRef.current?.(ok)
|
|
44
|
+
resolverRef.current = null
|
|
45
|
+
setPending(null)
|
|
46
|
+
},
|
|
47
|
+
[dontAskAgain],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const pasteDialog = pending ? (
|
|
51
|
+
<ConfirmDialog
|
|
52
|
+
open
|
|
53
|
+
variant="warning"
|
|
54
|
+
title="Paste multiple lines?"
|
|
55
|
+
message={`This pastes ${pending.lineCount} lines into the shell — they run immediately.`}
|
|
56
|
+
details={pending.text}
|
|
57
|
+
confirmLabel="Paste"
|
|
58
|
+
cancelLabel="Cancel"
|
|
59
|
+
onConfirm={() => settle(true)}
|
|
60
|
+
onClose={() => settle(false)}
|
|
61
|
+
>
|
|
62
|
+
<label className="flex items-center gap-2 text-sm text-theme-text-secondary cursor-pointer">
|
|
63
|
+
<input
|
|
64
|
+
type="checkbox"
|
|
65
|
+
checked={dontAskAgain}
|
|
66
|
+
onChange={(e) => setDontAskAgain(e.target.checked)}
|
|
67
|
+
className="w-4 h-4 rounded border-theme-border bg-theme-base accent-amber-500"
|
|
68
|
+
/>
|
|
69
|
+
<span>Don't ask again this session</span>
|
|
70
|
+
</label>
|
|
71
|
+
</ConfirmDialog>
|
|
72
|
+
) : null
|
|
73
|
+
|
|
74
|
+
return { confirmPaste, pasteDialog }
|
|
75
|
+
}
|
|
@@ -72,12 +72,23 @@ export type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
|
|
|
72
72
|
// the Scope switcher stays hidden until a second category lands. A one-option
|
|
73
73
|
// switcher is just noise (an always-selected pill that can't be changed).
|
|
74
74
|
const AVAILABLE_MODES: GitOpsMode[] = ['applications']
|
|
75
|
+
const GITOPS_COUNT_GROUP_PREFIXES = [
|
|
76
|
+
'argoproj.io/',
|
|
77
|
+
'source.toolkit.fluxcd.io/',
|
|
78
|
+
'kustomize.toolkit.fluxcd.io/',
|
|
79
|
+
'helm.toolkit.fluxcd.io/',
|
|
80
|
+
'notification.toolkit.fluxcd.io/',
|
|
81
|
+
]
|
|
75
82
|
export type GitOpsViewMode = 'table' | 'tiles'
|
|
76
83
|
// 'urgency' is the curated DEFAULT order (what needs attention first) — not a
|
|
77
84
|
// column, so no header shows it as active; clicking any header replaces it with
|
|
78
85
|
// that column's own semantics.
|
|
79
86
|
export type SortKey = 'urgency' | 'name' | 'health' | 'sync' | 'lastSync' | 'project'
|
|
80
87
|
|
|
88
|
+
function isGitOpsCountKey(key: string): boolean {
|
|
89
|
+
return GITOPS_COUNT_GROUP_PREFIXES.some((prefix) => key.startsWith(prefix))
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
// Row-level actions surfaced from the table's three-dot menu. The set
|
|
82
93
|
// mirrors what the detail page exposes today; callers wire the mutations
|
|
83
94
|
// + dialogs and dispatch via `onRowAction`. Argo-only actions (refresh,
|
|
@@ -102,7 +113,7 @@ export interface FleetClusterStamp {
|
|
|
102
113
|
name: string
|
|
103
114
|
}
|
|
104
115
|
export type FleetDestinationMatch = 'in_cluster' | 'exact' | 'inferred' | 'unmatched'
|
|
105
|
-
// FleetDestinationConfidence is a coarse signal the
|
|
116
|
+
// FleetDestinationConfidence is a coarse signal the frontend uses to style
|
|
106
117
|
// the destination chip. `high` = URL-equality match (either direct or
|
|
107
118
|
// via Argo cluster-secret), `medium` = name-equality match (more
|
|
108
119
|
// fragile, more likely to be a false positive when two clusters share
|
|
@@ -116,7 +127,7 @@ export interface FleetDestinationStamp {
|
|
|
116
127
|
// Confidence + reason are populated for non-unmatched rows. They power
|
|
117
128
|
// the chip's visual treatment (a checkmark on high-confidence matches)
|
|
118
129
|
// and its title= tooltip respectively. Both come from the hub —
|
|
119
|
-
// adding new values shouldn't break the
|
|
130
|
+
// adding new values shouldn't break the frontend (unknown confidence
|
|
120
131
|
// falls back to no special styling).
|
|
121
132
|
confidence?: FleetDestinationConfidence
|
|
122
133
|
reason?: string
|
|
@@ -180,11 +191,12 @@ export interface GitOpsTableViewProps {
|
|
|
180
191
|
// counts keyed "group/Kind" — e.g. "argoproj.io/Application" → 17. Drives
|
|
181
192
|
// the Scope-section mode tabs and the empty-state check.
|
|
182
193
|
counts: Record<string, number>
|
|
194
|
+
countsUnavailable?: string[]
|
|
183
195
|
// Caller refresh — typically invalidates its useQuery + refetches.
|
|
184
196
|
onRefresh?: () => void
|
|
185
197
|
// Row click — caller routes to its own detail page. When the host also
|
|
186
198
|
// passes `rowHrefFor`, the callback receives the MouseEvent so it can
|
|
187
|
-
// `preventDefault()` for
|
|
199
|
+
// `preventDefault()` for same-tree nav (e.g. react-router) or skip the
|
|
188
200
|
// preventDefault to let the anchor's default full-page navigation run
|
|
189
201
|
// (required for cross-router-boundary links).
|
|
190
202
|
onRowClick: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
@@ -217,8 +229,11 @@ export interface GitOpsTableViewProps {
|
|
|
217
229
|
searchHotkey?: boolean
|
|
218
230
|
// emptyStateTitle / emptyStateBody override the "No GitOps resources
|
|
219
231
|
// detected" copy. Hub passes "No GitOps resources across the fleet".
|
|
220
|
-
|
|
221
|
-
|
|
232
|
+
// ReactNode (not just string) so a host can split the body across lines
|
|
233
|
+
// (e.g. a `<br/>` between "what's wrong" and "what to do") or emphasize
|
|
234
|
+
// part of it. Strings still work unchanged.
|
|
235
|
+
emptyStateTitle?: ReactNode
|
|
236
|
+
emptyStateBody?: ReactNode
|
|
222
237
|
/**
|
|
223
238
|
* Which side the filter rail sits on. Default 'left' (OSS Radar, which
|
|
224
239
|
* has no app sidebar). A host with its own left navigation rail (the
|
|
@@ -259,6 +274,7 @@ export function GitOpsTableView({
|
|
|
259
274
|
loading,
|
|
260
275
|
error,
|
|
261
276
|
counts,
|
|
277
|
+
countsUnavailable,
|
|
262
278
|
onRefresh,
|
|
263
279
|
onRowClick,
|
|
264
280
|
rowHrefFor,
|
|
@@ -353,7 +369,14 @@ export function GitOpsTableView({
|
|
|
353
369
|
projects: counts['argoproj.io/AppProject'] ?? 0,
|
|
354
370
|
alerts: counts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
|
|
355
371
|
}
|
|
356
|
-
const totalGitOps = Object.
|
|
372
|
+
const totalGitOps = Object.entries(counts).reduce(
|
|
373
|
+
(sum, [key, n]) => sum + (isGitOpsCountKey(key) ? n : 0),
|
|
374
|
+
0,
|
|
375
|
+
)
|
|
376
|
+
const hasUnavailableGitOpsCounts = useMemo(
|
|
377
|
+
() => (countsUnavailable ?? []).some(isGitOpsCountKey),
|
|
378
|
+
[countsUnavailable],
|
|
379
|
+
)
|
|
357
380
|
|
|
358
381
|
const projects = useMemo(
|
|
359
382
|
() => countValues(allRows.map((row) => row.project).filter(Boolean)),
|
|
@@ -481,9 +504,9 @@ export function GitOpsTableView({
|
|
|
481
504
|
// Also require zero actual rows: the cold-cache retry can populate `rows`
|
|
482
505
|
// before the separate counts map catches up, and a populated table must not
|
|
483
506
|
// be hidden behind a "nothing here" screen.
|
|
484
|
-
if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter) {
|
|
507
|
+
if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts) {
|
|
485
508
|
return (
|
|
486
|
-
<div className="flex h-full min-h-0 flex-1 items-
|
|
509
|
+
<div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
|
|
487
510
|
<div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
|
|
488
511
|
<GitBranch className="mx-auto h-8 w-8 text-theme-text-tertiary" />
|
|
489
512
|
<h2 className="mt-3 text-base font-semibold text-theme-text-primary">
|
|
@@ -80,7 +80,7 @@ describe('getGitOpsTool', () => {
|
|
|
80
80
|
expect(getGitOpsTool('appprojects', 'argoproj.io')).toBe('argo')
|
|
81
81
|
})
|
|
82
82
|
// Argo kinds without an explicit group still route to 'argo'.
|
|
83
|
-
// Defends against a normalizer that drops `group` from the
|
|
83
|
+
// Defends against a normalizer that drops `group` from the frontend payload.
|
|
84
84
|
test('Argo kinds without group still route to argo', () => {
|
|
85
85
|
expect(getGitOpsTool('applications', undefined)).toBe('argo')
|
|
86
86
|
expect(getGitOpsTool('applicationsets', '')).toBe('argo')
|
|
@@ -34,7 +34,8 @@ export interface ResourcesSidebarProps {
|
|
|
34
34
|
onSelectedKindChange: (kind: SelectedKindInfo) => void
|
|
35
35
|
onKindChange?: () => void
|
|
36
36
|
apiResources?: APIResource[]
|
|
37
|
-
resourceCounts?: Record<string, number>
|
|
37
|
+
resourceCounts?: Record<string, number | null>
|
|
38
|
+
resourceUnavailable?: string[]
|
|
38
39
|
resourceForbidden?: string[]
|
|
39
40
|
pinned?: PinnedItem[]
|
|
40
41
|
togglePin?: (item: PinnedItem) => void
|
|
@@ -176,6 +177,7 @@ export function ResourcesSidebar({
|
|
|
176
177
|
onKindChange,
|
|
177
178
|
apiResources,
|
|
178
179
|
resourceCounts,
|
|
180
|
+
resourceUnavailable,
|
|
179
181
|
resourceForbidden,
|
|
180
182
|
pinned = [],
|
|
181
183
|
togglePin = () => {},
|
|
@@ -257,11 +259,11 @@ export function ResourcesSidebar({
|
|
|
257
259
|
}))
|
|
258
260
|
}, [categories])
|
|
259
261
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
262
|
+
const unavailableKinds = useMemo(() => new Set(resourceUnavailable ?? []), [resourceUnavailable])
|
|
263
|
+
|
|
264
|
+
// null for a key means "count unknown/unavailable" (rendered as a
|
|
265
|
+
// placeholder dash in the badge). 0 means "the API replied and
|
|
266
|
+
// confirmed there are zero of this kind".
|
|
265
267
|
const counts = useMemo(() => {
|
|
266
268
|
const results: Record<string, number | null> = {}
|
|
267
269
|
if (!resourceCounts) {
|
|
@@ -273,15 +275,16 @@ export function ResourcesSidebar({
|
|
|
273
275
|
}
|
|
274
276
|
for (const resource of resourcesToCount) {
|
|
275
277
|
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
278
|
+
if (unavailableKinds.has(key)) {
|
|
279
|
+
results[key] = null
|
|
280
|
+
} else if (key in resourceCounts) {
|
|
281
|
+
results[key] = resourceCounts[key] ?? null
|
|
282
|
+
} else {
|
|
283
|
+
results[key] = 0
|
|
284
|
+
}
|
|
282
285
|
}
|
|
283
286
|
return results
|
|
284
|
-
}, [resourcesToCount, resourceCounts])
|
|
287
|
+
}, [resourcesToCount, resourceCounts, unavailableKinds])
|
|
285
288
|
|
|
286
289
|
// Track which resource kinds returned 403 Forbidden
|
|
287
290
|
const forbiddenKinds = useMemo(() => {
|
|
@@ -1813,6 +1813,20 @@ export interface ResourceQueryResult {
|
|
|
1813
1813
|
dataUpdatedAt?: number
|
|
1814
1814
|
}
|
|
1815
1815
|
|
|
1816
|
+
export interface LargeListGuardState {
|
|
1817
|
+
kind: string
|
|
1818
|
+
count?: number
|
|
1819
|
+
reason?: 'too-many' | 'count-unavailable'
|
|
1820
|
+
limit: number
|
|
1821
|
+
namespaces: string[]
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function formatLargeListScope(namespaces: string[]): string {
|
|
1825
|
+
if (namespaces.length === 0) return 'all namespaces'
|
|
1826
|
+
if (namespaces.length === 1) return namespaces[0]
|
|
1827
|
+
return `${namespaces.length.toLocaleString()} namespaces`
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1816
1830
|
interface ResourcesViewProps {
|
|
1817
1831
|
namespaces: string[]
|
|
1818
1832
|
selectedResource?: SelectedResource | null
|
|
@@ -1826,8 +1840,10 @@ interface ResourcesViewProps {
|
|
|
1826
1840
|
// Lightweight counts for sidebar badges (from /api/resource-counts)
|
|
1827
1841
|
resourceCounts?: Record<string, number>
|
|
1828
1842
|
resourceForbidden?: string[]
|
|
1843
|
+
resourceUnavailable?: string[]
|
|
1829
1844
|
// Single query for the currently selected kind's full data
|
|
1830
1845
|
selectedKindQuery?: ResourceQueryResult
|
|
1846
|
+
largeListGuard?: LargeListGuardState | null
|
|
1831
1847
|
topPodMetrics?: TopPodMetrics[]
|
|
1832
1848
|
topNodeMetrics?: TopNodeMetrics[]
|
|
1833
1849
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
@@ -2042,7 +2058,9 @@ export function ResourcesView({
|
|
|
2042
2058
|
resourceQueries: resourceQueriesProp,
|
|
2043
2059
|
resourceCounts: resourceCountsProp,
|
|
2044
2060
|
resourceForbidden: resourceForbiddenProp,
|
|
2061
|
+
resourceUnavailable: resourceUnavailableProp,
|
|
2045
2062
|
selectedKindQuery: selectedKindQueryProp,
|
|
2063
|
+
largeListGuard,
|
|
2046
2064
|
topPodMetrics,
|
|
2047
2065
|
topNodeMetrics,
|
|
2048
2066
|
certExpiry,
|
|
@@ -3237,22 +3255,29 @@ export function ResourcesView({
|
|
|
3237
3255
|
const counts = useMemo(() => {
|
|
3238
3256
|
if (useNewCountsMode) {
|
|
3239
3257
|
// resourceCountsProp uses "group/Kind" keys for CRDs, "Kind" for core — same format as sidebar
|
|
3240
|
-
const
|
|
3258
|
+
const unavailableKinds = new Set(resourceUnavailableProp ?? [])
|
|
3259
|
+
const results: Record<string, number | null> = {}
|
|
3241
3260
|
for (const resource of resourcesToCount) {
|
|
3242
3261
|
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
3243
|
-
|
|
3262
|
+
if (unavailableKinds.has(key)) {
|
|
3263
|
+
results[key] = null
|
|
3264
|
+
} else if (key in resourceCountsProp!) {
|
|
3265
|
+
results[key] = resourceCountsProp![key]
|
|
3266
|
+
} else {
|
|
3267
|
+
results[key] = 0
|
|
3268
|
+
}
|
|
3244
3269
|
}
|
|
3245
3270
|
return results
|
|
3246
3271
|
}
|
|
3247
3272
|
// Legacy: derive counts from full query data
|
|
3248
|
-
const results: Record<string, number> = {}
|
|
3273
|
+
const results: Record<string, number | null> = {}
|
|
3249
3274
|
resourcesToCount.forEach((resource, index) => {
|
|
3250
3275
|
const data = resourceQueries[index]?.data
|
|
3251
3276
|
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
3252
3277
|
results[key] = Array.isArray(data) ? data.length : 0
|
|
3253
3278
|
})
|
|
3254
3279
|
return results
|
|
3255
|
-
}, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceQueries])
|
|
3280
|
+
}, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceUnavailableProp, resourceQueries])
|
|
3256
3281
|
|
|
3257
3282
|
// Track which resource kinds returned 403 Forbidden
|
|
3258
3283
|
const forbiddenKinds = useMemo(() => {
|
|
@@ -3987,6 +4012,7 @@ export function ResourcesView({
|
|
|
3987
4012
|
apiResources={apiResourcesProp}
|
|
3988
4013
|
resourceCounts={counts}
|
|
3989
4014
|
resourceForbidden={Array.from(forbiddenKinds)}
|
|
4015
|
+
resourceUnavailable={resourceUnavailableProp}
|
|
3990
4016
|
pinned={pinned}
|
|
3991
4017
|
togglePin={togglePin}
|
|
3992
4018
|
isPinned={isPinned}
|
|
@@ -4484,6 +4510,26 @@ export function ResourcesView({
|
|
|
4484
4510
|
<p className="text-theme-text-secondary font-medium">Access Restricted</p>
|
|
4485
4511
|
<p className="text-sm mt-1">Insufficient permissions to list {selectedKind.kind} resources</p>
|
|
4486
4512
|
</div>
|
|
4513
|
+
) : largeListGuard ? (
|
|
4514
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary px-6 text-center">
|
|
4515
|
+
<AlertTriangle className="w-8 h-8 text-amber-400 mb-2" />
|
|
4516
|
+
<p className="text-theme-text-secondary font-medium">
|
|
4517
|
+
{largeListGuard.reason === 'count-unavailable'
|
|
4518
|
+
? `${largeListGuard.kind} count unavailable`
|
|
4519
|
+
: `Too many ${largeListGuard.kind.toLowerCase()} to show`}
|
|
4520
|
+
</p>
|
|
4521
|
+
<p className="text-sm mt-1 max-w-xl">
|
|
4522
|
+
{largeListGuard.reason === 'count-unavailable'
|
|
4523
|
+
? 'Radar could not verify this list is small enough to load. Choose a namespace or smaller namespace set to try again.'
|
|
4524
|
+
: `${largeListGuard.count?.toLocaleString()} ${largeListGuard.kind.toLowerCase()} are in the current scope. Choose a namespace or smaller namespace set to load this view.`}
|
|
4525
|
+
</p>
|
|
4526
|
+
<p className="text-xs mt-2 text-theme-text-disabled">
|
|
4527
|
+
Radar limits full table loads to {largeListGuard.limit.toLocaleString()} resources to keep the UI responsive.
|
|
4528
|
+
</p>
|
|
4529
|
+
<p className="text-xs mt-2 text-theme-text-disabled">
|
|
4530
|
+
Scope: {formatLargeListScope(largeListGuard.namespaces)}
|
|
4531
|
+
</p>
|
|
4532
|
+
</div>
|
|
4487
4533
|
) : filteredResources.length === 0 ? (
|
|
4488
4534
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
|
|
4489
4535
|
<p>No {selectedKind.kind} found</p>
|
|
@@ -16,6 +16,6 @@ export * from './resource-utils-trivy'
|
|
|
16
16
|
export * from './resource-utils-traefik'
|
|
17
17
|
export * from './resource-utils-velero'
|
|
18
18
|
export { ResourcesView, ResourcesViewDataContext } from './ResourcesView'
|
|
19
|
-
export type { ResourceQueryResult, ExtraColumn } from './ResourcesView'
|
|
19
|
+
export type { ResourceQueryResult, ExtraColumn, LargeListGuardState } from './ResourcesView'
|
|
20
20
|
export { ResourcesSidebar } from './ResourcesSidebar'
|
|
21
21
|
export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem } from './ResourcesSidebar'
|
|
@@ -1127,6 +1127,10 @@ export function cronToHuman(cron: string): string {
|
|
|
1127
1127
|
if (minute === '0' && hour === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1128
1128
|
return 'Daily at midnight'
|
|
1129
1129
|
}
|
|
1130
|
+
if (minute === '0' && hour.startsWith('*/') && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1131
|
+
const interval = hour.slice(2)
|
|
1132
|
+
return interval === '1' ? 'Every hour' : `Every ${interval} hours`
|
|
1133
|
+
}
|
|
1130
1134
|
if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1131
1135
|
return `Daily at ${hour}:00`
|
|
1132
1136
|
}
|
|
@@ -53,6 +53,11 @@ export interface TimelineListProps {
|
|
|
53
53
|
onResourceClick?: NavigateToResource
|
|
54
54
|
initialFilter?: ActivityTypeFilter
|
|
55
55
|
initialTimeRange?: TimeRange
|
|
56
|
+
// Controlled "show deleted" toggle. When omitted the component manages it
|
|
57
|
+
// internally; the host passes it to share one toggle across list + swimlane
|
|
58
|
+
// and to drive server-side delete filtering.
|
|
59
|
+
showDeleted?: boolean
|
|
60
|
+
onShowDeletedChange?: (showDeleted: boolean) => void
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
@@ -80,11 +85,14 @@ const RESOURCE_KINDS = [
|
|
|
80
85
|
'StatefulSet',
|
|
81
86
|
]
|
|
82
87
|
|
|
83
|
-
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
|
|
88
|
+
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineListProps) {
|
|
84
89
|
const [searchTerm, setSearchTerm] = useState('')
|
|
85
90
|
const [activityTypeFilter, setActivityTypeFilter] = useState<ActivityTypeFilter>(initialFilter ?? 'all')
|
|
86
91
|
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
|
|
87
92
|
const [kindFilter, setKindFilter] = useState<string>('')
|
|
93
|
+
const [showDeletedInternal, setShowDeletedInternal] = useState(true)
|
|
94
|
+
const showDeleted = showDeletedProp ?? showDeletedInternal
|
|
95
|
+
const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
|
|
88
96
|
const [expandedItem, setExpandedItem] = useState<string | null>(null)
|
|
89
97
|
|
|
90
98
|
useEffect(() => {
|
|
@@ -111,6 +119,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
111
119
|
const isUnhealthyChange = isChangeEvent(item) && (item.healthState === 'unhealthy' || item.healthState === 'degraded')
|
|
112
120
|
if (!isUnhealthyChange) return false
|
|
113
121
|
}
|
|
122
|
+
if (!showDeleted && item.eventType === 'delete') return false
|
|
114
123
|
|
|
115
124
|
// Filter by search term
|
|
116
125
|
if (searchTerm) {
|
|
@@ -129,7 +138,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
129
138
|
|
|
130
139
|
return true
|
|
131
140
|
})
|
|
132
|
-
}, [events, activityTypeFilter, searchTerm])
|
|
141
|
+
}, [events, activityTypeFilter, searchTerm, showDeleted])
|
|
133
142
|
|
|
134
143
|
// Aggregated event group type
|
|
135
144
|
type AggregatedItem = {
|
|
@@ -245,12 +254,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
245
254
|
|
|
246
255
|
// Count stats
|
|
247
256
|
const stats = useMemo(() => {
|
|
248
|
-
if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0 }
|
|
257
|
+
if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0, deleted: 0 }
|
|
249
258
|
return {
|
|
250
259
|
total: events.length,
|
|
251
260
|
changes: events.filter((e) => isChangeEvent(e)).length,
|
|
252
261
|
warnings: events.filter((e) => e.eventType === 'Warning').length,
|
|
253
262
|
unhealthy: events.filter((e) => isChangeEvent(e) && (e.healthState === 'unhealthy' || e.healthState === 'degraded')).length,
|
|
263
|
+
deleted: events.filter((e) => e.eventType === 'delete').length,
|
|
254
264
|
}
|
|
255
265
|
}, [events])
|
|
256
266
|
|
|
@@ -306,6 +316,24 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
306
316
|
/>
|
|
307
317
|
</div>
|
|
308
318
|
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => setShowDeleted(!showDeleted)}
|
|
322
|
+
title="Show or hide resources that were deleted, including Pods that no longer exist"
|
|
323
|
+
className={clsx(
|
|
324
|
+
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-2 bg-theme-elevated',
|
|
325
|
+
showDeleted ? 'text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
<Trash2 className="w-3 h-3" />
|
|
329
|
+
Deleted
|
|
330
|
+
{stats.deleted > 0 && (
|
|
331
|
+
<span className="text-xs px-1.5 rounded bg-theme-hover/50">
|
|
332
|
+
{stats.deleted}
|
|
333
|
+
</span>
|
|
334
|
+
)}
|
|
335
|
+
</button>
|
|
336
|
+
|
|
309
337
|
{/* Kind filter */}
|
|
310
338
|
<select
|
|
311
339
|
value={kindFilter}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
Timer,
|
|
22
22
|
RotateCcw,
|
|
23
23
|
Shield,
|
|
24
|
+
Trash2,
|
|
24
25
|
} from 'lucide-react'
|
|
25
26
|
import type { TimelineEvent, Topology } from '../../types'
|
|
26
27
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
@@ -54,9 +55,14 @@ export interface TimelineSwimlanesProps {
|
|
|
54
55
|
// RBAC capability flag (was a radar/web context); host passes it. Default false.
|
|
55
56
|
hasLimitedAccess?: boolean
|
|
56
57
|
// GitOps lane labels deep-link to a controller path; the host decides how to
|
|
57
|
-
// navigate (radar router push, or cross-
|
|
58
|
+
// navigate (radar router push, or cross-route-tree href). When omitted, GitOps lanes
|
|
58
59
|
// fall back to onResourceClick (the resource drawer).
|
|
59
60
|
onNavigatePath?: (path: string) => void
|
|
61
|
+
// Controlled "show deleted" toggle. When omitted the component manages it
|
|
62
|
+
// internally; the host passes it to share one toggle with the list view and
|
|
63
|
+
// to drive server-side delete filtering on the underlying fetch.
|
|
64
|
+
showDeleted?: boolean
|
|
65
|
+
onShowDeletedChange?: (showDeleted: boolean) => void
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
interface ResourceLane extends BaseResourceLane {
|
|
@@ -180,7 +186,7 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
|
|
|
180
186
|
return breakdown
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath }: TimelineSwimlanesProps) {
|
|
189
|
+
export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineSwimlanesProps) {
|
|
184
190
|
// Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
|
|
185
191
|
// deep-link to GitOps detail rather than the resource drawer — the lane is
|
|
186
192
|
// already telling the user "this controller had changes/events"; the GitOps
|
|
@@ -203,6 +209,9 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
203
209
|
const [expandedLanes, setExpandedLanes] = useState<Set<string>>(new Set())
|
|
204
210
|
const [hasAutoZoomed, setHasAutoZoomed] = useState(false)
|
|
205
211
|
const [groupByApp, setGroupByApp] = useState(true) // Group by app.kubernetes.io/name label
|
|
212
|
+
const [showDeletedInternal, setShowDeletedInternal] = useState(true)
|
|
213
|
+
const showDeleted = showDeletedProp ?? showDeletedInternal
|
|
214
|
+
const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
|
|
206
215
|
|
|
207
216
|
// Stable lane ordering - use ref to avoid render loop (lanes depends on order, order depends on lanes)
|
|
208
217
|
const laneOrderRef = useRef<Map<string, number>>(new Map())
|
|
@@ -253,17 +262,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
253
262
|
|
|
254
263
|
// Filter events by search term
|
|
255
264
|
const filteredEvents = useMemo(() => {
|
|
256
|
-
|
|
265
|
+
const baseEvents = showDeleted ? events : events.filter(e => e.eventType !== 'delete')
|
|
266
|
+
if (!searchTerm) return baseEvents
|
|
257
267
|
|
|
258
268
|
const term = searchTerm.toLowerCase()
|
|
259
|
-
return
|
|
269
|
+
return baseEvents.filter(e =>
|
|
260
270
|
e.name.toLowerCase().includes(term) ||
|
|
261
271
|
e.kind.toLowerCase().includes(term) ||
|
|
262
272
|
e.namespace?.toLowerCase().includes(term) ||
|
|
263
273
|
e.reason?.toLowerCase().includes(term) ||
|
|
264
274
|
e.message?.toLowerCase().includes(term)
|
|
265
275
|
)
|
|
266
|
-
}, [events, searchTerm])
|
|
276
|
+
}, [events, searchTerm, showDeleted])
|
|
267
277
|
|
|
268
278
|
// Build hierarchical lanes using owner references + topology edges
|
|
269
279
|
// Uses the shared utility from utils/resource-hierarchy.ts
|
|
@@ -514,6 +524,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
514
524
|
<span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
|
|
515
525
|
</label>
|
|
516
526
|
</Tooltip>
|
|
527
|
+
<Tooltip content="Show resources Radar observed being deleted, including Pods that no longer exist" position="bottom">
|
|
528
|
+
<label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
|
|
529
|
+
<input
|
|
530
|
+
type="checkbox"
|
|
531
|
+
checked={showDeleted}
|
|
532
|
+
onChange={(e) => setShowDeleted(e.target.checked)}
|
|
533
|
+
className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
|
|
534
|
+
/>
|
|
535
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
536
|
+
<span className="border-b border-dotted border-theme-text-tertiary">Deleted</span>
|
|
537
|
+
</label>
|
|
538
|
+
</Tooltip>
|
|
517
539
|
{/* View toggle */}
|
|
518
540
|
{onViewModeChange && (
|
|
519
541
|
<div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
|
|
@@ -38,6 +38,8 @@ export interface SearchPillInputProps {
|
|
|
38
38
|
rightSlot?: React.ReactNode
|
|
39
39
|
/** Applied to the input container (host owns the box chrome: height, bg, border). */
|
|
40
40
|
className?: string
|
|
41
|
+
/** Applied to the `<input>` itself (host owns text size: e.g. a hero variant). */
|
|
42
|
+
inputClassName?: string
|
|
41
43
|
'aria-label'?: string
|
|
42
44
|
/** Fires when the modifier autocomplete opens/closes, so the host can suppress
|
|
43
45
|
its own results dropdown while a modifier is being completed. */
|
|
@@ -101,6 +103,7 @@ export function SearchPillInput({
|
|
|
101
103
|
leftSlot,
|
|
102
104
|
rightSlot,
|
|
103
105
|
className,
|
|
106
|
+
inputClassName,
|
|
104
107
|
onSuggestingChange,
|
|
105
108
|
...rest
|
|
106
109
|
}: SearchPillInputProps) {
|
|
@@ -110,6 +113,20 @@ export function SearchPillInput({
|
|
|
110
113
|
const [sel, setSel] = useState(0)
|
|
111
114
|
const [dismissed, setDismissed] = useState(false)
|
|
112
115
|
const [anchor, setAnchor] = useState<{ left: number; bottom: number } | null>(null)
|
|
116
|
+
// Collapse a long pill list to a few + "+N more" so they don't flood the
|
|
117
|
+
// field (e.g. a broad namespace scope seeded as ns: pills). Expanding wraps
|
|
118
|
+
// them so every pill stays removable.
|
|
119
|
+
const [pillsExpanded, setPillsExpanded] = useState(false)
|
|
120
|
+
const MAX_VISIBLE_PILLS = 3
|
|
121
|
+
// Reset the expand toggle once there's nothing to collapse (pills cleared or
|
|
122
|
+
// trimmed) — otherwise a later seed of many pills would render expanded,
|
|
123
|
+
// re-flooding the field instead of collapsing.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (pills.length <= MAX_VISIBLE_PILLS + 1) setPillsExpanded(false)
|
|
126
|
+
}, [pills.length])
|
|
127
|
+
const collapsePills = !pillsExpanded && pills.length > MAX_VISIBLE_PILLS + 1
|
|
128
|
+
const shownPills = collapsePills ? pills.slice(0, MAX_VISIBLE_PILLS) : pills
|
|
129
|
+
const hiddenPillCount = pills.length - shownPills.length
|
|
113
130
|
|
|
114
131
|
const mod = useMemo(() => activeModifier(text, aliases), [text, aliases])
|
|
115
132
|
|
|
@@ -181,12 +198,12 @@ export function SearchPillInput({
|
|
|
181
198
|
}, [suggesting, filtered, sel, mod, text, pills, commitPill, onChange, onKeyDown])
|
|
182
199
|
|
|
183
200
|
return (
|
|
184
|
-
<div ref={containerRef} className={clsx('flex items-center gap-1.5', className)} onClick={() => inputRef.current?.focus()}>
|
|
201
|
+
<div ref={containerRef} className={clsx('flex items-center gap-1.5', pillsExpanded && 'flex-wrap', className)} onClick={() => inputRef.current?.focus()}>
|
|
185
202
|
{leftSlot}
|
|
186
|
-
{
|
|
203
|
+
{shownPills.map((p, i) => (
|
|
187
204
|
<span key={`${p.key}:${p.value}:${i}`} className="inline-flex items-center gap-1 shrink-0 rounded-md bg-theme-elevated border border-theme-border-light pl-1.5 pr-1 py-0.5 text-xs whitespace-nowrap">
|
|
188
205
|
<span className="text-theme-text-tertiary">{p.key}:</span>
|
|
189
|
-
<span className="text-theme-text-primary
|
|
206
|
+
<span className="max-w-[16ch] truncate font-medium text-theme-text-primary" title={p.value}>{p.value}</span>
|
|
190
207
|
<button
|
|
191
208
|
type="button"
|
|
192
209
|
tabIndex={-1}
|
|
@@ -198,6 +215,27 @@ export function SearchPillInput({
|
|
|
198
215
|
</button>
|
|
199
216
|
</span>
|
|
200
217
|
))}
|
|
218
|
+
{collapsePills && hiddenPillCount > 0 && (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
tabIndex={-1}
|
|
222
|
+
onMouseDown={(e) => { e.preventDefault(); setPillsExpanded(true) }}
|
|
223
|
+
className="shrink-0 rounded-md bg-theme-elevated border border-theme-border-light px-1.5 py-0.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary whitespace-nowrap"
|
|
224
|
+
title="Show all filters"
|
|
225
|
+
>
|
|
226
|
+
+{hiddenPillCount} more
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
{pillsExpanded && pills.length > MAX_VISIBLE_PILLS + 1 && (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
tabIndex={-1}
|
|
233
|
+
onMouseDown={(e) => { e.preventDefault(); setPillsExpanded(false) }}
|
|
234
|
+
className="shrink-0 rounded-md px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary whitespace-nowrap"
|
|
235
|
+
>
|
|
236
|
+
less
|
|
237
|
+
</button>
|
|
238
|
+
)}
|
|
201
239
|
<input
|
|
202
240
|
ref={inputRef}
|
|
203
241
|
type="text"
|
|
@@ -207,7 +245,10 @@ export function SearchPillInput({
|
|
|
207
245
|
onFocus={onFocus}
|
|
208
246
|
placeholder={pills.length ? '' : placeholder}
|
|
209
247
|
aria-label={rest['aria-label']}
|
|
210
|
-
className=
|
|
248
|
+
className={clsx(
|
|
249
|
+
'flex-1 min-w-[80px] bg-transparent text-theme-text-primary placeholder-theme-text-tertiary outline-none',
|
|
250
|
+
inputClassName ?? 'text-sm',
|
|
251
|
+
)}
|
|
211
252
|
/>
|
|
212
253
|
{rightSlot}
|
|
213
254
|
{suggesting && anchor && mod && createPortal(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { isMac as isMacPlatform } from '../utils/platform'
|
|
2
3
|
|
|
3
4
|
export type ShortcutScope = 'global' | 'topology' | 'resources' | 'timeline' | 'helm' | 'gitops' | 'traffic' | 'applications' | 'audit' | 'drawer'
|
|
4
5
|
|
|
@@ -78,7 +79,7 @@ interface KeyMatcher {
|
|
|
78
79
|
altKey?: boolean
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
const isMac =
|
|
82
|
+
const isMac = isMacPlatform()
|
|
82
83
|
|
|
83
84
|
function parseKeys(keys: string): KeyMatcher {
|
|
84
85
|
// Multi-key sequence (space-separated, e.g. "g g")
|