@skyhook-io/k8s-ui 1.8.1 → 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/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 +22 -2
- 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/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,
|
|
@@ -180,6 +191,7 @@ 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
|
|
@@ -262,6 +274,7 @@ export function GitOpsTableView({
|
|
|
262
274
|
loading,
|
|
263
275
|
error,
|
|
264
276
|
counts,
|
|
277
|
+
countsUnavailable,
|
|
265
278
|
onRefresh,
|
|
266
279
|
onRowClick,
|
|
267
280
|
rowHrefFor,
|
|
@@ -356,7 +369,14 @@ export function GitOpsTableView({
|
|
|
356
369
|
projects: counts['argoproj.io/AppProject'] ?? 0,
|
|
357
370
|
alerts: counts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
|
|
358
371
|
}
|
|
359
|
-
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
|
+
)
|
|
360
380
|
|
|
361
381
|
const projects = useMemo(
|
|
362
382
|
() => countValues(allRows.map((row) => row.project).filter(Boolean)),
|
|
@@ -484,7 +504,7 @@ export function GitOpsTableView({
|
|
|
484
504
|
// Also require zero actual rows: the cold-cache retry can populate `rows`
|
|
485
505
|
// before the separate counts map catches up, and a populated table must not
|
|
486
506
|
// be hidden behind a "nothing here" screen.
|
|
487
|
-
if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter) {
|
|
507
|
+
if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts) {
|
|
488
508
|
return (
|
|
489
509
|
<div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
|
|
490
510
|
<div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
|
|
@@ -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'
|
|
@@ -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")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { compareVersions, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, type AppGroupFoldEntry } from './applications'
|
|
2
|
+
import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, type AppGroupFoldEntry } from './applications'
|
|
3
3
|
|
|
4
4
|
describe('compareVersions', () => {
|
|
5
5
|
it('orders semver', () => {
|
|
@@ -205,3 +205,40 @@ describe('foldAppGroups', () => {
|
|
|
205
205
|
expect(grouped.map((r) => r.kind)).toEqual(['group'])
|
|
206
206
|
})
|
|
207
207
|
})
|
|
208
|
+
|
|
209
|
+
describe('appGroupingExplainer', () => {
|
|
210
|
+
it('declared origins fold across clusters with no fix needed', () => {
|
|
211
|
+
for (const source of ['explicit', 'argo-path', 'argo-appset', 'flux-source']) {
|
|
212
|
+
const e = appGroupingExplainer({ key: 'k', env: 'prod', confidence: 'high', evidence: '', source })
|
|
213
|
+
expect(e.folds).toBe(true)
|
|
214
|
+
expect(e.fix).toBeUndefined()
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('NAME sources stay per-cluster and tell the user how to fold', () => {
|
|
219
|
+
for (const source of ['label', 'name-stem', 'namespace', undefined]) {
|
|
220
|
+
const e = appGroupingExplainer({ key: 'k', env: 'prod', confidence: 'high', evidence: '', source })
|
|
221
|
+
expect(e.folds).toBe(false)
|
|
222
|
+
expect(e.fix).toContain(APP_IDENTITY_ANNOTATION)
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('foldAppGroups pathKey disambiguation', () => {
|
|
228
|
+
const fleetEntry = (key: string, env: string, pathKey: string): AppGroupFoldEntry => ({
|
|
229
|
+
row: { key, name: 'billing', identity: { key: 'billing', env, confidence: 'high', evidence: 'e', portable: true, source: 'argo-path', pathKey } },
|
|
230
|
+
health: 'healthy', versions: [], ready: 1, desired: 1, kinds: { Deployment: 1 }, classComposition: [{ cls: 'service', count: 1 }],
|
|
231
|
+
})
|
|
232
|
+
const opts = { localScope: (e: AppGroupFoldEntry) => e.row.key }
|
|
233
|
+
|
|
234
|
+
it('folds same-name portable rows that share a pathKey', () => {
|
|
235
|
+
const rows = foldAppGroups([fleetEntry('cl-a', 'dev', 'apps/billing'), fleetEntry('cl-b', 'prod', 'apps/billing')], new Set(), false, opts)
|
|
236
|
+
expect(rows.filter((r) => r.kind === 'group').length).toBe(1)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('does NOT fold same-name portable rows with different pathKeys (two teams, two paths)', () => {
|
|
240
|
+
const rows = foldAppGroups([fleetEntry('cl-a', 'dev', 'teamA/billing'), fleetEntry('cl-b', 'prod', 'teamB/billing')], new Set(), false, opts)
|
|
241
|
+
expect(rows.filter((r) => r.kind === 'group').length).toBe(0)
|
|
242
|
+
expect(rows.filter((r) => r.kind === 'instance').length).toBe(2)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -53,6 +53,63 @@ export interface AppIdentity {
|
|
|
53
53
|
evidence: string
|
|
54
54
|
/** True when the key is backed by declared upstream identity and can group across clusters. */
|
|
55
55
|
portable?: boolean
|
|
56
|
+
/** Machine-readable provenance tier (radar applications_identity.go):
|
|
57
|
+
* explicit | argo-path | argo-appset | flux-source (declared origins, portable)
|
|
58
|
+
* · label | name-stem | namespace (NAMEs, per-cluster). */
|
|
59
|
+
source?: string
|
|
60
|
+
/** Declared source-path stem (argo-path / flux-source only). The display `key`
|
|
61
|
+
* is the name stem; this disambiguates same-name/different-path apps in the
|
|
62
|
+
* portable cross-cluster fold. */
|
|
63
|
+
pathKey?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The user-set annotation that forces cross-cluster grouping — the canonical
|
|
67
|
+
* answer to "how do I make this fold?" for non-GitOps apps. */
|
|
68
|
+
export const APP_IDENTITY_ANNOTATION = 'app.skyhook.io/app'
|
|
69
|
+
|
|
70
|
+
/** Whether a Source is a declared cross-cluster origin (mirrors the hub's
|
|
71
|
+
* isDeclaredPortableSource). NAMEs collide across clusters and stay per-cluster. */
|
|
72
|
+
export function isDeclaredAppSource(source?: string): boolean {
|
|
73
|
+
return source === 'explicit' || source === 'argo-path' || source === 'argo-appset' || source === 'flux-source'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A short label for how an app's identity was determined, by Source — the
|
|
77
|
+
* plain-language "grouped by …" phrase shown in the identity tooltip. */
|
|
78
|
+
export function appSourceLabel(source?: string): string {
|
|
79
|
+
switch (source) {
|
|
80
|
+
case 'explicit':
|
|
81
|
+
return `the ${APP_IDENTITY_ANNOTATION} annotation`
|
|
82
|
+
case 'argo-path':
|
|
83
|
+
return 'its Argo CD source path'
|
|
84
|
+
case 'argo-appset':
|
|
85
|
+
return 'its ApplicationSet (env fan-out)'
|
|
86
|
+
case 'flux-source':
|
|
87
|
+
return 'its Flux source'
|
|
88
|
+
case 'addon':
|
|
89
|
+
return 'a shared add-on name + chart/image'
|
|
90
|
+
case 'label':
|
|
91
|
+
return 'the app.kubernetes.io/name label'
|
|
92
|
+
case 'name-stem':
|
|
93
|
+
return 'a shared name + image'
|
|
94
|
+
case 'namespace':
|
|
95
|
+
return 'a shared namespace + image'
|
|
96
|
+
default:
|
|
97
|
+
return 'its workload grouping'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The explainer for "why isn't this grouped across clusters, and how do I fix
|
|
102
|
+
* it?" — drives the per-cluster row hint. `folds` is true when the identity is a
|
|
103
|
+
* declared origin (folds cross-cluster); otherwise `fix` says what to add. */
|
|
104
|
+
export function appGroupingExplainer(identity?: AppIdentity): { folds: boolean; how: string; fix?: string } {
|
|
105
|
+
const folds = isDeclaredAppSource(identity?.source)
|
|
106
|
+
return {
|
|
107
|
+
folds,
|
|
108
|
+
how: `Grouped by ${appSourceLabel(identity?.source)}.`,
|
|
109
|
+
fix: folds
|
|
110
|
+
? undefined
|
|
111
|
+
: `This app only has a per-cluster name, which can't prove two clusters run the same app. To fold it across clusters, set ${APP_IDENTITY_ANNOTATION} on its workloads in each cluster (same value), or deploy it via Argo CD / Flux with the environment in the source path.`,
|
|
112
|
+
}
|
|
56
113
|
}
|
|
57
114
|
|
|
58
115
|
export interface AppRow {
|
|
@@ -474,6 +531,12 @@ export interface FoldAppGroupsOptions<T extends AppGroupFoldEntry> {
|
|
|
474
531
|
* should include the cluster id so local name/repo evidence cannot merge
|
|
475
532
|
* unrelated clusters. Portable identities ignore the scope. */
|
|
476
533
|
localScope?: (entry: T) => string | undefined
|
|
534
|
+
/** Per-member env slices for the env ladder. A fleet member spans several
|
|
535
|
+
* per-cluster envs, so the host supplies them (cluster-coverage derived,
|
|
536
|
+
* authoritative) rather than the single `identity.env` — which the hub can
|
|
537
|
+
* stale when it joins the same overlay key across clusters. Defaults to the
|
|
538
|
+
* member's single identity env. */
|
|
539
|
+
envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>
|
|
477
540
|
}
|
|
478
541
|
|
|
479
542
|
export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
@@ -489,7 +552,10 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
|
489
552
|
if (!id) return null
|
|
490
553
|
const scope = options.localScope?.(e)
|
|
491
554
|
if (!scope) return id.key
|
|
492
|
-
|
|
555
|
+
// A declared-PATH identity displays its name stem as the key but carries the
|
|
556
|
+
// path stem in pathKey; two different apps can share a name while declaring
|
|
557
|
+
// different paths, so disambiguate the portable cross-cluster fold by pathKey.
|
|
558
|
+
return id.portable ? `portable:${id.key}${id.pathKey ? `:${id.pathKey}` : ''}` : `local:${scope}:${id.key}`
|
|
493
559
|
}
|
|
494
560
|
const byGroup = new Map<string, T[]>()
|
|
495
561
|
for (const e of entries) {
|
|
@@ -518,15 +584,20 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
|
518
584
|
let desired = 0
|
|
519
585
|
let health: AppHealth = 'unknown'
|
|
520
586
|
for (const m of members) {
|
|
521
|
-
const env = m.row.identity!.env
|
|
522
587
|
const v = newest(m)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (
|
|
588
|
+
// A fleet member spans several per-cluster envs; the host supplies them so
|
|
589
|
+
// the ladder reflects every env, not just the member's (possibly stale)
|
|
590
|
+
// single identity env. Default: the one identity env.
|
|
591
|
+
const slices = options.envsOf?.(m) ?? [{ env: m.row.identity!.env, health: m.health }]
|
|
592
|
+
for (const slice of slices) {
|
|
593
|
+
const cur = cellMap.get(slice.env)
|
|
594
|
+
if (!cur) {
|
|
595
|
+
cellMap.set(slice.env, { env: slice.env, health: slice.health, version: v, count: 1, firstKey: m.row.key })
|
|
596
|
+
} else {
|
|
597
|
+
cur.count++
|
|
598
|
+
if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0)) cur.health = slice.health
|
|
599
|
+
if (v && (!cur.version || compareVersions(v, cur.version) === 1)) cur.version = v
|
|
600
|
+
}
|
|
530
601
|
}
|
|
531
602
|
if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0)) health = m.health
|
|
532
603
|
ready += m.ready
|
|
@@ -672,3 +743,127 @@ export function newestTag(versions: string[]): string | undefined {
|
|
|
672
743
|
}
|
|
673
744
|
return best
|
|
674
745
|
}
|
|
746
|
+
|
|
747
|
+
// -----------------------------------------------------------------------------
|
|
748
|
+
// Applications list entry model — the per-row shape the shared list core renders.
|
|
749
|
+
// Discriminated on `variant` so the OSS single-cluster row and the Cloud fleet
|
|
750
|
+
// row (one row spanning several clusters) carry their own fields with no loose
|
|
751
|
+
// optional-superset overlap. The base carries everything the facet rail, fold,
|
|
752
|
+
// counts, and sort read; the variant arms carry only what their instance-row
|
|
753
|
+
// renderer needs. Both arms satisfy AppGroupFoldEntry, so foldAppGroups is shared.
|
|
754
|
+
// -----------------------------------------------------------------------------
|
|
755
|
+
|
|
756
|
+
export interface AppEntryBase {
|
|
757
|
+
row: AppRow
|
|
758
|
+
health: AppHealth
|
|
759
|
+
versions: string[]
|
|
760
|
+
kinds: Record<string, number>
|
|
761
|
+
workloadClass: AppWorkloadClass
|
|
762
|
+
/** Distinct contained classes — the inclusive facet-matching set. */
|
|
763
|
+
classSet: AppWorkloadClass[]
|
|
764
|
+
classComposition: { cls: AppWorkloadClass; count: number }[]
|
|
765
|
+
category: AppCategory
|
|
766
|
+
ready: number
|
|
767
|
+
desired: number
|
|
768
|
+
/** ready/desired as a fraction for sorting; -1 when nothing is desired. */
|
|
769
|
+
readyRatio: number
|
|
770
|
+
source: AppSource
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export interface SingleAppEntry extends AppEntryBase {
|
|
774
|
+
variant: 'single'
|
|
775
|
+
namespace: string
|
|
776
|
+
namespaces: string[]
|
|
777
|
+
env: string
|
|
778
|
+
envInferred: boolean
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** One environment instance within a fleet row (env name + worst health across
|
|
782
|
+
* its clusters + whether the env was inferred from the namespace). */
|
|
783
|
+
export interface EnvSlice {
|
|
784
|
+
env: string
|
|
785
|
+
health: AppHealth
|
|
786
|
+
inferred: boolean
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** A cluster this fleet row's app runs in. */
|
|
790
|
+
export interface AppClusterRef {
|
|
791
|
+
id: string
|
|
792
|
+
name: string
|
|
793
|
+
health: AppHealth
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export interface FleetAppEntry extends AppEntryBase {
|
|
797
|
+
variant: 'fleet'
|
|
798
|
+
envs: EnvSlice[]
|
|
799
|
+
clusters: AppClusterRef[]
|
|
800
|
+
/** True when the app runs different versions across its clusters/envs. */
|
|
801
|
+
versionSkew: boolean
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export type AppEntry = SingleAppEntry | FleetAppEntry
|
|
805
|
+
|
|
806
|
+
/** Build a single-cluster list entry from a wire row. The env is resolved via
|
|
807
|
+
* the server's identity classification (authoritative) with a namespace
|
|
808
|
+
* heuristic fallback for plain rows. */
|
|
809
|
+
export function buildSingleAppEntry(row: AppRow, discoveredEnvs?: ReadonlySet<string>): SingleAppEntry {
|
|
810
|
+
const kinds: Record<string, number> = {}
|
|
811
|
+
let ready = 0
|
|
812
|
+
let desired = 0
|
|
813
|
+
for (const wl of row.workloads || []) {
|
|
814
|
+
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
|
|
815
|
+
ready += wl.ready ?? 0
|
|
816
|
+
desired += wl.desired ?? 0
|
|
817
|
+
}
|
|
818
|
+
const namespace = namespaceOf(row)
|
|
819
|
+
// The server's identity classification carries the authoritative env (label/
|
|
820
|
+
// declared/discovered); plain rows fall back to the trio + discovered-token
|
|
821
|
+
// namespace heuristic.
|
|
822
|
+
const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
823
|
+
const env = row.identity?.env ?? resolved.env
|
|
824
|
+
const inferred = row.identity ? identityEnvInferred(row.identity) : resolved.inferred
|
|
825
|
+
return {
|
|
826
|
+
variant: 'single',
|
|
827
|
+
row,
|
|
828
|
+
health: healthOf(row.health),
|
|
829
|
+
versions: Array.from(new Set((row.versions || []).filter(Boolean))),
|
|
830
|
+
namespace,
|
|
831
|
+
namespaces: namespacesOf(row),
|
|
832
|
+
env,
|
|
833
|
+
envInferred: inferred,
|
|
834
|
+
kinds,
|
|
835
|
+
workloadClass: workloadClassOf(row.workload_class),
|
|
836
|
+
classSet: classSetOf(row),
|
|
837
|
+
classComposition: classCompositionOf(row),
|
|
838
|
+
category: categoryOf(row.category),
|
|
839
|
+
ready,
|
|
840
|
+
desired,
|
|
841
|
+
readyRatio: desired > 0 ? ready / desired : -1,
|
|
842
|
+
source: sourceOf(row.tier),
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** The text an entry matches free-text search against — name, key, namespace,
|
|
847
|
+
* source/class/category labels, versions, env, workload kinds + identity. Pure
|
|
848
|
+
* and exported so the list core and tests share one definition. */
|
|
849
|
+
export function searchTextForEntry(e: AppEntry): string {
|
|
850
|
+
const workloadText = (e.row.workloads || []).flatMap((wl) => [wl.kind, wl.namespace, wl.name, wl.version])
|
|
851
|
+
const envParts = e.variant === 'single' ? [e.env || 'unlabeled'] : e.envs.map((s) => s.env || 'unlabeled')
|
|
852
|
+
const nsParts = e.variant === 'single' ? [e.namespace] : e.clusters.map((c) => c.name)
|
|
853
|
+
return [
|
|
854
|
+
e.row.name,
|
|
855
|
+
e.row.key,
|
|
856
|
+
...nsParts,
|
|
857
|
+
SOURCE_META[e.source].label,
|
|
858
|
+
CLASS_META[e.workloadClass].label,
|
|
859
|
+
...e.classSet.map((c) => CLASS_META[c].label),
|
|
860
|
+
CATEGORY_META[e.category].label,
|
|
861
|
+
...e.versions,
|
|
862
|
+
...envParts,
|
|
863
|
+
...Object.keys(e.kinds),
|
|
864
|
+
...workloadText,
|
|
865
|
+
]
|
|
866
|
+
.filter(Boolean)
|
|
867
|
+
.join(' ')
|
|
868
|
+
.toLowerCase()
|
|
869
|
+
}
|