@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.
@@ -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&apos;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.values(counts).reduce((sum, n) => sum + n, 0)
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
- // null for a key means "not loaded yet" (rendered as a placeholder
261
- // dash in the badge). 0 means "the API replied and confirmed there
262
- // are zero of this kind". Don't conflate them — otherwise the
263
- // sidebar shows a confident "0" for every kind while the count
264
- // payload is still in flight.
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
- const v = resourceCounts[key]
277
- // Treat missing keys in a present resourceCounts payload as
278
- // "the API replied and didn't include this kind" → 0. Treat the
279
- // entire payload being absent as "not loaded" → null (handled
280
- // above).
281
- results[key] = v ?? 0
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 results: Record<string, number> = {}
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
- results[key] = resourceCountsProp![key] ?? 0
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 = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
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
- 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
+ }