Skip to content

Commit 0b76739

Browse files
committed
Focus application workload drill-in
1 parent 1b109f0 commit 0b76739

1 file changed

Lines changed: 134 additions & 76 deletions

File tree

packages/k8s-ui/src/components/applications/ApplicationDetail.tsx

Lines changed: 134 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,6 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
219219
[app.workloads],
220220
)
221221
const overall = worstHealth([app.health, ...workloads.map((w) => w.health)])
222-
const verdictTone = HEALTH_META[overall].pill
223-
const verdictLabel = HEALTH_META[overall].label
224222
const workloadClass = workloadClassOf(app.workload_class)
225223
const versions = useMemo(() => Array.from(new Set((app.versions || []).filter(Boolean))), [app.versions])
226224
const ready = workloads.reduce((n, w) => n + (w.ready ?? 0), 0)
@@ -256,6 +254,14 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
256254
// the app graph rather than silently rendering a different workload under a
257255
// URL that names the missing one.
258256
const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected
257+
const focusedWorkload = appGraphAvailable && selectedWorkload ? selectedWorkload : null
258+
const focusedWorkloadClass = focusedWorkload ? workloadClassOf(focusedWorkload.workload_class) : null
259+
const headerHealth = focusedWorkload ? healthOf(focusedWorkload.health) : overall
260+
const verdictTone = HEALTH_META[headerHealth].pill
261+
const verdictLabel = HEALTH_META[headerHealth].label
262+
const headerReady = focusedWorkload ? focusedWorkload.ready : ready
263+
const headerDesired = focusedWorkload ? focusedWorkload.desired : desired
264+
const focusedVersion = focusedWorkload?.appVersion || focusedWorkload?.version
259265
const showBatchActivityPanel = batchActivity.length > 0 && (workloads.length === 1 || (appGraphAvailable && selected === null))
260266
useEffect(() => {
261267
if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) {
@@ -330,14 +336,33 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
330336
<span className={`flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}>
331337
<Boxes className="h-4 w-4" aria-hidden />
332338
</span>
333-
<h1 className="text-2xl font-semibold text-theme-text-primary">{app.name}</h1>
334-
<ProvenanceBadge tier={app.tier} appKey={app.key} confidence={app.confidence} />
335-
<CategoryChip category={app.category} addonReason={app.addonReason} />
336-
<ClassBadge workloadClass={workloadClass} composition={classCompositionOf(app)} />
337-
<BatchSignalChip signal={batchSignal} />
339+
{focusedWorkload ? (
340+
<>
341+
<div className="min-w-0">
342+
<div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
343+
<button type="button" onClick={() => setSelected(null)} className="truncate hover:text-theme-text-primary" title="Back to application overview">
344+
{app.name}
345+
</button>
346+
<span aria-hidden>/</span>
347+
<span className="truncate">{focusedWorkload.kind}</span>
348+
</div>
349+
<h1 className="truncate text-2xl font-semibold text-theme-text-primary">{focusedWorkload.name}</h1>
350+
</div>
351+
<span className={`${CHIP} ${CHIP_TONE.neutral}`}>{focusedWorkload.kind}</span>
352+
{focusedWorkloadClass && <ClassBadge workloadClass={focusedWorkloadClass} />}
353+
</>
354+
) : (
355+
<>
356+
<h1 className="text-2xl font-semibold text-theme-text-primary">{app.name}</h1>
357+
<ProvenanceBadge tier={app.tier} appKey={app.key} confidence={app.confidence} />
358+
<CategoryChip category={app.category} addonReason={app.addonReason} />
359+
<ClassBadge workloadClass={workloadClass} composition={classCompositionOf(app)} />
360+
<BatchSignalChip signal={batchSignal} />
361+
</>
362+
)}
338363
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
339364
<span className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}>
340-
<StatusDot tone={mapHealthToTone(overall)} />
365+
<StatusDot tone={mapHealthToTone(headerHealth)} />
341366
<span className="text-sm font-semibold">{verdictLabel}</span>
342367
</span>
343368
{restartSignal && (
@@ -359,43 +384,68 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
359384

360385
{/* Context strip */}
361386
<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">
362-
{identityInstances && identityInstances.length > 1 ? (
363-
// The Environment fact IS the switcher when this app runs in several
364-
// envs — prominent, in existing header space, no extra row. Inline
365-
// pills for a handful; a picker beyond that (scales to ~any count).
366-
<div className="flex min-w-0 items-center gap-1.5">
367-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">Environment</span>
368-
<EnvSwitcher identityKey={app.identity?.key ?? ''} instances={identityInstances} activeKey={activeInstanceKey ?? app.key} onSwitch={onSwitchInstance} />
369-
</div>
370-
) : env ? (
371-
<ContextFact label="Environment">
372-
{inferred ? (
373-
<Tooltip content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`} delay={150}>
374-
<span className="italic">~{env}</span>
375-
</Tooltip>
376-
) : (
377-
env
387+
{focusedWorkload ? (
388+
<>
389+
<ContextFact label="Application">
390+
<button type="button" onClick={() => setSelected(null)} className="hover:text-theme-text-primary">
391+
{app.name}
392+
</button>
393+
</ContextFact>
394+
{focusedWorkload.namespace && (
395+
<ContextFact label="Namespace">
396+
<span className="font-mono">{focusedWorkload.namespace}</span>
397+
</ContextFact>
378398
)}
379-
</ContextFact>
380-
) : null}
381-
{namespace ? (
382-
<ContextFact label="Namespace">
383-
<span className="font-mono">{namespace}</span>
384-
</ContextFact>
385-
) : namespaces.length > 1 ? (
386-
<ContextFact label="Namespaces">
387-
<Tooltip content={namespaces.join(', ')} delay={150}>
388-
<span>{namespaces.length} namespaces</span>
389-
</Tooltip>
390-
</ContextFact>
391-
) : null}
392-
<ContextFact label="Ready">
393-
<ReadyBar ready={ready} desired={desired} width="w-16" />
394-
</ContextFact>
395-
{(app.appVersion || versions.length > 0) && (
396-
<ContextFact label="Version">
397-
<VersionInfo app={app} variant="fact" />
398-
</ContextFact>
399+
<ContextFact label="Ready">
400+
<ReadyBar ready={headerReady} desired={headerDesired} width="w-16" />
401+
</ContextFact>
402+
{focusedVersion && (
403+
<ContextFact label="Version">
404+
<span className="font-mono">{midTruncate(focusedVersion, 32)}</span>
405+
</ContextFact>
406+
)}
407+
</>
408+
) : (
409+
<>
410+
{identityInstances && identityInstances.length > 1 ? (
411+
// The Environment fact IS the switcher when this app runs in several
412+
// envs — prominent, in existing header space, no extra row. Inline
413+
// pills for a handful; a picker beyond that (scales to ~any count).
414+
<div className="flex min-w-0 items-center gap-1.5">
415+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">Environment</span>
416+
<EnvSwitcher identityKey={app.identity?.key ?? ''} instances={identityInstances} activeKey={activeInstanceKey ?? app.key} onSwitch={onSwitchInstance} />
417+
</div>
418+
) : env ? (
419+
<ContextFact label="Environment">
420+
{inferred ? (
421+
<Tooltip content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`} delay={150}>
422+
<span className="italic">~{env}</span>
423+
</Tooltip>
424+
) : (
425+
env
426+
)}
427+
</ContextFact>
428+
) : null}
429+
{namespace ? (
430+
<ContextFact label="Namespace">
431+
<span className="font-mono">{namespace}</span>
432+
</ContextFact>
433+
) : namespaces.length > 1 ? (
434+
<ContextFact label="Namespaces">
435+
<Tooltip content={namespaces.join(', ')} delay={150}>
436+
<span>{namespaces.length} namespaces</span>
437+
</Tooltip>
438+
</ContextFact>
439+
) : null}
440+
<ContextFact label="Ready">
441+
<ReadyBar ready={headerReady} desired={headerDesired} width="w-16" />
442+
</ContextFact>
443+
{(app.appVersion || versions.length > 0) && (
444+
<ContextFact label="Version">
445+
<VersionInfo app={app} variant="fact" />
446+
</ContextFact>
447+
)}
448+
</>
399449
)}
400450
</div>
401451

@@ -412,41 +462,49 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
412462
runtime. A single-workload app skips straight to its runtime. */}
413463
{workloads.length > 1 ? (
414464
<div className="flex h-[calc(100vh-13rem)] min-h-[480px]">
415-
<WorkloadRail
416-
workloads={workloads}
417-
colorByWorkload={ownership?.colorByWorkload ?? null}
418-
showAllEntry={appGraphAvailable}
419-
// Without an app graph the body always renders a workload (selected ??
420-
// workloads[0]); mirror that so the rail marks it active.
421-
selectedKey={appGraphAvailable ? selected : selected ?? (workloads[0] ? workloadKey(workloads[0]) : null)}
422-
focusedOwnerId={focusedOwnerId}
423-
onSelect={setSelected}
424-
onFocus={setFocusedOwnerId}
425-
/>
426-
<div className="relative min-h-0 flex-1 overflow-hidden bg-theme-surface">
427-
{appGraphAvailable && selected === null ? (
428-
<TopologyGraph
429-
topology={appGraph}
430-
viewMode="resources"
431-
groupingMode="namespace"
432-
hideGroupHeader
433-
onNodeClick={handleAppNodeClick}
434-
showExportButton={false}
435-
focusNodeId={appGraphFocusId}
465+
{focusedWorkload ? (
466+
<div key={workloadKey(focusedWorkload)} className="min-h-0 flex-1 overflow-hidden bg-theme-surface">
467+
{renderWorkload(focusedWorkload)}
468+
</div>
469+
) : (
470+
<>
471+
<WorkloadRail
472+
workloads={workloads}
473+
colorByWorkload={ownership?.colorByWorkload ?? null}
474+
showAllEntry={appGraphAvailable}
475+
// Without an app graph the body always renders a workload (selected ??
476+
// workloads[0]); mirror that so the rail marks it active.
477+
selectedKey={appGraphAvailable ? selected : selected ?? (workloads[0] ? workloadKey(workloads[0]) : null)}
436478
focusedOwnerId={focusedOwnerId}
437-
onNodeHover={handleNodeHover}
479+
onSelect={setSelected}
480+
onFocus={setFocusedOwnerId}
438481
/>
439-
) : selected === null && topologyLoading ? (
440-
// The graph is this view's landing — hold a loader while the
441-
// topology fetch is in flight instead of flashing the first
442-
// workload's runtime (which would fire its data fetches too).
443-
<PaneLoader label="Loading topology…" className="absolute inset-0" />
444-
) : (
445-
<div key={workloadKey(selectedWorkload ?? workloads[0])} className="h-full min-h-0">
446-
{renderWorkload(selectedWorkload ?? workloads[0])}
482+
<div className="relative min-h-0 flex-1 overflow-hidden bg-theme-surface">
483+
{appGraphAvailable && selected === null ? (
484+
<TopologyGraph
485+
topology={appGraph}
486+
viewMode="resources"
487+
groupingMode="namespace"
488+
hideGroupHeader
489+
onNodeClick={handleAppNodeClick}
490+
showExportButton={false}
491+
focusNodeId={appGraphFocusId}
492+
focusedOwnerId={focusedOwnerId}
493+
onNodeHover={handleNodeHover}
494+
/>
495+
) : selected === null && topologyLoading ? (
496+
// The graph is this view's landing — hold a loader while the
497+
// topology fetch is in flight instead of flashing the first
498+
// workload's runtime (which would fire its data fetches too).
499+
<PaneLoader label="Loading topology…" className="absolute inset-0" />
500+
) : (
501+
<div key={workloadKey(selectedWorkload ?? workloads[0])} className="h-full min-h-0">
502+
{renderWorkload(selectedWorkload ?? workloads[0])}
503+
</div>
504+
)}
447505
</div>
448-
)}
449-
</div>
506+
</>
507+
)}
450508
</div>
451509
) : (
452510
renderRuntime(workloads[0], renderWorkload)

0 commit comments

Comments
 (0)