@skyhook-io/k8s-ui 1.7.11 → 1.7.13
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/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +64 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +43 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -1
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- package/src/utils/workload-colors.ts +36 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { ArrowLeft, Boxes, Network, Layers, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import type { Topology, TopologyNode } from '../../types'
|
|
5
|
+
import { StatusDot, mapHealthToTone } from '../ui/status-tone'
|
|
6
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
7
|
+
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
8
|
+
import { pluralize } from '../../utils/pluralize'
|
|
9
|
+
import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
10
|
+
import { tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
|
|
11
|
+
import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
12
|
+
import {
|
|
13
|
+
type AppRow,
|
|
14
|
+
type AppWorkload,
|
|
15
|
+
type AppHealth,
|
|
16
|
+
CHIP,
|
|
17
|
+
CHIP_TONE,
|
|
18
|
+
HEALTH_META,
|
|
19
|
+
healthOf,
|
|
20
|
+
namespaceOf,
|
|
21
|
+
namespacesOf,
|
|
22
|
+
resolveEnv,
|
|
23
|
+
identityEnvInferred,
|
|
24
|
+
workloadClassOf,
|
|
25
|
+
classCompositionOf,
|
|
26
|
+
worstHealth,
|
|
27
|
+
appGroupLagMessage,
|
|
28
|
+
compareVersions,
|
|
29
|
+
} from '../../utils/applications'
|
|
30
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
31
|
+
import { midTruncate } from '../../utils/format'
|
|
32
|
+
import { VersionTooltip, AppIdentityTooltip } from './AppTooltips'
|
|
33
|
+
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
|
|
34
|
+
import { ReadyBar } from './ReadyBar'
|
|
35
|
+
|
|
36
|
+
// ApplicationDetail — pure single-cluster detail shell. Owns the title row
|
|
37
|
+
// (icon, name, provenance/ungrouped chip, add-on/mixed chip, class badge,
|
|
38
|
+
// health pill) + a context strip (Environment · Namespace · Ready · Version) +
|
|
39
|
+
// a workload selector (only when >1 workload). The embedded WorkloadView is
|
|
40
|
+
// injected by the host via the `renderWorkload` render-prop, keyed to the
|
|
41
|
+
// selected workload — the shell never touches data hooks.
|
|
42
|
+
//
|
|
43
|
+
// Count-adaptive landing: a multi-workload app with a topology lands on the
|
|
44
|
+
// app-level graph (the whole app's neighborhood) and drills into a workload's
|
|
45
|
+
// runtime on node/pill click. A single-workload app (or no topology) goes
|
|
46
|
+
// straight to the workload runtime — a graph of one Deployment + a Service
|
|
47
|
+
// adds nothing.
|
|
48
|
+
|
|
49
|
+
export type SelectedAppWorkload = NeighborhoodSeed
|
|
50
|
+
|
|
51
|
+
/** One env instance in the identity switcher — a sibling app row's digest. */
|
|
52
|
+
export interface AppIdentityInstance {
|
|
53
|
+
appKey: string
|
|
54
|
+
name: string
|
|
55
|
+
env: string
|
|
56
|
+
health: AppHealth
|
|
57
|
+
version?: string
|
|
58
|
+
confidence: string
|
|
59
|
+
evidence: string
|
|
60
|
+
count?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Workload selection is either fully controlled (key + callback, the host
|
|
64
|
+
* wires it to the URL so back/forward works) or fully internal — providing
|
|
65
|
+
* only half silently freezes the rail, so the types forbid it. `null` = the
|
|
66
|
+
* app graph landing; a key (see `workloadKey`) = that workload's runtime. */
|
|
67
|
+
type SelectionProps =
|
|
68
|
+
| { selectedWorkloadKey: string | null; onSelectWorkload: (key: string | null) => void }
|
|
69
|
+
| { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined }
|
|
70
|
+
|
|
71
|
+
export type ApplicationDetailProps = {
|
|
72
|
+
app: AppRow
|
|
73
|
+
onBack: () => void
|
|
74
|
+
/** Render the host's WorkloadView for the chosen workload. */
|
|
75
|
+
renderWorkload: (workload: SelectedAppWorkload) => ReactNode
|
|
76
|
+
/** Resources-view topology spanning the app's namespaces. When present and the
|
|
77
|
+
* app has >1 workload, the detail lands on the app graph. */
|
|
78
|
+
topology?: Topology
|
|
79
|
+
/** True while the host's topology fetch is in flight. Without it, a
|
|
80
|
+
* multi-workload landing briefly mounts the first workload's runtime (and
|
|
81
|
+
* fires its data fetches) before jumping to the graph. */
|
|
82
|
+
topologyLoading?: boolean
|
|
83
|
+
/** Open a related (non-workload) resource clicked in the app graph. */
|
|
84
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
85
|
+
/** App identity siblings (this instance included, ladder-ordered) — turns the
|
|
86
|
+
* context strip's Environment fact into a switcher. Identity grouping is
|
|
87
|
+
* classification, not an address: it switches between REAL instances, never
|
|
88
|
+
* an aggregate page. */
|
|
89
|
+
identityInstances?: AppIdentityInstance[] | null
|
|
90
|
+
/** Switch to a sibling instance (host swaps ?app= and, when it can match
|
|
91
|
+
* the current workload in the target, preserves ?workload= + ?tab=). */
|
|
92
|
+
onSwitchInstance?: (appKey: string) => void
|
|
93
|
+
/** Env tokens the cluster proved (the list derives the same set) — keeps a
|
|
94
|
+
* ungrouped app's Environment fact consistent between list and detail. */
|
|
95
|
+
discoveredEnvs?: ReadonlySet<string>
|
|
96
|
+
} & SelectionProps
|
|
97
|
+
|
|
98
|
+
function ContextFact({ label, children }: { label: string; children: ReactNode }) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
101
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{label}</span>
|
|
102
|
+
<span className="min-w-0 truncate text-xs text-theme-text-secondary">{children}</span>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collapseIdentityInstances(instances: AppIdentityInstance[], activeKey: string): AppIdentityInstance[] {
|
|
108
|
+
const byEnv = new Map<string, AppIdentityInstance[]>()
|
|
109
|
+
for (const inst of instances) {
|
|
110
|
+
byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst])
|
|
111
|
+
}
|
|
112
|
+
return Array.from(byEnv.values()).map((group) => {
|
|
113
|
+
const active = group.find((i) => i.appKey === activeKey)
|
|
114
|
+
const newest = group.reduce<AppIdentityInstance | undefined>((best, inst) => {
|
|
115
|
+
if (!best) return inst
|
|
116
|
+
return compareDefinedVersions(inst.version, best.version) === 1 ? inst : best
|
|
117
|
+
}, undefined)
|
|
118
|
+
const display = active ?? newest ?? group[0]
|
|
119
|
+
return {
|
|
120
|
+
...display,
|
|
121
|
+
health: worstHealth(group.map((inst) => inst.health)),
|
|
122
|
+
version: newest?.version,
|
|
123
|
+
count: group.length,
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function compareDefinedVersions(a: string | undefined, b: string | undefined): number {
|
|
129
|
+
if (!a && !b) return 0
|
|
130
|
+
if (a && !b) return 1
|
|
131
|
+
if (!a || !b) return -1
|
|
132
|
+
return compareVersions(a, b) ?? 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function ApplicationDetail({ app, onBack, renderWorkload, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, selectedWorkloadKey, onSelectWorkload }: ApplicationDetailProps) {
|
|
136
|
+
// Stable order regardless of API ordering: rail rows and the per-workload
|
|
137
|
+
// color assignment both follow this array, so an order flap between
|
|
138
|
+
// refetches must not reshuffle rows or reassign a workload's hue.
|
|
139
|
+
const workloads = useMemo(
|
|
140
|
+
() =>
|
|
141
|
+
[...(app.workloads ?? [])].sort(
|
|
142
|
+
(a, b) => a.name.localeCompare(b.name) || a.kind.localeCompare(b.kind) || a.namespace.localeCompare(b.namespace),
|
|
143
|
+
),
|
|
144
|
+
[app.workloads],
|
|
145
|
+
)
|
|
146
|
+
const overall = worstHealth([app.health, ...workloads.map((w) => w.health)])
|
|
147
|
+
const verdictTone = HEALTH_META[overall].pill
|
|
148
|
+
const verdictLabel = HEALTH_META[overall].label
|
|
149
|
+
const workloadClass = workloadClassOf(app.workload_class)
|
|
150
|
+
const versions = useMemo(() => Array.from(new Set((app.versions || []).filter(Boolean))), [app.versions])
|
|
151
|
+
const ready = workloads.reduce((n, w) => n + (w.ready ?? 0), 0)
|
|
152
|
+
const desired = workloads.reduce((n, w) => n + (w.desired ?? 0), 0)
|
|
153
|
+
const restartSignal = restartWarning(workloads)
|
|
154
|
+
// Resolve namespace the same way the list does (the workloads' shared
|
|
155
|
+
// namespace) so env/namespace match across list and detail. Multi-namespace
|
|
156
|
+
// apps get the count, never an arbitrary pick.
|
|
157
|
+
const namespace = namespaceOf(app)
|
|
158
|
+
const namespaces = namespacesOf(app)
|
|
159
|
+
const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
160
|
+
const env = app.identity?.env ?? resolvedEnv.env
|
|
161
|
+
const inferred = app.identity ? identityEnvInferred(app.identity) : resolvedEnv.inferred
|
|
162
|
+
|
|
163
|
+
// The app graph is the landing for multi-workload apps when a topology was
|
|
164
|
+
// injected. A single workload (or no topology) skips straight to runtime.
|
|
165
|
+
const appGraphAvailable = !!topology && workloads.length > 1
|
|
166
|
+
|
|
167
|
+
// `null` = the app graph (landing); a key = that workload's runtime. Initially
|
|
168
|
+
// null so multi-workload apps land on the graph; single-workload apps ignore
|
|
169
|
+
// this and render the lone workload directly. Controlled by the host (URL) when
|
|
170
|
+
// `selectedWorkloadKey` is provided, otherwise internal.
|
|
171
|
+
const [internalSelected, setInternalSelected] = useState<string | null>(null)
|
|
172
|
+
const rawSelected = selectedWorkloadKey !== undefined ? selectedWorkloadKey : internalSelected
|
|
173
|
+
const setSelected = useCallback(
|
|
174
|
+
(key: string | null) => (onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key)),
|
|
175
|
+
[onSelectWorkload],
|
|
176
|
+
)
|
|
177
|
+
const selectedWorkload = rawSelected ? workloads.find((w) => workloadKey(w) === rawSelected) : undefined
|
|
178
|
+
// A stale controlled key (a deleted workload still in the URL) falls back to
|
|
179
|
+
// the app graph rather than silently rendering a different workload under a
|
|
180
|
+
// URL that names the missing one.
|
|
181
|
+
const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) {
|
|
184
|
+
onSelectWorkload?.(null)
|
|
185
|
+
}
|
|
186
|
+
}, [selectedWorkloadKey, selectedWorkload, onSelectWorkload])
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (selectedWorkloadKey === undefined) setInternalSelected(null)
|
|
189
|
+
}, [app.key, selectedWorkloadKey])
|
|
190
|
+
|
|
191
|
+
// Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
|
|
192
|
+
// while the rest of the graph dims. Driven by the rail and, reciprocally, by
|
|
193
|
+
// hovering a node.
|
|
194
|
+
const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
|
|
195
|
+
|
|
196
|
+
const appSeeds = useMemo(
|
|
197
|
+
() => workloads.map((w) => ({ kind: w.kind, namespace: w.namespace, name: w.name })),
|
|
198
|
+
[workloads],
|
|
199
|
+
)
|
|
200
|
+
// Neighborhood subgraph + per-workload color/ownership tagging in one pass.
|
|
201
|
+
const ownership = useMemo(
|
|
202
|
+
() => (topology ? tagWorkloadOwnership(topology, appSeeds) : null),
|
|
203
|
+
[topology, appSeeds],
|
|
204
|
+
)
|
|
205
|
+
const appGraph = ownership?.topology ?? null
|
|
206
|
+
const appGraphFocusId = useMemo(
|
|
207
|
+
() => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
|
|
208
|
+
[topology, appSeeds],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// Hovering a node lights up its owning workload (and the rail row). An
|
|
212
|
+
// unowned node related to exactly ONE workload (a GitOps manager over a
|
|
213
|
+
// single workload here) still focuses that workload, mirroring rail-driven
|
|
214
|
+
// focus. Truly shared nodes clear instead of dimming everything.
|
|
215
|
+
const handleNodeHover = useCallback((node: TopologyNode | null) => {
|
|
216
|
+
if (!node) {
|
|
217
|
+
setFocusedOwnerId(null)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const stamp = ownershipOf(node.data)
|
|
221
|
+
setFocusedOwnerId(stamp.ownerWorkloadId ?? (stamp.focusWorkloadIds.length === 1 ? stamp.focusWorkloadIds[0] : NEUTRAL_OWNER))
|
|
222
|
+
}, [])
|
|
223
|
+
|
|
224
|
+
// A node click in the app graph either drills into one of the app's workloads
|
|
225
|
+
// (its runtime) or opens a related resource (Service/config/…) via the host.
|
|
226
|
+
const handleAppNodeClick = useCallback(
|
|
227
|
+
(node: TopologyNode) => {
|
|
228
|
+
const ns = (node.data?.namespace as string) || ''
|
|
229
|
+
const match = workloads.find((w) => w.kind === node.kind && w.name === node.name && w.namespace === ns)
|
|
230
|
+
if (match) {
|
|
231
|
+
setSelected(workloadKey(match))
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
onNavigateToResource?.({
|
|
235
|
+
kind: kindToPlural(node.kind),
|
|
236
|
+
namespace: ns,
|
|
237
|
+
name: node.name,
|
|
238
|
+
group: apiVersionToGroup(node.data?.apiVersion as string | undefined),
|
|
239
|
+
})
|
|
240
|
+
},
|
|
241
|
+
[workloads, onNavigateToResource, setSelected],
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div className="flex w-full flex-col">
|
|
246
|
+
{/* Title row */}
|
|
247
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-theme-border px-4 py-3 sm:px-6">
|
|
248
|
+
<button type="button" onClick={onBack} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary">
|
|
249
|
+
<ArrowLeft className="h-3.5 w-3.5" aria-hidden /> Applications
|
|
250
|
+
</button>
|
|
251
|
+
<span className="h-6 w-px bg-theme-border" aria-hidden />
|
|
252
|
+
<span className={`flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}>
|
|
253
|
+
<Boxes className="h-4 w-4" aria-hidden />
|
|
254
|
+
</span>
|
|
255
|
+
<h1 className="text-2xl font-semibold text-theme-text-primary">{app.name}</h1>
|
|
256
|
+
<ProvenanceBadge tier={app.tier} appKey={app.key} confidence={app.confidence} />
|
|
257
|
+
<CategoryChip category={app.category} addonReason={app.addonReason} />
|
|
258
|
+
<ClassBadge workloadClass={workloadClass} composition={classCompositionOf(app)} />
|
|
259
|
+
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
|
260
|
+
<span className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}>
|
|
261
|
+
<StatusDot tone={mapHealthToTone(overall)} />
|
|
262
|
+
<span className="text-sm font-semibold">{verdictLabel}</span>
|
|
263
|
+
</span>
|
|
264
|
+
{restartSignal && (
|
|
265
|
+
<Tooltip content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, 'restart')}`} delay={150}>
|
|
266
|
+
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}>
|
|
267
|
+
Pod warning: {restartSignal.reason || 'Restarts'} · {pluralize(restartSignal.restarts, 'restart')}
|
|
268
|
+
</span>
|
|
269
|
+
</Tooltip>
|
|
270
|
+
)}
|
|
271
|
+
{/* Amber only on real skew (same image, different tags) — the context
|
|
272
|
+
strip already covers the multi-image "N versions" case neutrally. */}
|
|
273
|
+
{app.versionSkew && versions.length > 1 && (
|
|
274
|
+
<Tooltip content={<VersionTooltip workloads={workloads} />} delay={150}>
|
|
275
|
+
<span className={`inline-flex items-center rounded-md px-2 py-1 font-mono text-xs ring-1 ring-inset ${CHIP_TONE.amber}`}>{versions.length} versions</span>
|
|
276
|
+
</Tooltip>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{/* Context strip */}
|
|
282
|
+
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-theme-border px-4 py-2 sm:px-6">
|
|
283
|
+
{identityInstances && identityInstances.length > 1 ? (
|
|
284
|
+
// The Environment fact IS the switcher when this app runs in several
|
|
285
|
+
// envs — prominent, in existing header space, no extra row. Inline
|
|
286
|
+
// pills for a handful; a picker beyond that (scales to ~any count).
|
|
287
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
288
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">Environment</span>
|
|
289
|
+
<EnvSwitcher identityKey={app.identity?.key ?? ''} instances={identityInstances} activeKey={app.key} onSwitch={onSwitchInstance} />
|
|
290
|
+
</div>
|
|
291
|
+
) : env ? (
|
|
292
|
+
<ContextFact label="Environment">
|
|
293
|
+
{inferred ? (
|
|
294
|
+
<Tooltip content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`} delay={150}>
|
|
295
|
+
<span className="italic">~{env}</span>
|
|
296
|
+
</Tooltip>
|
|
297
|
+
) : (
|
|
298
|
+
env
|
|
299
|
+
)}
|
|
300
|
+
</ContextFact>
|
|
301
|
+
) : null}
|
|
302
|
+
{namespace ? (
|
|
303
|
+
<ContextFact label="Namespace">
|
|
304
|
+
<span className="font-mono">{namespace}</span>
|
|
305
|
+
</ContextFact>
|
|
306
|
+
) : namespaces.length > 1 ? (
|
|
307
|
+
<ContextFact label="Namespaces">
|
|
308
|
+
<Tooltip content={namespaces.join(', ')} delay={150}>
|
|
309
|
+
<span>{namespaces.length} namespaces</span>
|
|
310
|
+
</Tooltip>
|
|
311
|
+
</ContextFact>
|
|
312
|
+
) : null}
|
|
313
|
+
<ContextFact label="Ready">
|
|
314
|
+
<ReadyBar ready={ready} desired={desired} width="w-16" />
|
|
315
|
+
</ContextFact>
|
|
316
|
+
{(app.appVersion || versions.length > 0) && (
|
|
317
|
+
<ContextFact label="Version">
|
|
318
|
+
<VersionInfo app={app} variant="fact" />
|
|
319
|
+
</ContextFact>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* Body: for a multi-workload app, a workload rail (navigation + color
|
|
324
|
+
legend + hover-focus) beside the app graph or the selected workload's
|
|
325
|
+
runtime. A single-workload app skips straight to its runtime. */}
|
|
326
|
+
{workloads.length > 1 ? (
|
|
327
|
+
<div className="flex h-[calc(100vh-13rem)] min-h-[480px]">
|
|
328
|
+
<WorkloadRail
|
|
329
|
+
workloads={workloads}
|
|
330
|
+
colorByWorkload={ownership?.colorByWorkload ?? null}
|
|
331
|
+
showAllEntry={appGraphAvailable}
|
|
332
|
+
// Without an app graph the body always renders a workload (selected ??
|
|
333
|
+
// workloads[0]); mirror that so the rail marks it active.
|
|
334
|
+
selectedKey={appGraphAvailable ? selected : selected ?? (workloads[0] ? workloadKey(workloads[0]) : null)}
|
|
335
|
+
focusedOwnerId={focusedOwnerId}
|
|
336
|
+
onSelect={setSelected}
|
|
337
|
+
onFocus={setFocusedOwnerId}
|
|
338
|
+
/>
|
|
339
|
+
<div className="relative min-h-0 flex-1 overflow-hidden bg-theme-surface">
|
|
340
|
+
{appGraphAvailable && selected === null ? (
|
|
341
|
+
<TopologyGraph
|
|
342
|
+
topology={appGraph}
|
|
343
|
+
viewMode="resources"
|
|
344
|
+
groupingMode="namespace"
|
|
345
|
+
hideGroupHeader
|
|
346
|
+
onNodeClick={handleAppNodeClick}
|
|
347
|
+
showExportButton={false}
|
|
348
|
+
focusNodeId={appGraphFocusId}
|
|
349
|
+
focusedOwnerId={focusedOwnerId}
|
|
350
|
+
onNodeHover={handleNodeHover}
|
|
351
|
+
/>
|
|
352
|
+
) : selected === null && topologyLoading ? (
|
|
353
|
+
// The graph is this view's landing — hold a loader while the
|
|
354
|
+
// topology fetch is in flight instead of flashing the first
|
|
355
|
+
// workload's runtime (which would fire its data fetches too).
|
|
356
|
+
<PaneLoader label="Loading topology…" className="absolute inset-0" />
|
|
357
|
+
) : (
|
|
358
|
+
<div key={workloadKey(selectedWorkload ?? workloads[0])} className="h-full min-h-0">
|
|
359
|
+
{renderWorkload(selectedWorkload ?? workloads[0])}
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
) : (
|
|
365
|
+
renderRuntime(workloads[0], renderWorkload)
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// WorkloadRail — the app's workloads as a left rail: color legend (each row's
|
|
372
|
+
// swatch matches that workload's node tint in the graph), navigation (click →
|
|
373
|
+
// that workload's runtime in place), and hover-focus (hover → that workload's
|
|
374
|
+
// nodes stay lit, the rest dim). Reciprocal: hovering a node lights its row.
|
|
375
|
+
function WorkloadRail({
|
|
376
|
+
workloads,
|
|
377
|
+
colorByWorkload,
|
|
378
|
+
showAllEntry,
|
|
379
|
+
selectedKey,
|
|
380
|
+
focusedOwnerId,
|
|
381
|
+
onSelect,
|
|
382
|
+
onFocus,
|
|
383
|
+
}: {
|
|
384
|
+
workloads: AppWorkload[]
|
|
385
|
+
colorByWorkload: Map<string, number> | null
|
|
386
|
+
showAllEntry: boolean
|
|
387
|
+
selectedKey: string | null
|
|
388
|
+
focusedOwnerId: WorkloadFocus
|
|
389
|
+
onSelect: (key: string | null) => void
|
|
390
|
+
onFocus: (owner: WorkloadFocus) => void
|
|
391
|
+
}) {
|
|
392
|
+
return (
|
|
393
|
+
<div
|
|
394
|
+
className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2"
|
|
395
|
+
onMouseLeave={() => onFocus(null)}
|
|
396
|
+
>
|
|
397
|
+
<div className="flex items-center justify-between px-1.5 pb-1 pt-0.5">
|
|
398
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Workloads</span>
|
|
399
|
+
<span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">{workloads.length}</span>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{showAllEntry && (
|
|
403
|
+
<RailRow
|
|
404
|
+
active={selectedKey === null}
|
|
405
|
+
onClick={() => onSelect(null)}
|
|
406
|
+
onMouseEnter={() => onFocus(null)}
|
|
407
|
+
swatch={<Network className="h-3.5 w-3.5 text-theme-text-secondary" aria-hidden />}
|
|
408
|
+
title="All workloads"
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
411
|
+
|
|
412
|
+
{workloads.map((w) => {
|
|
413
|
+
const key = workloadKey(w)
|
|
414
|
+
const idx = colorByWorkload?.get(key)
|
|
415
|
+
const hue = idx != null ? workloadHue(idx) : null
|
|
416
|
+
return (
|
|
417
|
+
<RailRow
|
|
418
|
+
key={key}
|
|
419
|
+
active={selectedKey === key}
|
|
420
|
+
focused={focusedOwnerId === key}
|
|
421
|
+
onClick={() => onSelect(key)}
|
|
422
|
+
onMouseEnter={() => onFocus(key)}
|
|
423
|
+
swatch={
|
|
424
|
+
<span
|
|
425
|
+
className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border ring-1 ring-inset ring-theme-border"
|
|
426
|
+
style={hue ? { background: hue.swatch } : undefined}
|
|
427
|
+
/>
|
|
428
|
+
}
|
|
429
|
+
title={w.name}
|
|
430
|
+
tooltip={`${w.name} · ${w.kind}`}
|
|
431
|
+
subtitle={w.kind}
|
|
432
|
+
trailing={<StatusDot tone={mapHealthToTone(healthOf(w.health))} />}
|
|
433
|
+
/>
|
|
434
|
+
)
|
|
435
|
+
})}
|
|
436
|
+
|
|
437
|
+
{colorByWorkload && (
|
|
438
|
+
<RailRow
|
|
439
|
+
muted
|
|
440
|
+
focused={focusedOwnerId === NEUTRAL_OWNER}
|
|
441
|
+
onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
|
|
442
|
+
swatch={<span className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border" />}
|
|
443
|
+
title="Shared / unscoped"
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function RailRow({
|
|
451
|
+
active,
|
|
452
|
+
focused,
|
|
453
|
+
muted,
|
|
454
|
+
onClick,
|
|
455
|
+
onMouseEnter,
|
|
456
|
+
swatch,
|
|
457
|
+
title,
|
|
458
|
+
tooltip,
|
|
459
|
+
subtitle,
|
|
460
|
+
trailing,
|
|
461
|
+
}: {
|
|
462
|
+
active?: boolean
|
|
463
|
+
focused?: boolean
|
|
464
|
+
muted?: boolean
|
|
465
|
+
onClick?: () => void
|
|
466
|
+
onMouseEnter?: () => void
|
|
467
|
+
swatch: ReactNode
|
|
468
|
+
title: string
|
|
469
|
+
/** Full label shown on hover — the row title truncates. */
|
|
470
|
+
tooltip?: string
|
|
471
|
+
subtitle?: string
|
|
472
|
+
trailing?: ReactNode
|
|
473
|
+
}) {
|
|
474
|
+
const className = clsx(
|
|
475
|
+
'flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors',
|
|
476
|
+
active
|
|
477
|
+
? 'selection selection-ring'
|
|
478
|
+
: focused
|
|
479
|
+
? 'bg-theme-hover'
|
|
480
|
+
: onClick && 'hover:bg-theme-hover',
|
|
481
|
+
!onClick && 'cursor-default',
|
|
482
|
+
)
|
|
483
|
+
const titleEl = <span className={clsx('block truncate text-xs', muted ? 'text-theme-text-tertiary' : 'font-medium text-theme-text-primary')}>{title}</span>
|
|
484
|
+
const inner = (
|
|
485
|
+
<>
|
|
486
|
+
{swatch}
|
|
487
|
+
<span className="min-w-0 flex-1">
|
|
488
|
+
{tooltip ? <Tooltip content={tooltip} delay={300} position="right">{titleEl}</Tooltip> : titleEl}
|
|
489
|
+
{subtitle && <span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">{subtitle}</span>}
|
|
490
|
+
</span>
|
|
491
|
+
{trailing}
|
|
492
|
+
</>
|
|
493
|
+
)
|
|
494
|
+
return onClick ? (
|
|
495
|
+
<button type="button" onClick={onClick} onMouseEnter={onMouseEnter} className={className}>{inner}</button>
|
|
496
|
+
) : (
|
|
497
|
+
<div onMouseEnter={onMouseEnter} className={className}>{inner}</div>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// renderRuntime shows the single-workload app's runtime (host-injected
|
|
502
|
+
// WorkloadView). Multi-workload apps render runtimes beside the rail instead,
|
|
503
|
+
// which also owns navigation back to the app graph.
|
|
504
|
+
function renderRuntime(
|
|
505
|
+
workload: SelectedAppWorkload | undefined,
|
|
506
|
+
renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
|
|
507
|
+
): ReactNode {
|
|
508
|
+
if (!workload) {
|
|
509
|
+
return (
|
|
510
|
+
<div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
|
|
511
|
+
This application has no inspectable workloads.
|
|
512
|
+
</div>
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
return (
|
|
516
|
+
// Concrete height (not flex-1 of an unsized column): the workload's
|
|
517
|
+
// Topology tab positions ReactFlow absolutely and collapses to zero inside
|
|
518
|
+
// a content-sized ancestor. Mirrors the multi-workload rail row's height.
|
|
519
|
+
<div className="flex h-[calc(100vh-13rem)] min-h-[480px] flex-col">
|
|
520
|
+
<div key={workloadKey(workload)} className="min-h-0 flex-1">{renderWorkload(workload)}</div>
|
|
521
|
+
</div>
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function restartWarning(workloads: AppWorkload[]): { restarts: number; reason?: string; workload: string } | null {
|
|
526
|
+
let worst: { restarts: number; reason?: string; workload: string } | null = null
|
|
527
|
+
for (const w of workloads) {
|
|
528
|
+
const r = w.restarts ?? 0
|
|
529
|
+
if (r > 0 && (!worst || r > worst.restarts)) {
|
|
530
|
+
worst = { restarts: r, reason: w.reason, workload: `${w.kind}/${w.name}` }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return worst
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// EnvSwitcher — the Environment fact's interactive form when one app runs in
|
|
537
|
+
// several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
|
|
538
|
+
// ladder). More: a picker popover with the full ladder-ordered list — same
|
|
539
|
+
// affordance at 5 envs or 100. Always ends with the evidence chip
|
|
540
|
+
// (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
|
|
541
|
+
// the amber lag chip.
|
|
542
|
+
const MAX_INLINE_ENVS = 4
|
|
543
|
+
|
|
544
|
+
function EnvSwitcher({
|
|
545
|
+
identityKey,
|
|
546
|
+
instances,
|
|
547
|
+
activeKey,
|
|
548
|
+
onSwitch,
|
|
549
|
+
}: {
|
|
550
|
+
identityKey: string
|
|
551
|
+
instances: AppIdentityInstance[]
|
|
552
|
+
activeKey: string
|
|
553
|
+
onSwitch?: (appKey: string) => void
|
|
554
|
+
}) {
|
|
555
|
+
const [open, setOpen] = useState(false)
|
|
556
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
if (!open) return
|
|
559
|
+
const onDown = (e: MouseEvent) => {
|
|
560
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
561
|
+
}
|
|
562
|
+
const onKey = (e: KeyboardEvent) => {
|
|
563
|
+
if (e.key === 'Escape') setOpen(false)
|
|
564
|
+
}
|
|
565
|
+
document.addEventListener('mousedown', onDown)
|
|
566
|
+
document.addEventListener('keydown', onKey)
|
|
567
|
+
return () => {
|
|
568
|
+
document.removeEventListener('mousedown', onDown)
|
|
569
|
+
document.removeEventListener('keydown', onKey)
|
|
570
|
+
}
|
|
571
|
+
}, [open])
|
|
572
|
+
|
|
573
|
+
const envInstances = useMemo(() => collapseIdentityInstances(instances, activeKey), [instances, activeKey])
|
|
574
|
+
const lag = appGroupLagMessage(envInstances)
|
|
575
|
+
const active = envInstances.find((i) => i.appKey === activeKey) ?? instances.find((i) => i.appKey === activeKey)
|
|
576
|
+
const envCounts = useMemo(() => {
|
|
577
|
+
const counts = new Map<string, number>()
|
|
578
|
+
for (const inst of instances) counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1)
|
|
579
|
+
return counts
|
|
580
|
+
}, [instances])
|
|
581
|
+
const evidenceChip = (
|
|
582
|
+
<Tooltip
|
|
583
|
+
content={<AppIdentityTooltip identityKey={identityKey} members={instances.map((i) => ({ name: i.name, env: i.env, confidence: i.confidence, evidence: i.evidence }))} />}
|
|
584
|
+
delay={150}
|
|
585
|
+
>
|
|
586
|
+
<span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
|
|
587
|
+
<Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
|
|
588
|
+
</span>
|
|
589
|
+
</Tooltip>
|
|
590
|
+
)
|
|
591
|
+
const lagChip = lag && <span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
|
|
592
|
+
|
|
593
|
+
if (envInstances.length <= MAX_INLINE_ENVS) {
|
|
594
|
+
return (
|
|
595
|
+
<span className="flex flex-wrap items-center gap-1">
|
|
596
|
+
{instances.map((inst) => {
|
|
597
|
+
const isActive = inst.appKey === activeKey
|
|
598
|
+
const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1
|
|
599
|
+
return (
|
|
600
|
+
<Tooltip key={inst.appKey} content={`${inst.name}${inst.version ? ` · ${inst.version}` : ''}`} delay={150}>
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
disabled={isActive}
|
|
604
|
+
onClick={() => !isActive && onSwitch?.(inst.appKey)}
|
|
605
|
+
className={clsx(
|
|
606
|
+
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors',
|
|
607
|
+
isActive ? 'selection selection-ring font-medium' : 'bg-theme-surface ring-theme-border hover:bg-theme-hover',
|
|
608
|
+
)}
|
|
609
|
+
>
|
|
610
|
+
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
611
|
+
{inst.env}
|
|
612
|
+
{duplicateEnv ? <span className="max-w-24 truncate text-theme-text-tertiary">{inst.name}</span> : null}
|
|
613
|
+
</button>
|
|
614
|
+
</Tooltip>
|
|
615
|
+
)
|
|
616
|
+
})}
|
|
617
|
+
{evidenceChip}
|
|
618
|
+
{lagChip}
|
|
619
|
+
</span>
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<div ref={rootRef} className="relative flex items-center gap-1">
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
onClick={() => setOpen((o) => !o)}
|
|
628
|
+
aria-haspopup="listbox"
|
|
629
|
+
aria-expanded={open}
|
|
630
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-theme-surface px-2 py-0.5 text-xs ring-1 ring-inset ring-theme-border hover:bg-theme-hover"
|
|
631
|
+
>
|
|
632
|
+
{active && <StatusDot tone={mapHealthToTone(active.health)} />}
|
|
633
|
+
<span className="font-medium">{active?.env ?? '—'}</span>
|
|
634
|
+
<span className="text-theme-text-tertiary">· {envInstances.length} environments</span>
|
|
635
|
+
<ChevronDown className={clsx('h-3 w-3 text-theme-text-tertiary transition-transform', open && 'rotate-180')} aria-hidden />
|
|
636
|
+
</button>
|
|
637
|
+
{evidenceChip}
|
|
638
|
+
{lagChip}
|
|
639
|
+
{open && (
|
|
640
|
+
<div role="listbox" className="absolute left-0 top-full z-50 mt-1 max-h-80 w-80 overflow-y-auto rounded-md border border-theme-border bg-theme-surface p-1 shadow-theme-md">
|
|
641
|
+
{instances.map((inst) => {
|
|
642
|
+
const isActive = inst.appKey === activeKey
|
|
643
|
+
return (
|
|
644
|
+
<button
|
|
645
|
+
key={inst.appKey}
|
|
646
|
+
type="button"
|
|
647
|
+
role="option"
|
|
648
|
+
aria-selected={isActive}
|
|
649
|
+
onClick={() => {
|
|
650
|
+
setOpen(false)
|
|
651
|
+
if (!isActive) onSwitch?.(inst.appKey)
|
|
652
|
+
}}
|
|
653
|
+
className={clsx(
|
|
654
|
+
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs',
|
|
655
|
+
isActive ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
656
|
+
)}
|
|
657
|
+
>
|
|
658
|
+
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
659
|
+
<span className="w-20 shrink-0 font-medium text-theme-text-primary">{inst.env}</span>
|
|
660
|
+
<span className="min-w-0 flex-1 truncate text-theme-text-secondary">
|
|
661
|
+
{inst.name}
|
|
662
|
+
</span>
|
|
663
|
+
{inst.version && <span className="font-mono text-[10px] text-theme-text-tertiary">{midTruncate(inst.version, 18)}</span>}
|
|
664
|
+
</button>
|
|
665
|
+
)
|
|
666
|
+
})}
|
|
667
|
+
</div>
|
|
668
|
+
)}
|
|
669
|
+
</div>
|
|
670
|
+
)
|
|
671
|
+
}
|