@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
|
@@ -1,167 +1,12 @@
|
|
|
1
|
-
import { useMemo
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { StatusDot, mapHealthToTone } from '../ui/status-tone'
|
|
5
|
-
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
-
import { EmptyState } from '../ui/EmptyState'
|
|
7
|
-
import { SearchBox } from '../ui/SearchBox'
|
|
8
|
-
import { PageHeader } from '../ui/PageHeader'
|
|
9
|
-
import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
|
|
10
|
-
import { Facet, type FacetTone } from '../ui/Facet'
|
|
11
|
-
import { SortableTh, TH_CLASS } from '../ui/SortableTh'
|
|
12
|
-
import { DistributionBar } from '../ui/DistributionBar'
|
|
13
|
-
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
14
|
-
import { pluralize } from '../../utils/pluralize'
|
|
15
|
-
import {
|
|
16
|
-
type AppRow,
|
|
17
|
-
type AppHealth,
|
|
18
|
-
type AppWorkloadClass,
|
|
19
|
-
type AppSource,
|
|
20
|
-
type AppCategory,
|
|
21
|
-
HEALTH_ORDER,
|
|
22
|
-
HEALTH_RANK,
|
|
23
|
-
HEALTH_META,
|
|
24
|
-
CLASS_ORDER,
|
|
25
|
-
CLASS_META,
|
|
26
|
-
CATEGORY_ORDER,
|
|
27
|
-
CATEGORY_META,
|
|
28
|
-
CHIP,
|
|
29
|
-
CHIP_TONE,
|
|
30
|
-
SOURCE_ORDER,
|
|
31
|
-
SOURCE_META,
|
|
32
|
-
categoryOf,
|
|
33
|
-
envRank,
|
|
34
|
-
healthOf,
|
|
35
|
-
isSystemNamespace,
|
|
36
|
-
namespaceOf,
|
|
37
|
-
namespacesOf,
|
|
38
|
-
resolveEnv,
|
|
39
|
-
identityEnvInferred,
|
|
40
|
-
sourceOf,
|
|
41
|
-
workloadClassOf,
|
|
42
|
-
classSetOf,
|
|
43
|
-
classCompositionOf,
|
|
44
|
-
foldAppGroups,
|
|
45
|
-
type FoldedRow,
|
|
46
|
-
} from '../../utils/applications'
|
|
47
|
-
import { ReadyBar } from './ReadyBar'
|
|
48
|
-
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
|
|
49
|
-
import { AppIdentityTooltip, EnvHint } from './AppTooltips'
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { type AppRow, buildSingleAppEntry } from '../../utils/applications'
|
|
3
|
+
import { ApplicationsView } from './ApplicationsView'
|
|
50
4
|
|
|
51
|
-
// ApplicationsList —
|
|
52
|
-
//
|
|
53
|
-
// (
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
interface AppEntry {
|
|
58
|
-
row: AppRow
|
|
59
|
-
health: AppHealth
|
|
60
|
-
versions: string[]
|
|
61
|
-
namespace: string
|
|
62
|
-
namespaces: string[]
|
|
63
|
-
env: string
|
|
64
|
-
envInferred: boolean
|
|
65
|
-
kinds: Record<string, number>
|
|
66
|
-
workloadClass: AppWorkloadClass
|
|
67
|
-
/** Distinct contained classes — the inclusive facet-matching set. */
|
|
68
|
-
classSet: AppWorkloadClass[]
|
|
69
|
-
classComposition: { cls: AppWorkloadClass; count: number }[]
|
|
70
|
-
category: AppCategory
|
|
71
|
-
ready: number
|
|
72
|
-
desired: number
|
|
73
|
-
/** ready/desired as a fraction for sorting; -1 when nothing is desired. */
|
|
74
|
-
readyRatio: number
|
|
75
|
-
source: AppSource
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function buildEntry(row: AppRow, discoveredEnvs?: ReadonlySet<string>): AppEntry {
|
|
79
|
-
const kinds: Record<string, number> = {}
|
|
80
|
-
let ready = 0
|
|
81
|
-
let desired = 0
|
|
82
|
-
for (const wl of row.workloads || []) {
|
|
83
|
-
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
|
|
84
|
-
ready += wl.ready ?? 0
|
|
85
|
-
desired += wl.desired ?? 0
|
|
86
|
-
}
|
|
87
|
-
const namespace = namespaceOf(row)
|
|
88
|
-
// The server's identity classification carries the authoritative env (label/
|
|
89
|
-
// declared/discovered); plain rows fall back to the trio + discovered-token
|
|
90
|
-
// namespace heuristic.
|
|
91
|
-
const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
92
|
-
const env = row.identity?.env ?? resolved.env
|
|
93
|
-
const inferred = row.identity ? identityEnvInferred(row.identity) : resolved.inferred
|
|
94
|
-
return {
|
|
95
|
-
row,
|
|
96
|
-
health: healthOf(row.health),
|
|
97
|
-
versions: Array.from(new Set((row.versions || []).filter(Boolean))),
|
|
98
|
-
namespace,
|
|
99
|
-
namespaces: namespacesOf(row),
|
|
100
|
-
env,
|
|
101
|
-
envInferred: inferred,
|
|
102
|
-
kinds,
|
|
103
|
-
workloadClass: workloadClassOf(row.workload_class),
|
|
104
|
-
classSet: classSetOf(row),
|
|
105
|
-
classComposition: classCompositionOf(row),
|
|
106
|
-
category: categoryOf(row.category),
|
|
107
|
-
ready,
|
|
108
|
-
desired,
|
|
109
|
-
readyRatio: desired > 0 ? ready / desired : -1,
|
|
110
|
-
source: sourceOf(row.tier),
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const envLabel = (env: string) => (env ? env : 'unlabeled')
|
|
115
|
-
|
|
116
|
-
function searchTextForEntry(e: AppEntry): string {
|
|
117
|
-
const workloadText = (e.row.workloads || []).flatMap((wl) => [wl.kind, wl.namespace, wl.name, wl.version])
|
|
118
|
-
return [
|
|
119
|
-
e.row.name,
|
|
120
|
-
e.row.key,
|
|
121
|
-
e.namespace,
|
|
122
|
-
SOURCE_META[e.source].label,
|
|
123
|
-
CLASS_META[e.workloadClass].label,
|
|
124
|
-
...e.classSet.map((c) => CLASS_META[c].label),
|
|
125
|
-
CATEGORY_META[e.category].label,
|
|
126
|
-
...e.versions,
|
|
127
|
-
envLabel(e.env),
|
|
128
|
-
...Object.keys(e.kinds),
|
|
129
|
-
...workloadText,
|
|
130
|
-
]
|
|
131
|
-
.filter(Boolean)
|
|
132
|
-
.join(' ')
|
|
133
|
-
.toLowerCase()
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Availability is the one status facet — map app health onto the shared facet
|
|
137
|
-
// tone so its dots read red/amber/green/grey like the GitOps sync+health facets.
|
|
138
|
-
const HEALTH_TONE: Record<AppHealth, FacetTone> = {
|
|
139
|
-
unhealthy: 'error',
|
|
140
|
-
degraded: 'warning',
|
|
141
|
-
healthy: 'success',
|
|
142
|
-
unknown: 'neutral',
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Sortable columns. `health` is the implicit default (worst-first then name);
|
|
146
|
-
// clicking a sortable header cycles asc → desc → off (back to default).
|
|
147
|
-
type SortKey = 'name' | 'ready' | 'version'
|
|
148
|
-
type SortDir = 'asc' | 'desc'
|
|
149
|
-
|
|
150
|
-
function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
|
|
151
|
-
switch (key) {
|
|
152
|
-
case 'name':
|
|
153
|
-
return a.row.name.localeCompare(b.row.name)
|
|
154
|
-
case 'ready':
|
|
155
|
-
return a.readyRatio - b.readyRatio
|
|
156
|
-
case 'version': {
|
|
157
|
-
// Sort by distinct-version count first (skewed apps cluster), then the
|
|
158
|
-
// first tag for a stable, human-meaningful order.
|
|
159
|
-
const byCount = a.versions.length - b.versions.length
|
|
160
|
-
if (byCount !== 0) return byCount
|
|
161
|
-
return (a.versions[0] ?? '').localeCompare(b.versions[0] ?? '')
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
5
|
+
// ApplicationsList — the OSS single-cluster Applications view. A thin wrapper
|
|
6
|
+
// that maps wire rows to single-cluster entries and drives the shared
|
|
7
|
+
// ApplicationsView core (the facet rail, hero header, fold, search, sort, and
|
|
8
|
+
// keyboard nav all live there). Data + selection are injected. The Cloud fleet
|
|
9
|
+
// view is a sibling wrapper over the same core with variant="fleet".
|
|
165
10
|
|
|
166
11
|
export interface ApplicationsListProps {
|
|
167
12
|
apps: AppRow[]
|
|
@@ -169,417 +14,19 @@ export interface ApplicationsListProps {
|
|
|
169
14
|
}
|
|
170
15
|
|
|
171
16
|
export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
|
|
172
|
-
const [textFilter, setTextFilter] = useState('')
|
|
173
|
-
const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
|
|
174
|
-
const [fEnv, setFEnv] = useState<Set<string>>(new Set())
|
|
175
|
-
const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
|
|
176
|
-
const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
|
|
177
|
-
const [fType, setFType] = useState<Set<AppCategory>>(new Set())
|
|
178
|
-
const [showSystem, setShowSystem] = useState(false)
|
|
179
|
-
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
|
|
180
|
-
|
|
181
17
|
// Env tokens this CLUSTER proved (identity classifications on the wire) feed
|
|
182
18
|
// the namespace heuristic, so sibling-less rows in discovered env namespaces
|
|
183
|
-
//
|
|
19
|
+
// still label without any hardcoded vocabulary.
|
|
184
20
|
const discoveredEnvs = useMemo(() => new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)), [apps])
|
|
185
|
-
const
|
|
186
|
-
// System namespaces are filtered before facet counts so the counts reflect
|
|
187
|
-
// what the user is actually looking at (consistent with the other facets).
|
|
188
|
-
// An app counts as system only when EVERY workload namespace is system —
|
|
189
|
-
// hiding a partly-user app would be worse than showing a partly-system one.
|
|
190
|
-
const isSystemApp = (e: AppEntry) => e.namespaces.length > 0 && e.namespaces.every(isSystemNamespace)
|
|
191
|
-
const all = useMemo(() => (showSystem ? allRaw : allRaw.filter((e) => !isSystemApp(e))), [allRaw, showSystem])
|
|
192
|
-
const systemCount = useMemo(() => allRaw.filter(isSystemApp).length, [allRaw])
|
|
193
|
-
|
|
194
|
-
const entries = useMemo(() => {
|
|
195
|
-
const t = textFilter.trim().toLowerCase()
|
|
196
|
-
const filtered = all.filter((e) => {
|
|
197
|
-
if (t && !searchTextForEntry(e).includes(t)) return false
|
|
198
|
-
if (fHealth.size && !fHealth.has(e.health)) return false
|
|
199
|
-
// Inclusive: a mixed app matches the filter of ANY class it contains.
|
|
200
|
-
if (fClass.size && !e.classSet.some((c) => fClass.has(c))) return false
|
|
201
|
-
if (fType.size && !fType.has(e.category)) return false
|
|
202
|
-
if (fSource.size && !fSource.has(e.source)) return false
|
|
203
|
-
if (fEnv.size && !fEnv.has(e.env || 'none')) return false
|
|
204
|
-
return true
|
|
205
|
-
})
|
|
206
|
-
if (sort) {
|
|
207
|
-
const factor = sort.dir === 'asc' ? 1 : -1
|
|
208
|
-
filtered.sort((a, b) => compareEntries(a, b, sort.key) * factor || a.row.name.localeCompare(b.row.name))
|
|
209
|
-
} else {
|
|
210
|
-
filtered.sort((a, b) => (HEALTH_RANK[b.health] ?? 0) - (HEALTH_RANK[a.health] ?? 0) || a.row.name.localeCompare(b.row.name))
|
|
211
|
-
}
|
|
212
|
-
return filtered
|
|
213
|
-
}, [all, textFilter, fHealth, fEnv, fSource, fClass, fType, sort])
|
|
214
|
-
|
|
215
|
-
// ── App groups ──────────────────────────────────────────────────────────────
|
|
216
|
-
// Instances sharing a wire `identity` fold into one ladder row (foldAppGroups).
|
|
217
|
-
// THE COLLAPSE EXPERIMENT: default collapsed, contingent on (a) text search
|
|
218
|
-
// auto-expanding into hidden instances, (b) instance rows one chevron away,
|
|
219
|
-
// (c) the group chip visibly carrying confidence. If heuristic precision
|
|
220
|
-
// disappoints in the field, default-expand by seeding expandedGroups with
|
|
221
|
-
// every group key instead of an empty set.
|
|
222
|
-
const FAMILY_AUTO_EXPAND_ON_SEARCH = true
|
|
223
|
-
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
224
|
-
const toggleGroup = (key: string) =>
|
|
225
|
-
setExpandedGroups((s) => {
|
|
226
|
-
const n = new Set(s)
|
|
227
|
-
n.has(key) ? n.delete(key) : n.add(key)
|
|
228
|
-
return n
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
const visibleRows = useMemo<FoldedRow<AppEntry>[]>(
|
|
232
|
-
() => foldAppGroups(entries, expandedGroups, FAMILY_AUTO_EXPAND_ON_SEARCH && textFilter.trim() !== ''),
|
|
233
|
-
[entries, expandedGroups, textFilter],
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
// Row keyboard navigation — same contract as the Resources table: j/k or
|
|
237
|
-
// arrows move a highlight, g g / G jump, Enter opens, Escape clears the
|
|
238
|
-
// highlight. The search box hands off via ArrowDown.
|
|
239
|
-
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
240
|
-
const rowsRef = useRef(visibleRows)
|
|
241
|
-
rowsRef.current = visibleRows
|
|
242
|
-
useEffect(() => setHighlightedIndex(-1), [visibleRows])
|
|
243
|
-
const firstOpenableVisibleRow = useCallback(() => {
|
|
244
|
-
const first = rowsRef.current[0]
|
|
245
|
-
if (!first) return null
|
|
246
|
-
return first.kind === 'group' ? first.cells[0]?.firstKey ?? null : first.entry.row.key
|
|
247
|
-
}, [])
|
|
248
|
-
const moveHighlight = (delta: number) =>
|
|
249
|
-
setHighlightedIndex((i) => Math.min(Math.max(i + delta, 0), rowsRef.current.length - 1))
|
|
250
|
-
useRegisterShortcuts([
|
|
251
|
-
{ id: 'applications-nav-down', keys: 'j', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
|
|
252
|
-
{ id: 'applications-nav-down-arrow', keys: 'ArrowDown', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
|
|
253
|
-
{ id: 'applications-nav-up', keys: 'k', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
|
|
254
|
-
{ id: 'applications-nav-up-arrow', keys: 'ArrowUp', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
|
|
255
|
-
{ id: 'applications-nav-top', keys: 'g g', description: 'Jump to first row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length > 0 ? 0 : -1) },
|
|
256
|
-
{ id: 'applications-nav-bottom', keys: 'G', description: 'Jump to last row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length - 1) },
|
|
257
|
-
{
|
|
258
|
-
id: 'applications-open', keys: 'Enter', description: 'Open application', category: 'Table', scope: 'applications',
|
|
259
|
-
handler: () => {
|
|
260
|
-
const r = rowsRef.current[highlightedIndex]
|
|
261
|
-
if (!r) return
|
|
262
|
-
// Enter on a group toggles it; on an instance, opens it.
|
|
263
|
-
if (r.kind === 'group') toggleGroup(r.key)
|
|
264
|
-
else onSelect(r.entry.row.key)
|
|
265
|
-
},
|
|
266
|
-
enabled: highlightedIndex >= 0,
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
id: 'applications-escape', keys: 'Escape', description: 'Clear row highlight', category: 'Table', scope: 'applications',
|
|
270
|
-
handler: () => setHighlightedIndex(-1),
|
|
271
|
-
enabled: highlightedIndex >= 0,
|
|
272
|
-
},
|
|
273
|
-
])
|
|
274
|
-
|
|
275
|
-
const counts = useMemo(() => {
|
|
276
|
-
const health: Record<string, number> = {}
|
|
277
|
-
const env: Record<string, number> = {}
|
|
278
|
-
const source: Record<string, number> = {}
|
|
279
|
-
const workloadClass: Record<string, number> = {}
|
|
280
|
-
const category: Record<string, number> = {}
|
|
281
|
-
for (const e of all) {
|
|
282
|
-
health[e.health] = (health[e.health] ?? 0) + 1
|
|
283
|
-
for (const c of e.classSet) workloadClass[c] = (workloadClass[c] ?? 0) + 1
|
|
284
|
-
source[e.source] = (source[e.source] ?? 0) + 1
|
|
285
|
-
category[e.category] = (category[e.category] ?? 0) + 1
|
|
286
|
-
env[e.env || 'none'] = (env[e.env || 'none'] ?? 0) + 1
|
|
287
|
-
}
|
|
288
|
-
return { health, env, source, workloadClass, category }
|
|
289
|
-
}, [all])
|
|
290
|
-
|
|
291
|
-
const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
|
|
292
|
-
const next = new Set(set)
|
|
293
|
-
next.has(v) ? next.delete(v) : next.add(v)
|
|
294
|
-
setter(next)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// asc → desc → off (null = default health-worst-first sort).
|
|
298
|
-
const onSort = (key: SortKey) => {
|
|
299
|
-
setSort((prev) => {
|
|
300
|
-
if (!prev || prev.key !== key) return { key, dir: 'asc' }
|
|
301
|
-
if (prev.dir === 'asc') return { key, dir: 'desc' }
|
|
302
|
-
return null
|
|
303
|
-
})
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const total = all.length
|
|
307
|
-
const envOptions = Object.entries(counts.env)
|
|
308
|
-
.sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
|
|
309
|
-
.map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
|
|
310
|
-
|
|
311
|
-
// Clickable status tile wired to the health facet — tap to filter to that tier.
|
|
312
|
-
const healthTile = (h: AppHealth, tone: SummaryTone) =>
|
|
313
|
-
counts.health[h] ? (
|
|
314
|
-
<SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => toggle(fHealth, setFHealth, h)} />
|
|
315
|
-
) : null
|
|
316
|
-
|
|
317
|
-
// showSystem lives in the Filters rail, so Clear resets it too (and its
|
|
318
|
-
// non-default ON state counts as an active filter that surfaces the button).
|
|
319
|
-
const anyFilterActive = !!(textFilter || fHealth.size || fClass.size || fType.size || fSource.size || fEnv.size || showSystem)
|
|
320
|
-
const clearAllFilters = () => {
|
|
321
|
-
setTextFilter('')
|
|
322
|
-
setFHealth(new Set())
|
|
323
|
-
setFClass(new Set())
|
|
324
|
-
setFType(new Set())
|
|
325
|
-
setFSource(new Set())
|
|
326
|
-
setFEnv(new Set())
|
|
327
|
-
setShowSystem(false)
|
|
328
|
-
}
|
|
21
|
+
const entries = useMemo(() => apps.map((a) => buildSingleAppEntry(a, discoveredEnvs)), [apps, discoveredEnvs])
|
|
329
22
|
|
|
330
23
|
return (
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
icon={Boxes}
|
|
339
|
-
title="Applications"
|
|
340
|
-
description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
|
|
341
|
-
actions={
|
|
342
|
-
<>
|
|
343
|
-
<SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
|
|
344
|
-
{healthTile('unhealthy', 'error')}
|
|
345
|
-
{healthTile('degraded', 'warning')}
|
|
346
|
-
{healthTile('healthy', 'success')}
|
|
347
|
-
{healthTile('unknown', 'neutral')}
|
|
348
|
-
</>
|
|
349
|
-
}
|
|
350
|
-
/>
|
|
351
|
-
<DistributionBar
|
|
352
|
-
className="mt-3"
|
|
353
|
-
ariaLabel="Health distribution"
|
|
354
|
-
segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
|
|
355
|
-
/>
|
|
356
|
-
</div>
|
|
357
|
-
|
|
358
|
-
{/* Body: filter sidebar | content (toolbar + table). */}
|
|
359
|
-
<div className="flex min-w-0 flex-1 overflow-hidden max-[899px]:flex-col">
|
|
360
|
-
{/* Filters sidebar — titled, with Clear; mirrors the GitOps facet rail.
|
|
361
|
-
Arbitrary 900px breakpoint (not `sm`) so the collapse is independent of
|
|
362
|
-
the consuming app's Tailwind breakpoint config (Hub uses defaults). */}
|
|
363
|
-
<aside className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b max-[899px]:border-r-0">
|
|
364
|
-
<div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
|
|
365
|
-
<span className="text-sm font-medium text-theme-text-secondary">Filters</span>
|
|
366
|
-
{anyFilterActive && (
|
|
367
|
-
<button type="button" onClick={clearAllFilters} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">Clear</button>
|
|
368
|
-
)}
|
|
369
|
-
</div>
|
|
370
|
-
<div className="flex-1 overflow-y-auto">
|
|
371
|
-
<Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => toggle(fHealth, setFHealth, v)} />
|
|
372
|
-
<Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => toggle(fClass, setFClass, v)} />
|
|
373
|
-
<Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => toggle(fType, setFType, v)} />
|
|
374
|
-
<Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
|
|
375
|
-
<Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => toggle(fSource, setFSource, v)} />
|
|
376
|
-
{systemCount > 0 && (
|
|
377
|
-
<label className="flex cursor-pointer items-center gap-2 border-b border-theme-border px-3 py-2 text-[11px] text-theme-text-secondary hover:bg-theme-hover">
|
|
378
|
-
<input type="checkbox" checked={showSystem} onChange={(e) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
|
|
379
|
-
<span>Show system namespaces</span>
|
|
380
|
-
<span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
|
|
381
|
-
</label>
|
|
382
|
-
)}
|
|
383
|
-
</div>
|
|
384
|
-
</aside>
|
|
385
|
-
|
|
386
|
-
{/* Content: toolbar (search + sort) over the scrollable table. */}
|
|
387
|
-
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
388
|
-
<div className="flex shrink-0 items-center gap-3 border-b border-theme-border px-4 py-3">
|
|
389
|
-
<SearchBox
|
|
390
|
-
value={textFilter}
|
|
391
|
-
onChange={setTextFilter}
|
|
392
|
-
scope="applications"
|
|
393
|
-
shortcutId="applications-search"
|
|
394
|
-
className="max-w-md flex-1"
|
|
395
|
-
onEnter={() => {
|
|
396
|
-
const key = highlightedIndex >= 0 && rowsRef.current[highlightedIndex]?.kind === 'instance'
|
|
397
|
-
? (rowsRef.current[highlightedIndex] as Extract<FoldedRow<AppEntry>, { kind: 'instance' }>).entry.row.key
|
|
398
|
-
: firstOpenableVisibleRow()
|
|
399
|
-
if (key) onSelect(key)
|
|
400
|
-
}}
|
|
401
|
-
onArrowDown={() => {
|
|
402
|
-
if (visibleRows.length > 0) setHighlightedIndex(0)
|
|
403
|
-
}}
|
|
404
|
-
/>
|
|
405
|
-
{/* Sorting is via the clickable column headers (Resources-table
|
|
406
|
-
pattern) — no separate sort control. */}
|
|
407
|
-
</div>
|
|
408
|
-
|
|
409
|
-
<div className="min-w-0 flex-1 overflow-auto bg-theme-base">
|
|
410
|
-
{entries.length === 0 ? (
|
|
411
|
-
<div className="p-4">
|
|
412
|
-
<EmptyState
|
|
413
|
-
tone="filtered"
|
|
414
|
-
variant="card"
|
|
415
|
-
headline={total === 0 ? 'No applications detected yet' : 'No applications match the filters'}
|
|
416
|
-
body={total === 0 ? 'Deploy services, workers, or jobs to this cluster to see them grouped by app.' : 'Clear the filters above.'}
|
|
417
|
-
/>
|
|
418
|
-
</div>
|
|
419
|
-
) : (
|
|
420
|
-
<table className="w-full text-left text-sm">
|
|
421
|
-
<thead className="sticky top-0 z-10 bg-theme-base">
|
|
422
|
-
<tr>
|
|
423
|
-
<SortableTh label="Application" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
|
|
424
|
-
<th className={TH_CLASS}>Namespace</th>
|
|
425
|
-
<th className={TH_CLASS}>Env</th>
|
|
426
|
-
<th className={TH_CLASS}>Class</th>
|
|
427
|
-
<SortableTh label="Ready" sortKey="ready" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
|
|
428
|
-
<SortableTh label="Version" sortKey="version" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
|
|
429
|
-
<th className={TH_CLASS}>Workloads</th>
|
|
430
|
-
<th className={clsx(TH_CLASS, 'w-8')} />
|
|
431
|
-
</tr>
|
|
432
|
-
</thead>
|
|
433
|
-
<tbody>
|
|
434
|
-
{visibleRows.map((r, idx) => r.kind === 'group' ? (
|
|
435
|
-
<tr
|
|
436
|
-
key={`group:${r.key}`}
|
|
437
|
-
ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
|
|
438
|
-
aria-expanded={r.expanded}
|
|
439
|
-
className={clsx(
|
|
440
|
-
'group/row cursor-pointer border-b-subtle',
|
|
441
|
-
idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
442
|
-
)}
|
|
443
|
-
onClick={() => toggleGroup(r.key)}
|
|
444
|
-
>
|
|
445
|
-
<td className="py-2.5 pl-3 pr-2">
|
|
446
|
-
<span className="flex items-center gap-2">
|
|
447
|
-
{/* Left status stripe (worst-child health) — the row status
|
|
448
|
-
gutter shared with the GitOps table. */}
|
|
449
|
-
<Tooltip content={HEALTH_META[r.health].label} delay={150}>
|
|
450
|
-
<span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[r.health].bar)} />
|
|
451
|
-
</Tooltip>
|
|
452
|
-
<ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
|
|
453
|
-
<span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
|
|
454
|
-
<Tooltip
|
|
455
|
-
content={<AppIdentityTooltip identityKey={r.label} members={r.members.map((m) => ({ name: m.row.name, env: m.row.identity!.env, confidence: m.row.identity!.confidence, evidence: m.row.identity!.evidence }))} />}
|
|
456
|
-
delay={150}
|
|
457
|
-
>
|
|
458
|
-
<span className={`${CHIP} ${r.confidence === 'high' ? CHIP_TONE.emerald : CHIP_TONE.neutral}`}>
|
|
459
|
-
<Layers className="mr-1 h-3 w-3" aria-hidden />{r.cells.length} envs
|
|
460
|
-
</span>
|
|
461
|
-
</Tooltip>
|
|
462
|
-
</span>
|
|
463
|
-
</td>
|
|
464
|
-
<td className="px-2 py-2.5">
|
|
465
|
-
<span className="text-xs text-theme-text-tertiary">{pluralize(r.members.length, 'instance')}</span>
|
|
466
|
-
</td>
|
|
467
|
-
<td className="px-2 py-2.5">
|
|
468
|
-
{/* The ladder: env-ordered cells; click drills into that env's instance. */}
|
|
469
|
-
<span className="flex flex-wrap items-center gap-1">
|
|
470
|
-
{/* Ladder cells scale-capped: a handful inline, the
|
|
471
|
-
rest behind "+N" (expand shows every instance). */}
|
|
472
|
-
{r.cells.slice(0, 4).map((c) => (
|
|
473
|
-
<Tooltip key={c.env} content={`${c.env}${c.version ? ` · ${c.version}` : ''}${c.count > 1 ? ` · ${c.count} instances — expand to choose` : ' — open'}`} delay={150}>
|
|
474
|
-
<button
|
|
475
|
-
type="button"
|
|
476
|
-
onClick={(ev) => {
|
|
477
|
-
ev.stopPropagation()
|
|
478
|
-
if (c.count > 1) toggleGroup(r.key)
|
|
479
|
-
else onSelect(c.firstKey)
|
|
480
|
-
}}
|
|
481
|
-
className={`${CHIP} ${CHIP_TONE.neutral} gap-1 hover:bg-theme-hover`}
|
|
482
|
-
>
|
|
483
|
-
<StatusDot tone={mapHealthToTone(c.health)} />{c.env}
|
|
484
|
-
</button>
|
|
485
|
-
</Tooltip>
|
|
486
|
-
))}
|
|
487
|
-
{r.cells.length > 4 && (
|
|
488
|
-
<Tooltip content={`${r.cells.length - 4} more environments — expand to see all instances`} delay={150}>
|
|
489
|
-
<button
|
|
490
|
-
type="button"
|
|
491
|
-
onClick={(ev) => { ev.stopPropagation(); toggleGroup(r.key) }}
|
|
492
|
-
className={`${CHIP} ${CHIP_TONE.muted} hover:bg-theme-hover`}
|
|
493
|
-
>
|
|
494
|
-
+{r.cells.length - 4}
|
|
495
|
-
</button>
|
|
496
|
-
</Tooltip>
|
|
497
|
-
)}
|
|
498
|
-
</span>
|
|
499
|
-
</td>
|
|
500
|
-
<td className="px-2 py-2.5"><ClassBadge workloadClass={r.workloadClass} composition={r.classComposition} /></td>
|
|
501
|
-
<td className="px-2 py-2.5"><ReadyBar ready={r.ready} desired={r.desired} /></td>
|
|
502
|
-
<td className="px-2 py-2.5">
|
|
503
|
-
{r.lag ? (
|
|
504
|
-
<Tooltip content={`Promotion lag: ${r.lag} (${r.cells.filter((c) => c.version).map((c) => `${c.env}=${c.version}`).join(', ')})`} delay={150}>
|
|
505
|
-
<span className={`${CHIP} ${CHIP_TONE.amber}`}>{r.lag}</span>
|
|
506
|
-
</Tooltip>
|
|
507
|
-
) : (
|
|
508
|
-
<span className="text-theme-text-tertiary">—</span>
|
|
509
|
-
)}
|
|
510
|
-
</td>
|
|
511
|
-
<td className="px-2 py-2.5">
|
|
512
|
-
<span className="text-xs text-theme-text-secondary">{Object.entries(r.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
|
|
513
|
-
</td>
|
|
514
|
-
<td className="pr-2 text-right" />
|
|
515
|
-
</tr>
|
|
516
|
-
) : ((e) => (
|
|
517
|
-
<tr
|
|
518
|
-
key={e.row.key}
|
|
519
|
-
ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
|
|
520
|
-
className={clsx(
|
|
521
|
-
'group/row cursor-pointer border-b-subtle',
|
|
522
|
-
idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
523
|
-
)}
|
|
524
|
-
onClick={() => onSelect(e.row.key)}
|
|
525
|
-
>
|
|
526
|
-
<td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
|
|
527
|
-
<span className="flex items-center gap-2">
|
|
528
|
-
<Tooltip content={HEALTH_META[e.health].label} delay={150}>
|
|
529
|
-
<span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[e.health].bar)} />
|
|
530
|
-
</Tooltip>
|
|
531
|
-
<span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
|
|
532
|
-
<ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
|
|
533
|
-
<CategoryChip category={e.category} addonReason={e.row.addonReason} />
|
|
534
|
-
</span>
|
|
535
|
-
</td>
|
|
536
|
-
<td className="px-2 py-2.5">
|
|
537
|
-
{e.namespace ? (
|
|
538
|
-
<span className="truncate font-mono text-xs text-theme-text-secondary">{e.namespace}</span>
|
|
539
|
-
) : e.namespaces.length > 1 ? (
|
|
540
|
-
<Tooltip content={e.namespaces.join(', ')} delay={150}>
|
|
541
|
-
<span className="text-xs text-theme-text-secondary">{e.namespaces.length} namespaces</span>
|
|
542
|
-
</Tooltip>
|
|
543
|
-
) : (
|
|
544
|
-
<span className="text-theme-text-tertiary">—</span>
|
|
545
|
-
)}
|
|
546
|
-
</td>
|
|
547
|
-
<td className="px-2 py-2.5">
|
|
548
|
-
{e.env ? (
|
|
549
|
-
e.envInferred ? (
|
|
550
|
-
<Tooltip content={`Inferred from namespace "${e.namespace || e.env}" — confirm with an environment label.`} delay={150}>
|
|
551
|
-
<span className={`${CHIP} italic ${CHIP_TONE.muted}`}>~{e.env}</span>
|
|
552
|
-
</Tooltip>
|
|
553
|
-
) : (
|
|
554
|
-
<span className={`${CHIP} ${CHIP_TONE.neutral}`}>{e.env}</span>
|
|
555
|
-
)
|
|
556
|
-
) : (
|
|
557
|
-
<Tooltip content={<EnvHint unlabeled />} delay={300}>
|
|
558
|
-
<span className="cursor-default text-theme-text-tertiary">—</span>
|
|
559
|
-
</Tooltip>
|
|
560
|
-
)}
|
|
561
|
-
</td>
|
|
562
|
-
<td className="px-2 py-2.5"><ClassBadge workloadClass={e.workloadClass} composition={e.classComposition} /></td>
|
|
563
|
-
<td className="px-2 py-2.5"><ReadyBar ready={e.ready} desired={e.desired} /></td>
|
|
564
|
-
<td className="px-2 py-2.5">
|
|
565
|
-
<VersionInfo app={e.row} variant="cell" />
|
|
566
|
-
</td>
|
|
567
|
-
<td className="px-2 py-2.5">
|
|
568
|
-
{Object.keys(e.kinds).length === 0 ? (
|
|
569
|
-
<span className="text-xs text-theme-text-tertiary">—</span>
|
|
570
|
-
) : (
|
|
571
|
-
<span className="text-xs text-theme-text-secondary">{Object.entries(e.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
|
|
572
|
-
)}
|
|
573
|
-
</td>
|
|
574
|
-
<td className="pr-2 text-right"><ChevronRight className="inline h-4 w-4 text-theme-text-tertiary" /></td>
|
|
575
|
-
</tr>
|
|
576
|
-
))(r.entry))}
|
|
577
|
-
</tbody>
|
|
578
|
-
</table>
|
|
579
|
-
)}
|
|
580
|
-
</div>
|
|
581
|
-
</div>
|
|
582
|
-
</div>
|
|
583
|
-
</div>
|
|
24
|
+
<ApplicationsView
|
|
25
|
+
variant="single"
|
|
26
|
+
entries={entries}
|
|
27
|
+
onSelect={onSelect}
|
|
28
|
+
title="Applications"
|
|
29
|
+
description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
|
|
30
|
+
/>
|
|
584
31
|
)
|
|
585
32
|
}
|