@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
|
@@ -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
|
+
}
|