@skyhook-io/k8s-ui 1.7.12 → 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/logs/WorkloadLogsViewer.tsx +8 -5
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- 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/SearchBox.tsx +85 -0
- 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/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +2 -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
|
@@ -109,6 +109,17 @@ const pendingRequests = new Map<number, {
|
|
|
109
109
|
reject: (error: Error) => void
|
|
110
110
|
}>()
|
|
111
111
|
|
|
112
|
+
const WORKER_UNAVAILABLE = 'Worker unavailable'
|
|
113
|
+
|
|
114
|
+
function rejectPendingWorkerRequests() {
|
|
115
|
+
for (const [, pending] of pendingRequests) {
|
|
116
|
+
pending.reject(new Error(WORKER_UNAVAILABLE))
|
|
117
|
+
}
|
|
118
|
+
pendingRequests.clear()
|
|
119
|
+
layoutWorker?.terminate()
|
|
120
|
+
layoutWorker = null
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
function getOrCreateWorker(): Worker {
|
|
113
124
|
if (!layoutWorker) {
|
|
114
125
|
layoutWorker = new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module' })
|
|
@@ -126,10 +137,7 @@ function getOrCreateWorker(): Worker {
|
|
|
126
137
|
}
|
|
127
138
|
layoutWorker.onerror = (e) => {
|
|
128
139
|
console.error('[TopologyLayout] Worker error:', e)
|
|
129
|
-
|
|
130
|
-
pending.reject(new Error('Worker error'))
|
|
131
|
-
}
|
|
132
|
-
pendingRequests.clear()
|
|
140
|
+
rejectPendingWorkerRequests()
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
return layoutWorker
|
|
@@ -142,10 +150,16 @@ function runLayoutViaWorker(
|
|
|
142
150
|
padding: typeof GROUP_PADDING
|
|
143
151
|
): Promise<LayoutResult> {
|
|
144
152
|
return new Promise((resolve, reject) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
try {
|
|
154
|
+
const worker = getOrCreateWorker()
|
|
155
|
+
const requestId = ++requestIdCounter
|
|
156
|
+
pendingRequests.set(requestId, { resolve, reject })
|
|
157
|
+
worker.postMessage({ type: 'layout', requestId, elkGraph, groupingMode, hideGroupHeader, padding })
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('[TopologyLayout] Worker unavailable:', err)
|
|
160
|
+
rejectPendingWorkerRequests()
|
|
161
|
+
reject(new Error(WORKER_UNAVAILABLE))
|
|
162
|
+
}
|
|
149
163
|
})
|
|
150
164
|
}
|
|
151
165
|
|
|
@@ -273,7 +287,18 @@ function runLayout(
|
|
|
273
287
|
if (layoutEngine === 'main-thread') {
|
|
274
288
|
return runLayoutOnMainThread(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
275
289
|
}
|
|
276
|
-
return runLayoutViaWorker(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
290
|
+
return runLayoutViaWorker(elkGraph, groupingMode, hideGroupHeader, padding).catch((err) => {
|
|
291
|
+
// A worker failure before a layout result arrives usually means the
|
|
292
|
+
// consumer's bundler/CSP couldn't serve or run the worker chunk.
|
|
293
|
+
// Fall back to the inline engine permanently rather than rendering a dead
|
|
294
|
+
// "Layout Error" pane.
|
|
295
|
+
if (err instanceof Error && err.message === WORKER_UNAVAILABLE) {
|
|
296
|
+
console.warn('[TopologyLayout] layout worker unavailable — falling back to main-thread ELK')
|
|
297
|
+
layoutEngine = 'main-thread'
|
|
298
|
+
return runLayoutOnMainThread(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
299
|
+
}
|
|
300
|
+
throw err
|
|
301
|
+
})
|
|
277
302
|
}
|
|
278
303
|
|
|
279
304
|
interface ElkNode {
|
|
@@ -798,7 +823,7 @@ function computeGridDimensions(cardCount: number, groupKey: string): { width: nu
|
|
|
798
823
|
}
|
|
799
824
|
|
|
800
825
|
// Two-phase layout: first layout groups internally, then position groups based on connections
|
|
801
|
-
// Layout
|
|
826
|
+
// Layout runs via the active engine — a Web Worker when available, else inline
|
|
802
827
|
export async function applyHierarchicalLayout(
|
|
803
828
|
elkGraph: ElkGraph,
|
|
804
829
|
topologyNodes: TopologyNode[],
|
|
@@ -817,7 +842,7 @@ export async function applyHierarchicalLayout(
|
|
|
817
842
|
try {
|
|
818
843
|
const padding = hideGroupHeader ? GROUP_PADDING_NO_HEADER : GROUP_PADDING
|
|
819
844
|
|
|
820
|
-
// Run layout
|
|
845
|
+
// Run layout via the active engine
|
|
821
846
|
const workerResult = await runLayout(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
822
847
|
|
|
823
848
|
if (workerResult.error) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { LucideIcon } from 'lucide-react'
|
|
3
|
+
import { EmptyState, type EmptyStateTone } from './EmptyState'
|
|
4
|
+
|
|
5
|
+
// CenteredEmpty — the shared "whole panel is empty" state, centered in the
|
|
6
|
+
// available height (matches how the view components present their own
|
|
7
|
+
// empty/healthy states). Use for not-found / no-data-at-all; for "the filter
|
|
8
|
+
// excluded everything" render an inline EmptyState card where the rows would be.
|
|
9
|
+
export function CenteredEmpty({
|
|
10
|
+
tone = 'neutral',
|
|
11
|
+
icon,
|
|
12
|
+
headline,
|
|
13
|
+
body,
|
|
14
|
+
action,
|
|
15
|
+
}: {
|
|
16
|
+
tone?: EmptyStateTone
|
|
17
|
+
icon?: LucideIcon
|
|
18
|
+
headline: string
|
|
19
|
+
body?: ReactNode
|
|
20
|
+
action?: ReactNode
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex min-h-[55vh] flex-1 items-center justify-center p-4">
|
|
24
|
+
<EmptyState tone={tone} variant="card" icon={icon} headline={headline} body={body} action={action} className="border-none bg-transparent" />
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import { Search, X } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { useRegisterShortcut, type ShortcutScope } from '../../hooks/useKeyboardShortcuts'
|
|
5
|
+
|
|
6
|
+
/** The standard list-view search box: themed input with a `/`-to-focus
|
|
7
|
+
* shortcut, Escape-to-blur, and a clear affordance. One definition so the
|
|
8
|
+
* views can't drift (hand-rolled copies had already diverged: a blue focus
|
|
9
|
+
* ring in Timeline, no clear button in Audit). ResourcesView keeps its inline
|
|
10
|
+
* variant — regex mode and row-navigation handoff are coupled to its table. */
|
|
11
|
+
export function SearchBox({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
scope,
|
|
15
|
+
shortcutId,
|
|
16
|
+
placeholder = 'Search... (press /)',
|
|
17
|
+
className,
|
|
18
|
+
onEnter,
|
|
19
|
+
onArrowDown,
|
|
20
|
+
}: {
|
|
21
|
+
value: string
|
|
22
|
+
onChange: (value: string) => void
|
|
23
|
+
/** Help-overlay grouping + collision priority for the `/` shortcut. */
|
|
24
|
+
scope: ShortcutScope
|
|
25
|
+
/** Unique shortcut id, e.g. 'applications-search'. */
|
|
26
|
+
shortcutId: string
|
|
27
|
+
placeholder?: string
|
|
28
|
+
/** Width/layout overrides — the box itself stays themed. */
|
|
29
|
+
className?: string
|
|
30
|
+
/** Enter in the box — e.g. open the first filtered row. */
|
|
31
|
+
onEnter?: () => void
|
|
32
|
+
/** ArrowDown in the box — hand focus off to list keyboard navigation. */
|
|
33
|
+
onArrowDown?: () => void
|
|
34
|
+
}) {
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
36
|
+
|
|
37
|
+
useRegisterShortcut({
|
|
38
|
+
id: shortcutId,
|
|
39
|
+
keys: '/',
|
|
40
|
+
description: 'Focus search',
|
|
41
|
+
category: 'Search',
|
|
42
|
+
scope,
|
|
43
|
+
handler: () => inputRef.current?.focus(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={clsx('relative', className)}>
|
|
48
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-tertiary" />
|
|
49
|
+
<input
|
|
50
|
+
ref={inputRef}
|
|
51
|
+
type="text"
|
|
52
|
+
value={value}
|
|
53
|
+
placeholder={placeholder}
|
|
54
|
+
onChange={(e) => onChange(e.target.value)}
|
|
55
|
+
onKeyDown={(e) => {
|
|
56
|
+
if (e.key === 'Escape') {
|
|
57
|
+
inputRef.current?.blur()
|
|
58
|
+
} else if (e.key === 'Enter' && onEnter) {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
inputRef.current?.blur()
|
|
61
|
+
onEnter()
|
|
62
|
+
} else if (e.key === 'ArrowDown' && onArrowDown) {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
inputRef.current?.blur()
|
|
65
|
+
onArrowDown()
|
|
66
|
+
}
|
|
67
|
+
}}
|
|
68
|
+
className="w-full rounded-lg border border-theme-border-light bg-theme-elevated py-1.5 pl-10 pr-9 text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
69
|
+
/>
|
|
70
|
+
{value && (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label="Clear search"
|
|
74
|
+
onClick={() => {
|
|
75
|
+
onChange('')
|
|
76
|
+
inputRef.current?.focus()
|
|
77
|
+
}}
|
|
78
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
|
|
79
|
+
>
|
|
80
|
+
<X className="h-3.5 w-3.5" />
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -6,6 +6,7 @@ export type { MiddleEllipsisProps } from './MiddleEllipsis'
|
|
|
6
6
|
export { EmptyState } from './EmptyState'
|
|
7
7
|
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
|
|
8
8
|
export { FetchResult } from './FetchResult'
|
|
9
|
+
export { SearchBox } from './SearchBox'
|
|
9
10
|
export { FilterPill } from './FilterPill'
|
|
10
11
|
export type { FilterPillTone } from './FilterPill'
|
|
11
12
|
export { StatusDot, mapHealthToTone } from './status-tone'
|
|
@@ -3,6 +3,7 @@ import { flushSync } from 'react-dom'
|
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
4
|
import { startViewTransitionSafe } from '../../utils/view-transition'
|
|
5
5
|
import { FetchResult } from '../ui/FetchResult'
|
|
6
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
6
7
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
7
8
|
import { clsx } from 'clsx'
|
|
8
9
|
import {
|
|
@@ -19,11 +20,14 @@ import {
|
|
|
19
20
|
Maximize2,
|
|
20
21
|
X,
|
|
21
22
|
BarChart3,
|
|
23
|
+
Network,
|
|
22
24
|
} from 'lucide-react'
|
|
23
|
-
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom } from '../../types'
|
|
25
|
+
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode } from '../../types'
|
|
24
26
|
import type { GitOpsStatus } from '../../types/gitops'
|
|
25
27
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
26
|
-
import { refToSelectedResource, pluralToKind } from '../../utils/navigation'
|
|
28
|
+
import { refToSelectedResource, pluralToKind, kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
29
|
+
import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
|
|
30
|
+
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
27
31
|
import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
28
32
|
import { gitOpsRouteForResource } from '../../utils/gitops-route'
|
|
29
33
|
import { isChangeEvent, isHistoricalEvent } from '../../types'
|
|
@@ -48,8 +52,10 @@ import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } f
|
|
|
48
52
|
import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
|
|
49
53
|
import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
|
|
50
54
|
import { getKindColorOutline, formatKindName } from '../ui/drawer-components'
|
|
55
|
+
import { midTruncate } from '../../utils/format'
|
|
51
56
|
|
|
52
|
-
type
|
|
57
|
+
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
58
|
+
type TabType = WorkloadTabType
|
|
53
59
|
|
|
54
60
|
// ============================================================================
|
|
55
61
|
// MAIN WORKLOAD VIEW — presentation only, data injected via props
|
|
@@ -81,11 +87,16 @@ interface WorkloadViewProps {
|
|
|
81
87
|
* the Escape shortcut.
|
|
82
88
|
*/
|
|
83
89
|
breadcrumb?: ReactNode
|
|
90
|
+
/** Suppress the standalone back arrow — for embeddings where "back" has no
|
|
91
|
+
* meaningful target (a single-workload app has no app graph to return to). */
|
|
92
|
+
hideBackButton?: boolean
|
|
84
93
|
/**
|
|
85
94
|
* Controls injected into the shell's tab-row scope slot — e.g. a cluster /
|
|
86
95
|
* workload picker in Radar Cloud. Absent in standalone Radar.
|
|
87
96
|
*/
|
|
88
97
|
scopeControls?: ReactNode
|
|
98
|
+
/** Hide WorkloadView's own breadcrumb/identity header when a host page owns that chrome. */
|
|
99
|
+
compactHeader?: boolean
|
|
89
100
|
|
|
90
101
|
// ── Data (injected by wrapper) ──────────────────────────────────────────
|
|
91
102
|
/** The resource data object */
|
|
@@ -107,8 +118,8 @@ interface WorkloadViewProps {
|
|
|
107
118
|
allEvents?: TimelineEvent[]
|
|
108
119
|
/** Whether timeline events are loading */
|
|
109
120
|
eventsLoading?: boolean
|
|
110
|
-
/** Topology data for hierarchy building */
|
|
111
|
-
topology?:
|
|
121
|
+
/** Topology data for hierarchy building + the Topology tab's neighborhood. */
|
|
122
|
+
topology?: Topology
|
|
112
123
|
resourceFocusedK8sEvents?: TimelineEvent[]
|
|
113
124
|
resourceFocusedUpdates?: TimelineEvent[]
|
|
114
125
|
resourceFocusedEventsLoading?: boolean
|
|
@@ -118,6 +129,8 @@ interface WorkloadViewProps {
|
|
|
118
129
|
// ── Capabilities ─────────────────────────────────────────────────────────
|
|
119
130
|
/** Whether secrets can be updated */
|
|
120
131
|
canUpdateSecrets?: boolean
|
|
132
|
+
/** Whether YAML editing should be disabled for read-only host surfaces. */
|
|
133
|
+
readOnlyYaml?: boolean
|
|
121
134
|
|
|
122
135
|
// ── Mutations ────────────────────────────────────────────────────────────
|
|
123
136
|
/** Update a resource from YAML */
|
|
@@ -183,6 +196,12 @@ interface WorkloadViewProps {
|
|
|
183
196
|
}) => ReactNode
|
|
184
197
|
/** Render the metrics tab content */
|
|
185
198
|
renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
199
|
+
/** Render a read-only YAML view for a related object from the workload's
|
|
200
|
+
* neighborhood. Providing this turns the YAML tab into an object explorer
|
|
201
|
+
* (rail of the workload + its Services/config/policies/pods); omitting it
|
|
202
|
+
* keeps the single-manifest YAML tab. Injected because resource fetching
|
|
203
|
+
* lives host-side. */
|
|
204
|
+
renderRelatedYaml?: (ref: { kind: string; namespace: string; name: string; group?: string }) => ReactNode
|
|
186
205
|
/** Whether metrics are available for this resource kind */
|
|
187
206
|
isMetricsAvailable?: (kind: string, resource: any) => boolean
|
|
188
207
|
/** Render extra content at the bottom of the overview tab (e.g. audit findings) */
|
|
@@ -218,7 +237,9 @@ export function WorkloadView({
|
|
|
218
237
|
initialTab,
|
|
219
238
|
group,
|
|
220
239
|
breadcrumb,
|
|
240
|
+
hideBackButton,
|
|
221
241
|
scopeControls,
|
|
242
|
+
compactHeader,
|
|
222
243
|
// Data
|
|
223
244
|
resource,
|
|
224
245
|
relationships,
|
|
@@ -237,6 +258,7 @@ export function WorkloadView({
|
|
|
237
258
|
resourceFocusedUpdatesError = null,
|
|
238
259
|
// Capabilities
|
|
239
260
|
canUpdateSecrets,
|
|
261
|
+
readOnlyYaml,
|
|
240
262
|
// Mutations
|
|
241
263
|
onUpdateResource,
|
|
242
264
|
isUpdatingResource,
|
|
@@ -246,6 +268,7 @@ export function WorkloadView({
|
|
|
246
268
|
onTabChange,
|
|
247
269
|
// Render props
|
|
248
270
|
renderLogsTab,
|
|
271
|
+
renderRelatedYaml,
|
|
249
272
|
renderMetricsTab,
|
|
250
273
|
isMetricsAvailable,
|
|
251
274
|
// Duplicate
|
|
@@ -292,7 +315,6 @@ export function WorkloadView({
|
|
|
292
315
|
// startViewTransitionSafe handles the API-missing fallback AND
|
|
293
316
|
// swallows the InvalidStateError that the API rejects with when
|
|
294
317
|
// a new transition supersedes an in-flight one (rapid clicks).
|
|
295
|
-
// (SKY-833 bug 49)
|
|
296
318
|
startViewTransitionSafe(() => flushSync(() => setShowYaml(yaml)))
|
|
297
319
|
}, [])
|
|
298
320
|
|
|
@@ -317,10 +339,75 @@ export function WorkloadView({
|
|
|
317
339
|
})
|
|
318
340
|
}, [allEvents, topology, kind, namespace, name])
|
|
319
341
|
|
|
342
|
+
// Topology tab — the seeded neighborhood around this one workload (its
|
|
343
|
+
// ownership core + attached Services/config/policies), not the whole namespace.
|
|
344
|
+
const neighborhoodSeed = useMemo(() => [{ kind, namespace, name }], [kind, namespace, name])
|
|
345
|
+
const neighborhood = useMemo(
|
|
346
|
+
() => (topology ? neighborhoodFor(topology, neighborhoodSeed) : null),
|
|
347
|
+
[topology, neighborhoodSeed],
|
|
348
|
+
)
|
|
349
|
+
const neighborhoodFocusId = useMemo(
|
|
350
|
+
() => (topology ? seedNodeIds(topology, neighborhoodSeed)[0] : undefined),
|
|
351
|
+
[topology, neighborhoodSeed],
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
// The Topology tab stays visible while topology is loading (the pane shows a
|
|
355
|
+
// loader) and hides only when topology arrived and nothing matched the seed.
|
|
356
|
+
// A deep-linked ?tab=topology that turns out unavailable falls back to
|
|
357
|
+
// overview instead of rendering an empty body under a hidden tab.
|
|
358
|
+
const topologyTabHidden = !!topology && (!neighborhood || neighborhood.nodes.length === 0)
|
|
359
|
+
const effectiveTab: TabType = activeTab === 'topology' && topologyTabHidden ? 'overview' : activeTab
|
|
360
|
+
|
|
361
|
+
// YAML tab object rail — the same neighborhood, as a manifest list: the
|
|
362
|
+
// workload first, then routing → config → policy/scaling → ownership.
|
|
363
|
+
const yamlObjects = useMemo(() => {
|
|
364
|
+
if (!neighborhood) return []
|
|
365
|
+
const order: Record<string, number> = {
|
|
366
|
+
Service: 1, Ingress: 1, HTTPRoute: 1,
|
|
367
|
+
ConfigMap: 2, Secret: 2,
|
|
368
|
+
HorizontalPodAutoscaler: 3, PodDisruptionBudget: 3, NetworkPolicy: 3,
|
|
369
|
+
ReplicaSet: 4, Pod: 5,
|
|
370
|
+
}
|
|
371
|
+
return neighborhood.nodes
|
|
372
|
+
.filter((n) => n.kind !== 'Internet' && n.kind !== 'PodGroup')
|
|
373
|
+
.map((n) => ({
|
|
374
|
+
id: n.id,
|
|
375
|
+
kind: n.kind as string,
|
|
376
|
+
namespace: (n.data?.namespace as string) || namespace,
|
|
377
|
+
name: n.name,
|
|
378
|
+
group: apiVersionToGroup(n.data?.apiVersion as string | undefined),
|
|
379
|
+
primary: n.id === neighborhoodFocusId,
|
|
380
|
+
}))
|
|
381
|
+
.sort((a, b) =>
|
|
382
|
+
a.primary !== b.primary
|
|
383
|
+
? (a.primary ? -1 : 1)
|
|
384
|
+
: (order[a.kind] ?? 9) - (order[b.kind] ?? 9) || a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
|
|
385
|
+
)
|
|
386
|
+
}, [neighborhood, neighborhoodFocusId, namespace])
|
|
387
|
+
// null = the workload's own manifest (the editable one).
|
|
388
|
+
const [yamlObjectId, setYamlObjectId] = useState<string | null>(null)
|
|
389
|
+
useEffect(() => setYamlObjectId(null), [kind, namespace, name])
|
|
390
|
+
const yamlObject = yamlObjectId ? yamlObjects.find((o) => o.id === yamlObjectId) : undefined
|
|
391
|
+
const handleTopologyNodeClick = useCallback(
|
|
392
|
+
(node: TopologyNode) => {
|
|
393
|
+
if (!onNavigateToResource || !node.kind || !node.name) return
|
|
394
|
+
onNavigateToResource({
|
|
395
|
+
kind: kindToPlural(node.kind),
|
|
396
|
+
namespace: (node.data?.namespace as string) || '',
|
|
397
|
+
name: node.name,
|
|
398
|
+
group: apiVersionToGroup(node.data?.apiVersion as string | undefined),
|
|
399
|
+
})
|
|
400
|
+
},
|
|
401
|
+
[onNavigateToResource],
|
|
402
|
+
)
|
|
403
|
+
|
|
320
404
|
// Flatten events from hierarchy
|
|
321
405
|
const resourceEvents = useMemo(() => {
|
|
322
406
|
return getAllEventsFromHierarchy(resourceLanes)
|
|
323
407
|
}, [resourceLanes])
|
|
408
|
+
const overviewEvents = resourceEvents.length > 0 ? resourceEvents : (resourceFocusedK8sEvents ?? [])
|
|
409
|
+
const overviewEventsLoading = resourceEvents.length > 0 ? eventsLoading : resourceFocusedEventsLoading
|
|
410
|
+
const overviewEventsError = resourceEvents.length > 0 ? undefined : resourceFocusedK8sError
|
|
324
411
|
|
|
325
412
|
// Get pods from relationships and hierarchy
|
|
326
413
|
const childPods = useMemo(() => {
|
|
@@ -446,6 +533,7 @@ export function WorkloadView({
|
|
|
446
533
|
const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
|
|
447
534
|
const tabs: DetailShellTab<TabType>[] = [
|
|
448
535
|
{ id: 'overview', label: 'Overview', icon: <Layers className="w-4 h-4" /> },
|
|
536
|
+
{ id: 'topology', label: 'Topology', icon: <Network className="w-4 h-4" />, hidden: topologyTabHidden },
|
|
449
537
|
{
|
|
450
538
|
id: 'timeline',
|
|
451
539
|
label: 'Timeline',
|
|
@@ -548,6 +636,7 @@ export function WorkloadView({
|
|
|
548
636
|
data={resource}
|
|
549
637
|
onCopy={(text) => copyToClipboard(text, 'yaml')}
|
|
550
638
|
copied={copied === 'yaml'}
|
|
639
|
+
readOnly={readOnlyYaml}
|
|
551
640
|
onSaved={handleSaved}
|
|
552
641
|
onSave={onUpdateResource}
|
|
553
642
|
isSaving={isUpdatingResource}
|
|
@@ -593,7 +682,7 @@ export function WorkloadView({
|
|
|
593
682
|
<DetailShell
|
|
594
683
|
breadcrumb={breadcrumb}
|
|
595
684
|
nav={
|
|
596
|
-
breadcrumb ? undefined : (
|
|
685
|
+
breadcrumb || hideBackButton ? undefined : (
|
|
597
686
|
<button
|
|
598
687
|
onClick={onBack}
|
|
599
688
|
className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
@@ -673,13 +762,14 @@ export function WorkloadView({
|
|
|
673
762
|
</>
|
|
674
763
|
}
|
|
675
764
|
tabs={tabs}
|
|
676
|
-
activeTab={
|
|
765
|
+
activeTab={effectiveTab}
|
|
677
766
|
onTabChange={handleSetTab}
|
|
678
767
|
scopeControls={scopeControls}
|
|
679
768
|
tabStripEnd={<ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...actionsBarProps} />}
|
|
680
769
|
overlay={saveSuccess ? <SaveSuccessAnimation /> : null}
|
|
770
|
+
compactHeader={compactHeader}
|
|
681
771
|
>
|
|
682
|
-
{
|
|
772
|
+
{effectiveTab === 'overview' && (
|
|
683
773
|
<InfoTab
|
|
684
774
|
resource={resource}
|
|
685
775
|
selectedResource={selectedResource}
|
|
@@ -695,15 +785,32 @@ export function WorkloadView({
|
|
|
695
785
|
onSwitchToTimeline={() => handleSetTab('timeline')}
|
|
696
786
|
rendererOverrides={rendererOverrides}
|
|
697
787
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
698
|
-
events={
|
|
699
|
-
eventsLoading={
|
|
788
|
+
events={overviewEvents}
|
|
789
|
+
eventsLoading={overviewEventsLoading}
|
|
700
790
|
updates={resourceFocusedUpdates}
|
|
701
|
-
eventsError={
|
|
791
|
+
eventsError={overviewEventsError}
|
|
702
792
|
updatesError={resourceFocusedUpdatesError}
|
|
703
793
|
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
704
794
|
/>
|
|
705
795
|
)}
|
|
706
|
-
{
|
|
796
|
+
{effectiveTab === 'topology' && (
|
|
797
|
+
<div className="relative h-full min-h-0 w-full">
|
|
798
|
+
{topology ? (
|
|
799
|
+
<TopologyGraph
|
|
800
|
+
topology={neighborhood}
|
|
801
|
+
viewMode="resources"
|
|
802
|
+
groupingMode="namespace"
|
|
803
|
+
hideGroupHeader
|
|
804
|
+
onNodeClick={handleTopologyNodeClick}
|
|
805
|
+
showExportButton={false}
|
|
806
|
+
focusNodeId={neighborhoodFocusId}
|
|
807
|
+
/>
|
|
808
|
+
) : (
|
|
809
|
+
<PaneLoader label="Loading topology…" className="absolute inset-0" />
|
|
810
|
+
)}
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
813
|
+
{effectiveTab === 'timeline' && (
|
|
707
814
|
<EventsTab
|
|
708
815
|
events={resourceEvents}
|
|
709
816
|
resourceLanes={resourceLanes}
|
|
@@ -716,7 +823,7 @@ export function WorkloadView({
|
|
|
716
823
|
onSelectEvent={setSelectedEventId}
|
|
717
824
|
/>
|
|
718
825
|
)}
|
|
719
|
-
{
|
|
826
|
+
{effectiveTab === 'logs' && renderLogsTab && (
|
|
720
827
|
renderLogsTab({
|
|
721
828
|
kind,
|
|
722
829
|
apiKind,
|
|
@@ -730,29 +837,56 @@ export function WorkloadView({
|
|
|
730
837
|
onConsumeInitialContainer: () => setInitialContainer(null),
|
|
731
838
|
})
|
|
732
839
|
)}
|
|
733
|
-
{
|
|
840
|
+
{effectiveTab === 'metrics' && renderMetricsTab && (
|
|
734
841
|
<div className="h-full overflow-auto p-4">
|
|
735
842
|
{renderMetricsTab({ kind: resource?.kind || kind, namespace, name })}
|
|
736
843
|
</div>
|
|
737
844
|
)}
|
|
738
|
-
{
|
|
739
|
-
<div className="h-full
|
|
740
|
-
{
|
|
741
|
-
<
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
845
|
+
{effectiveTab === 'yaml' && (
|
|
846
|
+
<div className="flex h-full min-h-0">
|
|
847
|
+
{renderRelatedYaml && yamlObjects.length > 1 && (
|
|
848
|
+
<div 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">
|
|
849
|
+
<div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Objects</div>
|
|
850
|
+
{yamlObjects.map((o) => {
|
|
851
|
+
const active = o.primary ? yamlObjectId === null : yamlObjectId === o.id
|
|
852
|
+
return (
|
|
853
|
+
<button
|
|
854
|
+
key={o.id}
|
|
855
|
+
type="button"
|
|
856
|
+
onClick={() => setYamlObjectId(o.primary ? null : o.id)}
|
|
857
|
+
className={clsx(
|
|
858
|
+
'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors',
|
|
859
|
+
active ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
860
|
+
)}
|
|
861
|
+
>
|
|
862
|
+
<span className="truncate text-xs font-medium text-theme-text-primary">{midTruncate(o.name, 26)}</span>
|
|
863
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{formatKindName(o.kind)}</span>
|
|
864
|
+
</button>
|
|
865
|
+
)
|
|
866
|
+
})}
|
|
867
|
+
</div>
|
|
755
868
|
)}
|
|
869
|
+
<div className="h-full min-w-0 flex-1 overflow-auto">
|
|
870
|
+
{yamlObject && !yamlObject.primary && renderRelatedYaml ? (
|
|
871
|
+
renderRelatedYaml(yamlObject)
|
|
872
|
+
) : !resource ? (
|
|
873
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
|
|
874
|
+
) : (
|
|
875
|
+
<EditableYamlView
|
|
876
|
+
resource={selectedResource}
|
|
877
|
+
data={resource}
|
|
878
|
+
onCopy={(text) => copyToClipboard(text, 'yaml')}
|
|
879
|
+
copied={copied === 'yaml'}
|
|
880
|
+
readOnly={readOnlyYaml}
|
|
881
|
+
onSaved={handleSaved}
|
|
882
|
+
onSave={onUpdateResource}
|
|
883
|
+
isSaving={isUpdatingResource}
|
|
884
|
+
saveError={updateResourceError}
|
|
885
|
+
onDuplicate={onDuplicate}
|
|
886
|
+
onDownload={onDownload}
|
|
887
|
+
/>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
756
890
|
</div>
|
|
757
891
|
)}
|
|
758
892
|
</DetailShell>
|
|
@@ -1240,7 +1374,7 @@ function InfoTab({
|
|
|
1240
1374
|
onClick={onSwitchToTimeline}
|
|
1241
1375
|
className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
1242
1376
|
>
|
|
1243
|
-
|
|
1377
|
+
Showing recent events across this workload. Switch to the <span className="underline">Timeline</span> tab for full history and resource relationships.
|
|
1244
1378
|
</button>
|
|
1245
1379
|
)}
|
|
1246
1380
|
renderSidebar={(sidebarSections) => (
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { WorkloadView } from './WorkloadView'
|
|
1
|
+
export { WorkloadView, type WorkloadTabType } from './WorkloadView'
|
|
2
2
|
export { ResourceDetailDrawer } from './ResourceDetailDrawer'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
export type ShortcutScope = 'global' | 'topology' | 'resources' | 'timeline' | 'helm' | 'gitops' | 'traffic' | 'drawer'
|
|
3
|
+
export type ShortcutScope = 'global' | 'topology' | 'resources' | 'timeline' | 'helm' | 'gitops' | 'traffic' | 'applications' | 'audit' | 'drawer'
|
|
4
4
|
|
|
5
5
|
// Scope priority: higher number = higher priority (wins when multiple scopes active)
|
|
6
6
|
const SCOPE_PRIORITY: Record<ShortcutScope, number> = {
|
|
@@ -11,6 +11,8 @@ const SCOPE_PRIORITY: Record<ShortcutScope, number> = {
|
|
|
11
11
|
helm: 1,
|
|
12
12
|
gitops: 1,
|
|
13
13
|
traffic: 1,
|
|
14
|
+
applications: 1,
|
|
15
|
+
audit: 1,
|
|
14
16
|
drawer: 2,
|
|
15
17
|
}
|
|
16
18
|
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,10 @@ export * from './components/issues'
|
|
|
53
53
|
// Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
|
|
54
54
|
export * from './components/cluster-switcher'
|
|
55
55
|
|
|
56
|
+
// Applications (shared host-agnostic list + detail shell for the deployable-
|
|
57
|
+
// software surface; OSS renders single-cluster, Cloud adds the fleet layer)
|
|
58
|
+
export * from './components/applications'
|
|
59
|
+
|
|
56
60
|
// Compare (ResourceCompareView, CompareResourcePicker, normalize utilities)
|
|
57
61
|
export * from './components/compare'
|
|
58
62
|
|