@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.
@@ -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
- return id.portable ? `portable:${id.key}` : `local:${scope}:${id.key}`
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
- const cur = cellMap.get(env)
524
- if (!cur) {
525
- cellMap.set(env, { env, health: m.health, version: v, count: 1, firstKey: m.row.key })
526
- } else {
527
- cur.count++
528
- if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0)) cur.health = m.health
529
- if (v && (!cur.version || compareVersions(v, cur.version) === 1)) cur.version = v
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
+ }
@@ -0,0 +1,4 @@
1
+ /** True when running on a macOS / iOS platform (drives ⌘ vs Ctrl conventions). */
2
+ export function isMac(): boolean {
3
+ return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
4
+ }