@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
-
import { ArrowLeft, Boxes,
|
|
2
|
+
import { AlertTriangle, ArrowLeft, Boxes, ChevronDown, ExternalLink, Layers, Radio, Search } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import type { Topology, TopologyNode } from '../../types'
|
|
4
|
+
import type { ResourceRef, Topology, TopologyNode } from '../../types'
|
|
5
|
+
import argoCdLogo from '../../assets/gitops/argocd.png'
|
|
6
|
+
import fluxLogo from '../../assets/gitops/flux.svg'
|
|
5
7
|
import { StatusDot, mapHealthToTone } from '../ui/status-tone'
|
|
6
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
9
|
+
import { EmptyState } from '../ui/EmptyState'
|
|
10
|
+
import { ResourceRefBadge } from '../ui/drawer-components'
|
|
7
11
|
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
8
12
|
import { pluralize } from '../../utils/pluralize'
|
|
9
|
-
import { kindToPlural,
|
|
10
|
-
import { tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
|
|
13
|
+
import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
|
|
14
|
+
import { batchRunParentNodes, tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
|
|
11
15
|
import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
16
|
+
import { getTopologyIcon } from '../../utils/resource-icons'
|
|
12
17
|
import {
|
|
13
18
|
type AppRow,
|
|
19
|
+
type AppHistory,
|
|
20
|
+
type AppSourceRef,
|
|
14
21
|
type AppWorkload,
|
|
22
|
+
type AppBatchActivity,
|
|
15
23
|
type AppHealth,
|
|
16
24
|
CHIP,
|
|
17
25
|
CHIP_TONE,
|
|
@@ -23,197 +31,321 @@ import {
|
|
|
23
31
|
identityEnvInferred,
|
|
24
32
|
workloadClassOf,
|
|
25
33
|
classCompositionOf,
|
|
34
|
+
batchSignalForApp,
|
|
35
|
+
batchActivityForApp,
|
|
36
|
+
batchRuntimeForApp,
|
|
37
|
+
applicationDisplayHealth,
|
|
38
|
+
sourceReportedHealth,
|
|
39
|
+
sourceSyncHealth,
|
|
40
|
+
servingReadiness,
|
|
26
41
|
worstHealth,
|
|
27
42
|
appGroupLagMessage,
|
|
28
43
|
compareVersions,
|
|
44
|
+
appSourceLabel,
|
|
45
|
+
overlayProvenance,
|
|
29
46
|
} from '../../utils/applications'
|
|
30
47
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
31
48
|
import { midTruncate } from '../../utils/format'
|
|
32
49
|
import { VersionTooltip, AppIdentityTooltip } from './AppTooltips'
|
|
33
|
-
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
|
|
50
|
+
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
|
|
34
51
|
import { ReadyBar } from './ReadyBar'
|
|
52
|
+
import { collapseStableReplicaSets, layerDeploymentInventory, topologyGroup, type DeploymentInventory, type DeploymentTopologyLayer } from '../../utils/application-topology'
|
|
35
53
|
|
|
36
|
-
// ApplicationDetail
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
54
|
+
// ApplicationDetail owns the application chrome and scope switcher. The selected
|
|
55
|
+
// scope decides the one tab row shown in the detail pane: app scope gets
|
|
56
|
+
// Overview/Topology/History; workload scope delegates to the host's WorkloadView.
|
|
57
|
+
|
|
58
|
+
export type SelectedAppWorkload = NeighborhoodSeed;
|
|
50
59
|
|
|
51
60
|
/** One env instance in the identity switcher — a sibling app row's digest. */
|
|
52
61
|
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
|
|
62
|
+
appKey: string;
|
|
63
|
+
name: string;
|
|
64
|
+
env: string;
|
|
65
|
+
health: AppHealth;
|
|
66
|
+
version?: string;
|
|
67
|
+
confidence: string;
|
|
68
|
+
evidence: string;
|
|
69
|
+
count?: number;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
/** Workload selection is either fully controlled (key + callback, the host
|
|
64
73
|
* wires it to the URL so back/forward works) or fully internal — providing
|
|
65
|
-
* only half silently freezes the
|
|
66
|
-
*
|
|
74
|
+
* only half silently freezes the selector, so the types forbid it. `null` means
|
|
75
|
+
* the application itself is selected; a key (see `workloadKey`) selects a
|
|
76
|
+
* workload scope. */
|
|
67
77
|
type SelectionProps =
|
|
68
|
-
| {
|
|
69
|
-
|
|
78
|
+
| {
|
|
79
|
+
selectedWorkloadKey: string | null;
|
|
80
|
+
onSelectWorkload: (key: string | null) => void;
|
|
81
|
+
}
|
|
82
|
+
| { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined };
|
|
83
|
+
|
|
84
|
+
type CanonicalApplicationView = "overview" | "topology" | "history";
|
|
85
|
+
|
|
86
|
+
export type ApplicationView = CanonicalApplicationView;
|
|
87
|
+
|
|
88
|
+
const DEFAULT_APPLICATION_VIEW: CanonicalApplicationView = "overview";
|
|
89
|
+
|
|
90
|
+
const APPLICATION_VIEWS: Array<{
|
|
91
|
+
id: CanonicalApplicationView;
|
|
92
|
+
label: string;
|
|
93
|
+
}> = [
|
|
94
|
+
{ id: "overview", label: "Overview" },
|
|
95
|
+
{ id: "topology", label: "Topology" },
|
|
96
|
+
{ id: "history", label: "History" },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
function canonicalApplicationView(
|
|
100
|
+
view?: ApplicationView,
|
|
101
|
+
): CanonicalApplicationView {
|
|
102
|
+
return view ?? DEFAULT_APPLICATION_VIEW;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type ViewProps =
|
|
106
|
+
| {
|
|
107
|
+
selectedView: ApplicationView;
|
|
108
|
+
onSelectView: (view: ApplicationView) => void;
|
|
109
|
+
}
|
|
110
|
+
| { selectedView?: undefined; onSelectView?: undefined };
|
|
70
111
|
|
|
71
112
|
export type ApplicationDetailProps = {
|
|
72
|
-
app: AppRow
|
|
73
|
-
onBack: () => void
|
|
113
|
+
app: AppRow;
|
|
114
|
+
onBack: () => void;
|
|
74
115
|
/** Render the host's WorkloadView for the chosen workload. */
|
|
75
|
-
renderWorkload: (workload: SelectedAppWorkload) => ReactNode
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
116
|
+
renderWorkload: (workload: SelectedAppWorkload) => ReactNode;
|
|
117
|
+
/** Render host-scoped operational issues for the app overview. */
|
|
118
|
+
renderOverviewIssues?: () => ReactNode;
|
|
119
|
+
/** Whether the host-scoped issue surface contains current issues. */
|
|
120
|
+
hasOverviewIssues?: boolean;
|
|
121
|
+
/** Resources-view topology spanning the app's namespaces. When present, it
|
|
122
|
+
* powers the application Topology view and workload hover focus. */
|
|
123
|
+
topology?: Topology;
|
|
79
124
|
/** True while the host's topology fetch is in flight. Without it, a
|
|
80
|
-
* multi-workload
|
|
81
|
-
* fires its data fetches) before jumping to the graph. */
|
|
125
|
+
* multi-workload app can briefly show an empty topology while topology loads. */
|
|
82
126
|
topologyLoading?: boolean
|
|
127
|
+
/** Exact resources reported by the app's Argo CD, Flux, or Helm deployment source. */
|
|
128
|
+
deploymentInventory?: DeploymentInventory
|
|
83
129
|
/** Open a related (non-workload) resource clicked in the app graph. */
|
|
84
|
-
onNavigateToResource?: (resource: {
|
|
130
|
+
onNavigateToResource?: (resource: {
|
|
131
|
+
kind: string;
|
|
132
|
+
namespace: string;
|
|
133
|
+
name: string;
|
|
134
|
+
group?: string;
|
|
135
|
+
}) => void;
|
|
136
|
+
/** Open a generated Job or Workflow in the run history of its parent workload. */
|
|
137
|
+
onSelectWorkloadRun?: (
|
|
138
|
+
workload: SelectedAppWorkload,
|
|
139
|
+
run: TopologyNode,
|
|
140
|
+
) => void;
|
|
85
141
|
/** App identity siblings (this instance included, ladder-ordered) — turns the
|
|
86
142
|
* context strip's Environment fact into a switcher. Identity grouping is
|
|
87
143
|
* classification, not an address: it switches between REAL instances, never
|
|
88
144
|
* an aggregate page. */
|
|
89
|
-
identityInstances?: AppIdentityInstance[] | null
|
|
145
|
+
identityInstances?: AppIdentityInstance[] | null;
|
|
90
146
|
/** Switch to a sibling instance (host swaps ?app= and, when it can match
|
|
91
147
|
* the current workload in the target, preserves ?workload= + ?tab=). */
|
|
92
|
-
onSwitchInstance?: (appKey: string) => void
|
|
148
|
+
onSwitchInstance?: (appKey: string) => void;
|
|
93
149
|
/** Env tokens the cluster proved (the list derives the same set) — keeps a
|
|
94
150
|
* ungrouped app's Environment fact consistent between list and detail. */
|
|
95
|
-
discoveredEnvs?: ReadonlySet<string
|
|
151
|
+
discoveredEnvs?: ReadonlySet<string>;
|
|
96
152
|
/** Which instance in `identityInstances` is active, by its `appKey`. Defaults
|
|
97
153
|
* to `app.key` — correct for the OSS case, where each instance IS a distinct
|
|
98
154
|
* app row keyed the same way. A host that keys instances on something other
|
|
99
155
|
* than the app key (e.g. the Cloud fleet keys per cluster, while `app.key`
|
|
100
156
|
* must stay the logical app key for provenance) passes the active instance's
|
|
101
157
|
* key here so the switcher marks it. */
|
|
102
|
-
activeInstanceKey?: string
|
|
103
|
-
|
|
158
|
+
activeInstanceKey?: string;
|
|
159
|
+
history?: AppHistory;
|
|
160
|
+
historyLoading?: boolean;
|
|
161
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
162
|
+
} & SelectionProps &
|
|
163
|
+
ViewProps;
|
|
104
164
|
|
|
105
|
-
function ContextFact({
|
|
165
|
+
function ContextFact({
|
|
166
|
+
label,
|
|
167
|
+
children,
|
|
168
|
+
}: {
|
|
169
|
+
label: string;
|
|
170
|
+
children: ReactNode;
|
|
171
|
+
}) {
|
|
106
172
|
return (
|
|
107
173
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
108
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
109
|
-
|
|
174
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
175
|
+
{label}
|
|
176
|
+
</span>
|
|
177
|
+
<span className="min-w-0 truncate text-xs text-theme-text-secondary">
|
|
178
|
+
{children}
|
|
179
|
+
</span>
|
|
110
180
|
</div>
|
|
111
|
-
)
|
|
181
|
+
);
|
|
112
182
|
}
|
|
113
183
|
|
|
114
|
-
function collapseIdentityInstances(
|
|
115
|
-
|
|
184
|
+
function collapseIdentityInstances(
|
|
185
|
+
instances: AppIdentityInstance[],
|
|
186
|
+
activeKey: string,
|
|
187
|
+
): AppIdentityInstance[] {
|
|
188
|
+
const byEnv = new Map<string, AppIdentityInstance[]>();
|
|
116
189
|
for (const inst of instances) {
|
|
117
|
-
byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst])
|
|
190
|
+
byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst]);
|
|
118
191
|
}
|
|
119
192
|
return Array.from(byEnv.values()).map((group) => {
|
|
120
|
-
const active = group.find((i) => i.appKey === activeKey)
|
|
121
|
-
const newest = group.reduce<AppIdentityInstance | undefined>(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
193
|
+
const active = group.find((i) => i.appKey === activeKey);
|
|
194
|
+
const newest = group.reduce<AppIdentityInstance | undefined>(
|
|
195
|
+
(best, inst) => {
|
|
196
|
+
if (!best) return inst;
|
|
197
|
+
return compareDefinedVersions(inst.version, best.version) === 1
|
|
198
|
+
? inst
|
|
199
|
+
: best;
|
|
200
|
+
},
|
|
201
|
+
undefined,
|
|
202
|
+
);
|
|
203
|
+
const display = active ?? newest ?? group[0];
|
|
126
204
|
return {
|
|
127
205
|
...display,
|
|
128
206
|
health: worstHealth(group.map((inst) => inst.health)),
|
|
129
207
|
version: newest?.version,
|
|
130
208
|
count: group.length,
|
|
131
|
-
}
|
|
132
|
-
})
|
|
209
|
+
};
|
|
210
|
+
});
|
|
133
211
|
}
|
|
134
212
|
|
|
135
|
-
function compareDefinedVersions(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
213
|
+
function compareDefinedVersions(
|
|
214
|
+
a: string | undefined,
|
|
215
|
+
b: string | undefined,
|
|
216
|
+
): number {
|
|
217
|
+
if (!a && !b) return 0;
|
|
218
|
+
if (a && !b) return 1;
|
|
219
|
+
if (!a || !b) return -1;
|
|
220
|
+
return compareVersions(a, b) ?? 0;
|
|
140
221
|
}
|
|
141
222
|
|
|
142
|
-
export function ApplicationDetail({ app, onBack, renderWorkload, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, selectedWorkloadKey, onSelectWorkload }: ApplicationDetailProps) {
|
|
223
|
+
export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewIssues, hasOverviewIssues, topology, topologyLoading, deploymentInventory, onNavigateToResource, onSelectWorkloadRun, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, history, historyLoading, onOpenSource, selectedWorkloadKey, onSelectWorkload, selectedView, onSelectView }: ApplicationDetailProps) {
|
|
143
224
|
// Stable order regardless of API ordering: rail rows and the per-workload
|
|
144
225
|
// color assignment both follow this array, so an order flap between
|
|
145
226
|
// refetches must not reshuffle rows or reassign a workload's hue.
|
|
146
227
|
const workloads = useMemo(
|
|
147
228
|
() =>
|
|
148
229
|
[...(app.workloads ?? [])].sort(
|
|
149
|
-
(a, b) =>
|
|
230
|
+
(a, b) =>
|
|
231
|
+
a.name.localeCompare(b.name) ||
|
|
232
|
+
a.kind.localeCompare(b.kind) ||
|
|
233
|
+
a.namespace.localeCompare(b.namespace),
|
|
150
234
|
),
|
|
151
235
|
[app.workloads],
|
|
152
|
-
)
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
236
|
+
);
|
|
237
|
+
const workloadClass = workloadClassOf(app.workload_class);
|
|
238
|
+
const batchRuntime = batchRuntimeForApp(app);
|
|
239
|
+
const overall = applicationDisplayHealth(app);
|
|
240
|
+
const verdictTone = HEALTH_META[overall].pill;
|
|
241
|
+
const verdictLabel =
|
|
242
|
+
workloadClass === "job" && overall === batchRuntime.health
|
|
243
|
+
? batchRuntime.label
|
|
244
|
+
: HEALTH_META[overall].label;
|
|
245
|
+
const versions = useMemo(
|
|
246
|
+
() => Array.from(new Set((app.versions || []).filter(Boolean))),
|
|
247
|
+
[app.versions],
|
|
248
|
+
);
|
|
249
|
+
const { ready, desired } = servingReadiness(workloads);
|
|
250
|
+
const restartSignal = restartWarning(workloads);
|
|
251
|
+
const batchSignal = workloadClass === "job" ? null : batchSignalForApp(app);
|
|
161
252
|
// Resolve namespace the same way the list does (the workloads' shared
|
|
162
253
|
// namespace) so env/namespace match across list and detail. Multi-namespace
|
|
163
254
|
// apps get the count, never an arbitrary pick.
|
|
164
|
-
const namespace = namespaceOf(app)
|
|
165
|
-
const namespaces = namespacesOf(app)
|
|
166
|
-
const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
167
|
-
const env = app.identity?.env ?? resolvedEnv.env
|
|
168
|
-
const inferred = app.identity
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
255
|
+
const namespace = namespaceOf(app);
|
|
256
|
+
const namespaces = namespacesOf(app);
|
|
257
|
+
const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs);
|
|
258
|
+
const env = app.identity?.env ?? resolvedEnv.env;
|
|
259
|
+
const inferred = app.identity
|
|
260
|
+
? identityEnvInferred(app.identity)
|
|
261
|
+
: resolvedEnv.inferred;
|
|
262
|
+
const [internalView, setInternalView] = useState<CanonicalApplicationView>(
|
|
263
|
+
DEFAULT_APPLICATION_VIEW,
|
|
264
|
+
);
|
|
265
|
+
const activeView = canonicalApplicationView(selectedView ?? internalView);
|
|
266
|
+
const setView = useCallback(
|
|
267
|
+
(view: CanonicalApplicationView) =>
|
|
268
|
+
onSelectView ? onSelectView(view) : setInternalView(view),
|
|
269
|
+
[onSelectView],
|
|
270
|
+
);
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (selectedView === undefined) setInternalView(DEFAULT_APPLICATION_VIEW);
|
|
273
|
+
}, [app.key, selectedView]);
|
|
274
|
+
|
|
275
|
+
const [internalSelected, setInternalSelected] = useState<string | null>(null);
|
|
276
|
+
const implicitSingleWorkloadKey =
|
|
277
|
+
workloads.length === 1 ? workloadKey(workloads[0]) : null;
|
|
278
|
+
const rawSelected =
|
|
279
|
+
selectedWorkloadKey !== undefined
|
|
280
|
+
? selectedWorkloadKey
|
|
281
|
+
: (internalSelected ?? implicitSingleWorkloadKey);
|
|
180
282
|
const setSelected = useCallback(
|
|
181
|
-
(key: string | null) =>
|
|
283
|
+
(key: string | null) =>
|
|
284
|
+
onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key),
|
|
182
285
|
[onSelectWorkload],
|
|
183
|
-
)
|
|
184
|
-
const selectedWorkload = rawSelected
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected
|
|
286
|
+
);
|
|
287
|
+
const selectedWorkload = rawSelected
|
|
288
|
+
? workloads.find((w) => workloadKey(w) === rawSelected)
|
|
289
|
+
: undefined;
|
|
290
|
+
const singleWorkloadScope = workloads.length === 1 && !!selectedWorkload;
|
|
189
291
|
useEffect(() => {
|
|
190
|
-
if (
|
|
191
|
-
|
|
292
|
+
if (
|
|
293
|
+
selectedWorkloadKey !== undefined &&
|
|
294
|
+
selectedWorkloadKey !== null &&
|
|
295
|
+
!selectedWorkload
|
|
296
|
+
) {
|
|
297
|
+
onSelectWorkload?.(null);
|
|
192
298
|
}
|
|
193
|
-
}, [selectedWorkloadKey, selectedWorkload, onSelectWorkload])
|
|
299
|
+
}, [selectedWorkloadKey, selectedWorkload, onSelectWorkload]);
|
|
194
300
|
useEffect(() => {
|
|
195
|
-
if (selectedWorkloadKey === undefined) setInternalSelected(null)
|
|
196
|
-
}, [app.key, selectedWorkloadKey])
|
|
301
|
+
if (selectedWorkloadKey === undefined) setInternalSelected(null);
|
|
302
|
+
}, [app.key, selectedWorkloadKey]);
|
|
197
303
|
|
|
198
304
|
// Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
|
|
199
305
|
// while the rest of the graph dims. Driven by the rail and, reciprocally, by
|
|
200
306
|
// hovering a node.
|
|
201
307
|
const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
|
|
308
|
+
const [expandedReplicaSetOwners, setExpandedReplicaSetOwners] = useState<Set<string>>(new Set())
|
|
309
|
+
useEffect(() => setExpandedReplicaSetOwners(new Set()), [app.key])
|
|
310
|
+
const toggleReplicaSets = useCallback((ownerID: string) => {
|
|
311
|
+
setExpandedReplicaSetOwners((current) => {
|
|
312
|
+
const next = new Set(current)
|
|
313
|
+
if (next.has(ownerID)) next.delete(ownerID)
|
|
314
|
+
else next.add(ownerID)
|
|
315
|
+
return next
|
|
316
|
+
})
|
|
317
|
+
}, [])
|
|
202
318
|
|
|
203
319
|
const appSeeds = useMemo(
|
|
204
|
-
() =>
|
|
320
|
+
() =>
|
|
321
|
+
workloads.map((w) => ({
|
|
322
|
+
kind: w.kind,
|
|
323
|
+
namespace: w.namespace,
|
|
324
|
+
name: w.name,
|
|
325
|
+
group: w.group,
|
|
326
|
+
})),
|
|
205
327
|
[workloads],
|
|
206
|
-
)
|
|
328
|
+
);
|
|
207
329
|
// Neighborhood subgraph + per-workload color/ownership tagging in one pass.
|
|
330
|
+
const composedTopology = useMemo(
|
|
331
|
+
() => topology ? collapseStableReplicaSets(topology, expandedReplicaSetOwners) : null,
|
|
332
|
+
[topology, expandedReplicaSetOwners],
|
|
333
|
+
)
|
|
208
334
|
const ownership = useMemo(
|
|
209
|
-
() => (
|
|
210
|
-
[
|
|
335
|
+
() => (composedTopology ? tagWorkloadOwnership(composedTopology, appSeeds) : null),
|
|
336
|
+
[composedTopology, appSeeds],
|
|
211
337
|
)
|
|
212
338
|
const appGraph = ownership?.topology ?? null
|
|
339
|
+
const deploymentLayer = useMemo(
|
|
340
|
+
() => appGraph && deploymentInventory && app.sourceRef
|
|
341
|
+
? layerDeploymentInventory(appGraph, deploymentInventory, sourceInventoryLabel(app.sourceRef))
|
|
342
|
+
: null,
|
|
343
|
+
[appGraph, deploymentInventory, app.sourceRef],
|
|
344
|
+
)
|
|
213
345
|
const appGraphFocusId = useMemo(
|
|
214
346
|
() => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
|
|
215
347
|
[topology, appSeeds],
|
|
216
|
-
)
|
|
348
|
+
);
|
|
217
349
|
|
|
218
350
|
// Hovering a node lights up its owning workload (and the rail row). An
|
|
219
351
|
// unowned node related to exactly ONE workload (a GitOps manager over a
|
|
@@ -221,65 +353,128 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
221
353
|
// focus. Truly shared nodes clear instead of dimming everything.
|
|
222
354
|
const handleNodeHover = useCallback((node: TopologyNode | null) => {
|
|
223
355
|
if (!node) {
|
|
224
|
-
setFocusedOwnerId(null)
|
|
225
|
-
return
|
|
356
|
+
setFocusedOwnerId(null);
|
|
357
|
+
return;
|
|
226
358
|
}
|
|
227
|
-
const stamp = ownershipOf(node.data)
|
|
228
|
-
setFocusedOwnerId(
|
|
229
|
-
|
|
359
|
+
const stamp = ownershipOf(node.data);
|
|
360
|
+
setFocusedOwnerId(
|
|
361
|
+
stamp.ownerWorkloadId ??
|
|
362
|
+
(stamp.focusWorkloadIds.length === 1
|
|
363
|
+
? stamp.focusWorkloadIds[0]
|
|
364
|
+
: NEUTRAL_OWNER),
|
|
365
|
+
);
|
|
366
|
+
}, []);
|
|
230
367
|
|
|
231
368
|
// A node click in the app graph either drills into one of the app's workloads
|
|
232
369
|
// (its runtime) or opens a related resource (Service/config/…) via the host.
|
|
233
370
|
const handleAppNodeClick = useCallback(
|
|
234
371
|
(node: TopologyNode) => {
|
|
372
|
+
if (node.data?.sourceInventoryGroup && app.sourceRef) {
|
|
373
|
+
onOpenSource?.(app.sourceRef)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
235
376
|
const ns = (node.data?.namespace as string) || ''
|
|
236
377
|
const match = workloads.find((w) => w.kind === node.kind && w.name === node.name && w.namespace === ns)
|
|
237
378
|
if (match) {
|
|
238
|
-
setSelected(workloadKey(match))
|
|
239
|
-
return
|
|
379
|
+
setSelected(workloadKey(match));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const parents = appGraph ? batchRunParentNodes(appGraph, node) : [];
|
|
383
|
+
const parentWorkload = parents.flatMap((parent) => {
|
|
384
|
+
const parentNamespace = (parent.data?.namespace as string) || "";
|
|
385
|
+
const workload = workloads.find(
|
|
386
|
+
(candidate) =>
|
|
387
|
+
candidate.kind === parent.kind &&
|
|
388
|
+
candidate.name === parent.name &&
|
|
389
|
+
candidate.namespace === parentNamespace,
|
|
390
|
+
);
|
|
391
|
+
return workload ? [workload] : [];
|
|
392
|
+
})[0];
|
|
393
|
+
if (parentWorkload && onSelectWorkloadRun) {
|
|
394
|
+
onSelectWorkloadRun(parentWorkload, node);
|
|
395
|
+
return;
|
|
240
396
|
}
|
|
241
397
|
onNavigateToResource?.({
|
|
242
398
|
kind: kindToPlural(node.kind),
|
|
243
399
|
namespace: ns,
|
|
244
400
|
name: node.name,
|
|
245
|
-
group:
|
|
401
|
+
group: topologyGroup(node),
|
|
246
402
|
})
|
|
247
403
|
},
|
|
248
|
-
[workloads, onNavigateToResource, setSelected],
|
|
404
|
+
[app.sourceRef, appGraph, workloads, onNavigateToResource, onOpenSource, onSelectWorkloadRun, setSelected],
|
|
249
405
|
)
|
|
250
406
|
|
|
407
|
+
const appTopology = deploymentLayer?.topology ?? appGraph ?? null
|
|
408
|
+
const appTopologyAvailable = !!appTopology && appTopology.nodes.length > 0
|
|
409
|
+
const colorByWorkload = ownership?.colorByWorkload ?? null
|
|
410
|
+
|
|
251
411
|
return (
|
|
252
|
-
<div className="flex w-full flex-col">
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
412
|
+
<div className="flex min-h-0 w-full flex-1 flex-col bg-theme-base">
|
|
413
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-theme-border px-4 py-3 sm:px-6">
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
onClick={onBack}
|
|
417
|
+
className="flex shrink-0 items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
418
|
+
>
|
|
256
419
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden /> Applications
|
|
257
420
|
</button>
|
|
258
|
-
<span
|
|
259
|
-
|
|
421
|
+
<span
|
|
422
|
+
className="hidden h-6 w-px bg-theme-border sm:block"
|
|
423
|
+
aria-hidden
|
|
424
|
+
/>
|
|
425
|
+
<span
|
|
426
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}
|
|
427
|
+
>
|
|
260
428
|
<Boxes className="h-4 w-4" aria-hidden />
|
|
261
429
|
</span>
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
430
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
431
|
+
<h1 className="min-w-0 truncate text-xl font-semibold text-theme-text-primary lg:text-2xl">
|
|
432
|
+
{app.name}
|
|
433
|
+
</h1>
|
|
434
|
+
{!singleWorkloadScope && (
|
|
435
|
+
<>
|
|
436
|
+
<span className="shrink-0 text-theme-text-tertiary">/</span>
|
|
437
|
+
<ApplicationScopeSelector
|
|
438
|
+
workloads={workloads}
|
|
439
|
+
selectedWorkload={selectedWorkload ?? null}
|
|
440
|
+
onSelect={setSelected}
|
|
441
|
+
onFocus={setFocusedOwnerId}
|
|
442
|
+
/>
|
|
443
|
+
</>
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
446
|
+
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
|
447
|
+
<span
|
|
448
|
+
className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}
|
|
449
|
+
>
|
|
268
450
|
<StatusDot tone={mapHealthToTone(overall)} />
|
|
269
451
|
<span className="text-sm font-semibold">{verdictLabel}</span>
|
|
270
452
|
</span>
|
|
271
453
|
{restartSignal && (
|
|
272
|
-
<Tooltip
|
|
273
|
-
|
|
274
|
-
|
|
454
|
+
<Tooltip
|
|
455
|
+
content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, "restart")}`}
|
|
456
|
+
delay={150}
|
|
457
|
+
>
|
|
458
|
+
<span
|
|
459
|
+
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}
|
|
460
|
+
>
|
|
461
|
+
Pod warning: {restartSignal.reason || "Restarts"} ·{" "}
|
|
462
|
+
{pluralize(restartSignal.restarts, "restart")}
|
|
275
463
|
</span>
|
|
276
464
|
</Tooltip>
|
|
277
465
|
)}
|
|
278
466
|
{/* Amber only on real skew (same image, different tags) — the context
|
|
279
467
|
strip already covers the multi-image "N versions" case neutrally. */}
|
|
280
468
|
{app.versionSkew && versions.length > 1 && (
|
|
281
|
-
<Tooltip
|
|
282
|
-
<
|
|
469
|
+
<Tooltip
|
|
470
|
+
content={<VersionTooltip workloads={workloads} />}
|
|
471
|
+
delay={150}
|
|
472
|
+
>
|
|
473
|
+
<span
|
|
474
|
+
className={`inline-flex items-center rounded-md px-2 py-1 font-mono text-xs ring-1 ring-inset ${CHIP_TONE.amber}`}
|
|
475
|
+
>
|
|
476
|
+
{versions.length} versions
|
|
477
|
+
</span>
|
|
283
478
|
</Tooltip>
|
|
284
479
|
)}
|
|
285
480
|
</div>
|
|
@@ -287,18 +482,46 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
287
482
|
|
|
288
483
|
{/* Context strip */}
|
|
289
484
|
<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">
|
|
485
|
+
<ProvenanceBadge
|
|
486
|
+
tier={app.tier}
|
|
487
|
+
appKey={app.key}
|
|
488
|
+
confidence={app.confidence}
|
|
489
|
+
/>
|
|
490
|
+
<CategoryChip category={app.category} addonReason={app.addonReason} />
|
|
491
|
+
<ClassBadge
|
|
492
|
+
workloadClass={workloadClass}
|
|
493
|
+
composition={classCompositionOf(app)}
|
|
494
|
+
/>
|
|
495
|
+
<BatchSignalChip signal={batchSignal} />
|
|
496
|
+
{singleWorkloadScope && selectedWorkload.name !== app.name && (
|
|
497
|
+
<ContextFact label="Workload">
|
|
498
|
+
<span className="font-mono">
|
|
499
|
+
{selectedWorkload.kind}/{selectedWorkload.name}
|
|
500
|
+
</span>
|
|
501
|
+
</ContextFact>
|
|
502
|
+
)}
|
|
290
503
|
{identityInstances && identityInstances.length > 1 ? (
|
|
291
504
|
// The Environment fact IS the switcher when this app runs in several
|
|
292
505
|
// envs — prominent, in existing header space, no extra row. Inline
|
|
293
506
|
// pills for a handful; a picker beyond that (scales to ~any count).
|
|
294
507
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
295
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
296
|
-
|
|
508
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
509
|
+
Environment
|
|
510
|
+
</span>
|
|
511
|
+
<EnvSwitcher
|
|
512
|
+
identityKey={app.identity?.key ?? ""}
|
|
513
|
+
instances={identityInstances}
|
|
514
|
+
activeKey={activeInstanceKey ?? app.key}
|
|
515
|
+
onSwitch={onSwitchInstance}
|
|
516
|
+
/>
|
|
297
517
|
</div>
|
|
298
518
|
) : env ? (
|
|
299
519
|
<ContextFact label="Environment">
|
|
300
520
|
{inferred ? (
|
|
301
|
-
<Tooltip
|
|
521
|
+
<Tooltip
|
|
522
|
+
content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`}
|
|
523
|
+
delay={150}
|
|
524
|
+
>
|
|
302
525
|
<span className="italic">~{env}</span>
|
|
303
526
|
</Tooltip>
|
|
304
527
|
) : (
|
|
@@ -312,14 +535,18 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
312
535
|
</ContextFact>
|
|
313
536
|
) : namespaces.length > 1 ? (
|
|
314
537
|
<ContextFact label="Namespaces">
|
|
315
|
-
<Tooltip content={namespaces.join(
|
|
538
|
+
<Tooltip content={namespaces.join(", ")} delay={150}>
|
|
316
539
|
<span>{namespaces.length} namespaces</span>
|
|
317
540
|
</Tooltip>
|
|
318
541
|
</ContextFact>
|
|
319
542
|
) : null}
|
|
320
|
-
|
|
321
|
-
<
|
|
322
|
-
|
|
543
|
+
{workloadClass === "job" ? (
|
|
544
|
+
<ContextFact label="Runtime">{batchRuntime.label}</ContextFact>
|
|
545
|
+
) : desired > 0 ? (
|
|
546
|
+
<ContextFact label="Ready">
|
|
547
|
+
<ReadyBar ready={ready} desired={desired} width="w-16" />
|
|
548
|
+
</ContextFact>
|
|
549
|
+
) : null}
|
|
323
550
|
{(app.appVersion || versions.length > 0) && (
|
|
324
551
|
<ContextFact label="Version">
|
|
325
552
|
<VersionInfo app={app} variant="fact" />
|
|
@@ -327,352 +554,2192 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
327
554
|
)}
|
|
328
555
|
</div>
|
|
329
556
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
557
|
+
<div className="min-h-0 flex-1 overflow-hidden bg-theme-base">
|
|
558
|
+
{selectedWorkload ? (
|
|
559
|
+
renderRuntime(selectedWorkload, renderWorkload)
|
|
560
|
+
) : (
|
|
561
|
+
<ApplicationWorkspace
|
|
562
|
+
app={app}
|
|
563
|
+
activeView={activeView}
|
|
564
|
+
onViewChange={setView}
|
|
336
565
|
workloads={workloads}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
566
|
+
ready={ready}
|
|
567
|
+
desired={desired}
|
|
568
|
+
versions={versions}
|
|
569
|
+
topology={appTopology}
|
|
570
|
+
topologyLoading={topologyLoading}
|
|
571
|
+
deploymentLayer={deploymentLayer}
|
|
572
|
+
deploymentSource={app.sourceRef}
|
|
573
|
+
topologyAvailable={appTopologyAvailable}
|
|
574
|
+
focusNodeId={appGraphFocusId}
|
|
342
575
|
focusedOwnerId={focusedOwnerId}
|
|
343
|
-
|
|
344
|
-
|
|
576
|
+
colorByWorkload={colorByWorkload}
|
|
577
|
+
onNodeClick={handleAppNodeClick}
|
|
578
|
+
onNodeHover={handleNodeHover}
|
|
579
|
+
onFocusWorkload={setFocusedOwnerId}
|
|
580
|
+
onSelectWorkload={(workload) => setSelected(workloadKey(workload))}
|
|
581
|
+
onNavigateToResource={onNavigateToResource}
|
|
582
|
+
history={history}
|
|
583
|
+
historyLoading={historyLoading}
|
|
584
|
+
onOpenSource={onOpenSource}
|
|
585
|
+
onToggleReplicaSets={toggleReplicaSets}
|
|
586
|
+
renderOverviewIssues={renderOverviewIssues}
|
|
587
|
+
hasOverviewIssues={hasOverviewIssues}
|
|
345
588
|
/>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
<TopologyGraph
|
|
349
|
-
topology={appGraph}
|
|
350
|
-
viewMode="resources"
|
|
351
|
-
groupingMode="namespace"
|
|
352
|
-
hideGroupHeader
|
|
353
|
-
onNodeClick={handleAppNodeClick}
|
|
354
|
-
showExportButton={false}
|
|
355
|
-
focusNodeId={appGraphFocusId}
|
|
356
|
-
focusedOwnerId={focusedOwnerId}
|
|
357
|
-
onNodeHover={handleNodeHover}
|
|
358
|
-
/>
|
|
359
|
-
) : selected === null && topologyLoading ? (
|
|
360
|
-
// The graph is this view's landing — hold a loader while the
|
|
361
|
-
// topology fetch is in flight instead of flashing the first
|
|
362
|
-
// workload's runtime (which would fire its data fetches too).
|
|
363
|
-
<PaneLoader label="Loading topology…" className="absolute inset-0" />
|
|
364
|
-
) : (
|
|
365
|
-
<div key={workloadKey(selectedWorkload ?? workloads[0])} className="h-full min-h-0">
|
|
366
|
-
{renderWorkload(selectedWorkload ?? workloads[0])}
|
|
367
|
-
</div>
|
|
368
|
-
)}
|
|
369
|
-
</div>
|
|
370
|
-
</div>
|
|
371
|
-
) : (
|
|
372
|
-
renderRuntime(workloads[0], renderWorkload)
|
|
373
|
-
)}
|
|
589
|
+
)}
|
|
590
|
+
</div>
|
|
374
591
|
</div>
|
|
375
|
-
)
|
|
592
|
+
);
|
|
376
593
|
}
|
|
377
594
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
function WorkloadRail({
|
|
595
|
+
function ApplicationWorkspace({
|
|
596
|
+
app,
|
|
597
|
+
activeView,
|
|
598
|
+
onViewChange,
|
|
383
599
|
workloads,
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
600
|
+
ready,
|
|
601
|
+
desired,
|
|
602
|
+
versions,
|
|
603
|
+
topology,
|
|
604
|
+
topologyLoading,
|
|
605
|
+
deploymentLayer,
|
|
606
|
+
deploymentSource,
|
|
607
|
+
topologyAvailable,
|
|
608
|
+
focusNodeId,
|
|
387
609
|
focusedOwnerId,
|
|
388
|
-
|
|
389
|
-
|
|
610
|
+
colorByWorkload,
|
|
611
|
+
onNodeClick,
|
|
612
|
+
onNodeHover,
|
|
613
|
+
onFocusWorkload,
|
|
614
|
+
onSelectWorkload,
|
|
615
|
+
onNavigateToResource,
|
|
616
|
+
history,
|
|
617
|
+
historyLoading,
|
|
618
|
+
onOpenSource,
|
|
619
|
+
onToggleReplicaSets,
|
|
620
|
+
renderOverviewIssues,
|
|
621
|
+
hasOverviewIssues,
|
|
390
622
|
}: {
|
|
623
|
+
app: AppRow
|
|
624
|
+
activeView: CanonicalApplicationView
|
|
625
|
+
onViewChange: (view: CanonicalApplicationView) => void
|
|
391
626
|
workloads: AppWorkload[]
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
627
|
+
ready: number
|
|
628
|
+
desired: number
|
|
629
|
+
versions: string[]
|
|
630
|
+
topology: Topology | null
|
|
631
|
+
topologyLoading?: boolean
|
|
632
|
+
deploymentLayer: DeploymentTopologyLayer | null
|
|
633
|
+
deploymentSource?: AppSourceRef
|
|
634
|
+
topologyAvailable: boolean
|
|
635
|
+
focusNodeId?: string
|
|
395
636
|
focusedOwnerId: WorkloadFocus
|
|
396
|
-
|
|
397
|
-
|
|
637
|
+
colorByWorkload: Map<string, number> | null
|
|
638
|
+
onNodeClick: (node: TopologyNode) => void
|
|
639
|
+
onNodeHover: (node: TopologyNode | null) => void
|
|
640
|
+
onFocusWorkload: (owner: WorkloadFocus) => void
|
|
641
|
+
onSelectWorkload: (workload: AppWorkload) => void
|
|
642
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
643
|
+
history?: AppHistory
|
|
644
|
+
historyLoading?: boolean
|
|
645
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
646
|
+
onToggleReplicaSets: (ownerID: string) => void
|
|
647
|
+
renderOverviewIssues?: () => ReactNode
|
|
648
|
+
hasOverviewIssues?: boolean
|
|
398
649
|
}) {
|
|
650
|
+
const historyCount =
|
|
651
|
+
(history?.anchors?.length ?? 0) +
|
|
652
|
+
(history?.incidents?.length ?? app.events?.length ?? 0);
|
|
399
653
|
return (
|
|
400
|
-
<div
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
654
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
655
|
+
<ApplicationViewTabs
|
|
656
|
+
activeView={activeView}
|
|
657
|
+
historyCount={historyCount}
|
|
658
|
+
onChange={onViewChange}
|
|
659
|
+
/>
|
|
660
|
+
{activeView === "overview" && (
|
|
661
|
+
<ApplicationOverview
|
|
662
|
+
app={app}
|
|
663
|
+
workloads={workloads}
|
|
664
|
+
ready={ready}
|
|
665
|
+
desired={desired}
|
|
666
|
+
versions={versions}
|
|
667
|
+
onSelectWorkload={onSelectWorkload}
|
|
668
|
+
onNavigateToResource={onNavigateToResource}
|
|
669
|
+
history={history}
|
|
670
|
+
onSelectHistory={() => onViewChange("history")}
|
|
671
|
+
onOpenSource={onOpenSource}
|
|
672
|
+
renderOverviewIssues={renderOverviewIssues}
|
|
673
|
+
hasOverviewIssues={hasOverviewIssues}
|
|
416
674
|
/>
|
|
417
675
|
)}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
<RailRow
|
|
446
|
-
muted
|
|
447
|
-
focused={focusedOwnerId === NEUTRAL_OWNER}
|
|
448
|
-
onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
|
|
449
|
-
swatch={<span className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border" />}
|
|
450
|
-
title="Shared / unscoped"
|
|
676
|
+
{activeView === "topology" && (
|
|
677
|
+
<ApplicationTopology
|
|
678
|
+
topology={topology}
|
|
679
|
+
loading={topologyLoading}
|
|
680
|
+
available={topologyAvailable}
|
|
681
|
+
workloads={workloads}
|
|
682
|
+
colorByWorkload={colorByWorkload}
|
|
683
|
+
focusNodeId={focusNodeId}
|
|
684
|
+
focusedOwnerId={focusedOwnerId}
|
|
685
|
+
onNodeClick={onNodeClick}
|
|
686
|
+
onNodeHover={onNodeHover}
|
|
687
|
+
onFocusWorkload={onFocusWorkload}
|
|
688
|
+
onSelectWorkload={onSelectWorkload}
|
|
689
|
+
deploymentLayer={deploymentLayer}
|
|
690
|
+
deploymentSource={deploymentSource}
|
|
691
|
+
onOpenSource={onOpenSource}
|
|
692
|
+
onToggleReplicaSets={onToggleReplicaSets}
|
|
693
|
+
/>
|
|
694
|
+
)}
|
|
695
|
+
{activeView === "history" && (
|
|
696
|
+
<ApplicationHistoryView
|
|
697
|
+
history={history}
|
|
698
|
+
loading={historyLoading}
|
|
699
|
+
fallbackEvents={app.events ?? []}
|
|
700
|
+
workloads={workloads}
|
|
701
|
+
onSelectWorkload={onSelectWorkload}
|
|
702
|
+
onOpenSource={onOpenSource}
|
|
451
703
|
/>
|
|
452
704
|
)}
|
|
453
705
|
</div>
|
|
454
|
-
)
|
|
706
|
+
);
|
|
455
707
|
}
|
|
456
708
|
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
onClick,
|
|
462
|
-
onMouseEnter,
|
|
463
|
-
swatch,
|
|
464
|
-
title,
|
|
465
|
-
tooltip,
|
|
466
|
-
subtitle,
|
|
467
|
-
trailing,
|
|
709
|
+
function ApplicationViewTabs({
|
|
710
|
+
activeView,
|
|
711
|
+
historyCount,
|
|
712
|
+
onChange,
|
|
468
713
|
}: {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
onClick?: () => void
|
|
473
|
-
onMouseEnter?: () => void
|
|
474
|
-
swatch: ReactNode
|
|
475
|
-
title: string
|
|
476
|
-
/** Full label shown on hover — the row title truncates. */
|
|
477
|
-
tooltip?: string
|
|
478
|
-
subtitle?: string
|
|
479
|
-
trailing?: ReactNode
|
|
714
|
+
activeView: CanonicalApplicationView;
|
|
715
|
+
historyCount: number;
|
|
716
|
+
onChange: (view: CanonicalApplicationView) => void;
|
|
480
717
|
}) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
718
|
+
return (
|
|
719
|
+
<div
|
|
720
|
+
className="flex shrink-0 items-center border-b border-theme-border px-4 sm:px-6"
|
|
721
|
+
role="tablist"
|
|
722
|
+
aria-label="Application views"
|
|
723
|
+
>
|
|
724
|
+
<div className="flex min-w-0 gap-1 overflow-x-auto">
|
|
725
|
+
{APPLICATION_VIEWS.map((view) => {
|
|
726
|
+
const active = view.id === activeView;
|
|
727
|
+
const badge =
|
|
728
|
+
view.id === "history" && historyCount > 0 ? historyCount : null;
|
|
729
|
+
return (
|
|
730
|
+
<button
|
|
731
|
+
key={view.id}
|
|
732
|
+
type="button"
|
|
733
|
+
role="tab"
|
|
734
|
+
aria-selected={active}
|
|
735
|
+
onClick={() => onChange(view.id)}
|
|
736
|
+
className={clsx(
|
|
737
|
+
"flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
|
738
|
+
active
|
|
739
|
+
? "border-skyhook-500 text-theme-text-primary"
|
|
740
|
+
: "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
|
|
741
|
+
)}
|
|
742
|
+
>
|
|
743
|
+
{view.label}
|
|
744
|
+
{badge !== null && (
|
|
745
|
+
<span className="rounded bg-theme-hover px-1.5 py-0.5 text-[10px] font-semibold text-theme-text-tertiary">
|
|
746
|
+
{badge}
|
|
747
|
+
</span>
|
|
748
|
+
)}
|
|
749
|
+
</button>
|
|
750
|
+
);
|
|
751
|
+
})}
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
);
|
|
506
755
|
}
|
|
507
756
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
757
|
+
type AppIssueSeverity = "error" | "warning" | "info";
|
|
758
|
+
|
|
759
|
+
type AppIssue = {
|
|
760
|
+
key: string;
|
|
761
|
+
severity: AppIssueSeverity;
|
|
762
|
+
title: string;
|
|
763
|
+
detail?: string;
|
|
764
|
+
workload?: AppWorkload;
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
function ApplicationOverview({
|
|
768
|
+
app,
|
|
769
|
+
workloads,
|
|
770
|
+
ready,
|
|
771
|
+
desired,
|
|
772
|
+
versions,
|
|
773
|
+
onSelectWorkload,
|
|
774
|
+
onNavigateToResource,
|
|
775
|
+
history,
|
|
776
|
+
onSelectHistory,
|
|
777
|
+
onOpenSource,
|
|
778
|
+
renderOverviewIssues,
|
|
779
|
+
hasOverviewIssues,
|
|
780
|
+
}: {
|
|
781
|
+
app: AppRow
|
|
782
|
+
workloads: AppWorkload[]
|
|
783
|
+
ready: number
|
|
784
|
+
desired: number
|
|
785
|
+
versions: string[]
|
|
786
|
+
onSelectWorkload: (workload: AppWorkload) => void
|
|
787
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
788
|
+
history?: AppHistory
|
|
789
|
+
onSelectHistory: () => void
|
|
790
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
791
|
+
renderOverviewIssues?: () => ReactNode
|
|
792
|
+
hasOverviewIssues?: boolean
|
|
793
|
+
}) {
|
|
794
|
+
const rel = app.relationships
|
|
795
|
+
const hasEntrypoints = Boolean(rel && (relationshipRefs(rel, 'service').length > 0 || relationshipRefs(rel, 'ingress').length > 0 || relationshipRefs(rel, 'route').length > 0))
|
|
796
|
+
const hasDependencies = Boolean(rel && (
|
|
797
|
+
relationshipRefs(rel, 'config').length > 0 ||
|
|
798
|
+
relationshipRefs(rel, 'scaler').length > 0 ||
|
|
799
|
+
relationshipRefs(rel, 'storage').length > 0 ||
|
|
800
|
+
relationshipRefs(rel, 'pdb').length > 0 ||
|
|
801
|
+
relationshipRefs(rel, 'networkPolicy').length > 0
|
|
802
|
+
))
|
|
803
|
+
const composition = classCompositionOf(app)
|
|
804
|
+
.map(({ cls, count }) => `${count} ${cls}`)
|
|
805
|
+
.join(' / ')
|
|
806
|
+
const issues = useMemo(() => buildAppIssues(workloads, app.events ?? []), [workloads, app.events])
|
|
807
|
+
const currentIssuesPresent = renderOverviewIssues
|
|
808
|
+
? hasOverviewIssues ?? issues.length > 0
|
|
809
|
+
: issues.length > 0
|
|
810
|
+
const batchActivity = useMemo(() => batchActivityForApp(app), [app])
|
|
811
|
+
const batchStats = batchOverviewStats(batchActivity)
|
|
812
|
+
const pureBatch = workloadClassOf(app.workload_class) === 'job'
|
|
813
|
+
const workloadComposition = workloadKindComposition(workloads)
|
|
814
|
+
const latestChange = history?.summary?.state === 'change' ? history.summary : undefined
|
|
815
|
+
const runtimeHealth = healthOf(app.runtimeHealth ?? worstHealth(workloads.map((workload) => workload.health)))
|
|
816
|
+
const hasDeliveryStatus = Boolean(app.sourceStatus?.sync || app.sourceStatus?.health)
|
|
817
|
+
const extraFactCount = Number(Boolean(latestChange)) + Number(hasDeliveryStatus)
|
|
818
|
+
const factGrid = pureBatch
|
|
819
|
+
? extraFactCount === 2
|
|
820
|
+
? '2xl:grid-cols-6'
|
|
821
|
+
: extraFactCount === 1
|
|
822
|
+
? '2xl:grid-cols-5'
|
|
823
|
+
: '2xl:grid-cols-4'
|
|
824
|
+
: extraFactCount === 2
|
|
825
|
+
? '2xl:grid-cols-5'
|
|
826
|
+
: extraFactCount === 1
|
|
827
|
+
? '2xl:grid-cols-4'
|
|
828
|
+
: '2xl:grid-cols-3'
|
|
829
|
+
|
|
522
830
|
return (
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
831
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
832
|
+
<div className="grid w-full max-w-[2400px] gap-4 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
|
833
|
+
<div className="min-w-0 space-y-4">
|
|
834
|
+
{renderOverviewIssues ? renderOverviewIssues() : <ApplicationNow issues={issues} onSelectWorkload={onSelectWorkload} />}
|
|
835
|
+
<ApplicationLatestHistory
|
|
836
|
+
history={history}
|
|
837
|
+
sourceRef={app.sourceRef}
|
|
838
|
+
showIncidentPreview={!currentIssuesPresent}
|
|
839
|
+
onSelectHistory={onSelectHistory}
|
|
840
|
+
onOpenSource={onOpenSource}
|
|
841
|
+
/>
|
|
842
|
+
<div className={clsx('grid gap-3 md:grid-cols-2', factGrid)}>
|
|
843
|
+
<ApplicationFact
|
|
844
|
+
label="Runtime"
|
|
845
|
+
value={pureBatch ? batchRuntimeForApp(app).label : HEALTH_META[runtimeHealth].label}
|
|
846
|
+
detail={pureBatch ? batchStats.activeDetail : desired > 0 ? `${ready}/${desired} ready` : 'No desired replicas'}
|
|
847
|
+
/>
|
|
848
|
+
{hasDeliveryStatus && (
|
|
849
|
+
<ApplicationFact
|
|
850
|
+
label="Delivery"
|
|
851
|
+
value={<SourceDeliveryStatus status={app.sourceStatus!} />}
|
|
852
|
+
detail={app.sourceRef ? sourceObjectLabel(app.sourceRef) : undefined}
|
|
853
|
+
/>
|
|
854
|
+
)}
|
|
855
|
+
{pureBatch ? (
|
|
856
|
+
<>
|
|
857
|
+
<ApplicationFact label="Batch resources" value={String(workloads.length)} detail={workloadComposition || 'No workloads'} />
|
|
858
|
+
<ApplicationFact label="Active runs" value={batchStats.activeValue} detail={batchStats.activeDetail} />
|
|
859
|
+
<ApplicationFact label="Retained runs" value={batchStats.retainedValue} detail="Kubernetes-retained history" />
|
|
860
|
+
</>
|
|
861
|
+
) : (
|
|
862
|
+
<>
|
|
863
|
+
<ApplicationFact label="Workloads" value={String(workloads.length)} detail={composition || 'No workloads'} />
|
|
864
|
+
<ApplicationFact label="Version" value={app.appVersion || (versions.length === 1 ? versions[0] : versions.length > 1 ? `${versions.length} versions` : 'Unknown')} />
|
|
865
|
+
</>
|
|
866
|
+
)}
|
|
867
|
+
{latestChange && (
|
|
868
|
+
<ApplicationLatestChangeFact
|
|
869
|
+
summary={latestChange}
|
|
870
|
+
sourceRef={app.sourceRef ? undefined : history?.sourceRef}
|
|
871
|
+
onSelectHistory={onSelectHistory}
|
|
872
|
+
onOpenSource={onOpenSource}
|
|
873
|
+
/>
|
|
874
|
+
)}
|
|
875
|
+
</div>
|
|
876
|
+
{hasEntrypoints && (
|
|
877
|
+
<ApplicationEntrypoints
|
|
878
|
+
relationships={rel}
|
|
879
|
+
onNavigateToResource={onNavigateToResource}
|
|
880
|
+
/>
|
|
881
|
+
)}
|
|
882
|
+
<ApplicationPanel title="Workloads">
|
|
883
|
+
<WorkloadsMatrix
|
|
884
|
+
workloads={workloads}
|
|
885
|
+
onSelectWorkload={onSelectWorkload}
|
|
886
|
+
/>
|
|
887
|
+
</ApplicationPanel>
|
|
888
|
+
{!pureBatch && <ApplicationBatchOverview activity={batchActivity} />}
|
|
889
|
+
</div>
|
|
890
|
+
<aside className={clsx('min-w-0 gap-4 xl:block xl:space-y-4', hasDependencies ? 'grid md:grid-cols-2' : 'max-w-xl xl:max-w-none')}>
|
|
891
|
+
<ApplicationSourceProvenance app={app} onOpenSource={onOpenSource} />
|
|
892
|
+
{hasDependencies && <ApplicationDependencies relationships={rel} onNavigateToResource={onNavigateToResource} />}
|
|
893
|
+
</aside>
|
|
894
|
+
</div>
|
|
528
895
|
</div>
|
|
529
|
-
)
|
|
896
|
+
);
|
|
530
897
|
}
|
|
531
898
|
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
for (const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
worst = { restarts: r, reason: w.reason, workload: `${w.kind}/${w.name}` }
|
|
538
|
-
}
|
|
899
|
+
function workloadKindComposition(workloads: AppWorkload[]): string {
|
|
900
|
+
const counts = new Map<string, number>();
|
|
901
|
+
for (const workload of workloads) {
|
|
902
|
+
if (!workload.kind) continue;
|
|
903
|
+
counts.set(workload.kind, (counts.get(workload.kind) ?? 0) + 1);
|
|
539
904
|
}
|
|
540
|
-
return
|
|
905
|
+
return [...counts.entries()]
|
|
906
|
+
.sort(
|
|
907
|
+
([aKind, aCount], [bKind, bCount]) =>
|
|
908
|
+
bCount - aCount || aKind.localeCompare(bKind),
|
|
909
|
+
)
|
|
910
|
+
.map(([kind, count]) => pluralize(count, kind))
|
|
911
|
+
.join(" / ");
|
|
541
912
|
}
|
|
542
913
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
// affordance at 5 envs or 100. Always ends with the evidence chip
|
|
547
|
-
// (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
|
|
548
|
-
// the amber lag chip.
|
|
549
|
-
const MAX_INLINE_ENVS = 4
|
|
550
|
-
|
|
551
|
-
function EnvSwitcher({
|
|
552
|
-
identityKey,
|
|
553
|
-
instances,
|
|
554
|
-
activeKey,
|
|
555
|
-
onSwitch,
|
|
914
|
+
function ApplicationSourceProvenance({
|
|
915
|
+
app,
|
|
916
|
+
onOpenSource,
|
|
556
917
|
}: {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
activeKey: string
|
|
560
|
-
onSwitch?: (appKey: string) => void
|
|
918
|
+
app: AppRow
|
|
919
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
561
920
|
}) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
useEffect(() => {
|
|
565
|
-
if (!open) return
|
|
566
|
-
const onDown = (e: MouseEvent) => {
|
|
567
|
-
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
568
|
-
}
|
|
569
|
-
const onKey = (e: KeyboardEvent) => {
|
|
570
|
-
if (e.key === 'Escape') setOpen(false)
|
|
571
|
-
}
|
|
572
|
-
document.addEventListener('mousedown', onDown)
|
|
573
|
-
document.addEventListener('keydown', onKey)
|
|
574
|
-
return () => {
|
|
575
|
-
document.removeEventListener('mousedown', onDown)
|
|
576
|
-
document.removeEventListener('keydown', onKey)
|
|
577
|
-
}
|
|
578
|
-
}, [open])
|
|
579
|
-
|
|
580
|
-
const envInstances = useMemo(() => collapseIdentityInstances(instances, activeKey), [instances, activeKey])
|
|
581
|
-
const lag = appGroupLagMessage(envInstances)
|
|
582
|
-
const active = envInstances.find((i) => i.appKey === activeKey) ?? instances.find((i) => i.appKey === activeKey)
|
|
583
|
-
const envCounts = useMemo(() => {
|
|
584
|
-
const counts = new Map<string, number>()
|
|
585
|
-
for (const inst of instances) counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1)
|
|
586
|
-
return counts
|
|
587
|
-
}, [instances])
|
|
588
|
-
const evidenceChip = (
|
|
589
|
-
<Tooltip
|
|
590
|
-
content={<AppIdentityTooltip identityKey={identityKey} members={instances.map((i) => ({ name: i.name, env: i.env, confidence: i.confidence, evidence: i.evidence }))} />}
|
|
591
|
-
delay={150}
|
|
592
|
-
>
|
|
593
|
-
<span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
|
|
594
|
-
<Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
|
|
595
|
-
</span>
|
|
596
|
-
</Tooltip>
|
|
597
|
-
)
|
|
598
|
-
const lagChip = lag && <span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
|
|
599
|
-
|
|
600
|
-
if (envInstances.length <= MAX_INLINE_ENVS) {
|
|
921
|
+
if (app.sourceRef) {
|
|
922
|
+
const sourceName = `${app.sourceRef.namespace}/${app.sourceRef.name}`
|
|
601
923
|
return (
|
|
602
|
-
<
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
<button
|
|
609
|
-
type="button"
|
|
610
|
-
disabled={isActive}
|
|
611
|
-
onClick={() => !isActive && onSwitch?.(inst.appKey)}
|
|
612
|
-
className={clsx(
|
|
613
|
-
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors',
|
|
614
|
-
isActive ? 'selection selection-ring font-medium' : 'bg-theme-surface ring-theme-border hover:bg-theme-hover',
|
|
615
|
-
)}
|
|
616
|
-
>
|
|
617
|
-
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
618
|
-
{inst.env}
|
|
619
|
-
{duplicateEnv ? <span className="max-w-24 truncate text-theme-text-tertiary">{inst.name}</span> : null}
|
|
620
|
-
</button>
|
|
924
|
+
<ApplicationPanel title="Deployment source">
|
|
925
|
+
<div className="space-y-3">
|
|
926
|
+
<div>
|
|
927
|
+
<div className="text-xs font-semibold uppercase text-theme-text-tertiary">{sourceObjectLabel(app.sourceRef)}</div>
|
|
928
|
+
<Tooltip content={sourceName} delay={150}>
|
|
929
|
+
<div className="mt-1 truncate font-mono text-sm font-medium text-theme-text-primary">{sourceName}</div>
|
|
621
930
|
</Tooltip>
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
931
|
+
</div>
|
|
932
|
+
{app.sourceStatus && <SourceDeliveryStatus status={app.sourceStatus} />}
|
|
933
|
+
{onOpenSource && (
|
|
934
|
+
<button
|
|
935
|
+
type="button"
|
|
936
|
+
onClick={() => onOpenSource(app.sourceRef!)}
|
|
937
|
+
className="inline-flex w-fit items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
|
|
938
|
+
>
|
|
939
|
+
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
940
|
+
{sourceLinkLabel(app.sourceRef)}
|
|
941
|
+
</button>
|
|
942
|
+
)}
|
|
943
|
+
</div>
|
|
944
|
+
</ApplicationPanel>
|
|
627
945
|
)
|
|
628
946
|
}
|
|
629
947
|
|
|
948
|
+
if (app.sourceConflict) {
|
|
949
|
+
return (
|
|
950
|
+
<ApplicationPanel title="Deployment source">
|
|
951
|
+
<div className="space-y-2">
|
|
952
|
+
<div className="flex items-center gap-2">
|
|
953
|
+
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
|
|
954
|
+
<span className="text-sm font-semibold text-theme-text-primary">Multiple deployment sources detected</span>
|
|
955
|
+
</div>
|
|
956
|
+
<p className="text-sm text-theme-text-secondary">Workloads in this application do not share one deployment manager.</p>
|
|
957
|
+
</div>
|
|
958
|
+
</ApplicationPanel>
|
|
959
|
+
)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const inferred = app.identity?.confidence === 'medium'
|
|
963
|
+
const identityLabel = app.identity?.source ? appSourceLabel(app.identity.source) : undefined
|
|
964
|
+
const tierLabel = !app.identity && app.tier ? overlayProvenance(app.tier) : undefined
|
|
630
965
|
return (
|
|
631
|
-
<
|
|
632
|
-
<
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
{evidenceChip}
|
|
645
|
-
{lagChip}
|
|
646
|
-
{open && (
|
|
647
|
-
<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">
|
|
648
|
-
{instances.map((inst) => {
|
|
649
|
-
const isActive = inst.appKey === activeKey
|
|
650
|
-
return (
|
|
651
|
-
<button
|
|
652
|
-
key={inst.appKey}
|
|
653
|
-
type="button"
|
|
654
|
-
role="option"
|
|
655
|
-
aria-selected={isActive}
|
|
656
|
-
onClick={() => {
|
|
657
|
-
setOpen(false)
|
|
658
|
-
if (!isActive) onSwitch?.(inst.appKey)
|
|
659
|
-
}}
|
|
660
|
-
className={clsx(
|
|
661
|
-
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs',
|
|
662
|
-
isActive ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
663
|
-
)}
|
|
664
|
-
>
|
|
665
|
-
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
666
|
-
<span className="w-20 shrink-0 font-medium text-theme-text-primary">{inst.env}</span>
|
|
667
|
-
<span className="min-w-0 flex-1 truncate text-theme-text-secondary">
|
|
668
|
-
{inst.name}
|
|
669
|
-
</span>
|
|
670
|
-
{inst.version && <span className="font-mono text-[10px] text-theme-text-tertiary">{midTruncate(inst.version, 18)}</span>}
|
|
671
|
-
</button>
|
|
672
|
-
)
|
|
673
|
-
})}
|
|
966
|
+
<ApplicationPanel title="Application identity">
|
|
967
|
+
<div className="space-y-2">
|
|
968
|
+
<div className="flex items-center gap-2">
|
|
969
|
+
{inferred && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />}
|
|
970
|
+
<span className="text-sm font-semibold text-theme-text-primary">
|
|
971
|
+
{identityLabel
|
|
972
|
+
? inferred
|
|
973
|
+
? 'Inferred application boundary'
|
|
974
|
+
: `Identified by ${identityLabel}`
|
|
975
|
+
: tierLabel
|
|
976
|
+
? `Grouped by ${tierLabel} metadata`
|
|
977
|
+
: 'Grouped from Kubernetes ownership'}
|
|
978
|
+
</span>
|
|
674
979
|
</div>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
980
|
+
{inferred && identityLabel && <p className="text-sm text-theme-text-secondary">Matched using {identityLabel}.</p>}
|
|
981
|
+
{app.identity?.evidence && (
|
|
982
|
+
<Tooltip content={app.identity.evidence} delay={150}>
|
|
983
|
+
<div className="truncate font-mono text-xs text-theme-text-tertiary">{app.identity.evidence}</div>
|
|
984
|
+
</Tooltip>
|
|
985
|
+
)}
|
|
986
|
+
</div>
|
|
987
|
+
</ApplicationPanel>
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function ApplicationEntrypoints({
|
|
992
|
+
relationships,
|
|
993
|
+
onNavigateToResource,
|
|
994
|
+
}: {
|
|
995
|
+
relationships: AppRow['relationships']
|
|
996
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
997
|
+
}) {
|
|
998
|
+
const serviceRefs = relationshipRefs(relationships, 'service')
|
|
999
|
+
const ingressRefs = relationshipRefs(relationships, 'ingress')
|
|
1000
|
+
const routeRefs = relationshipRefs(relationships, 'route')
|
|
1001
|
+
const serviceCount = serviceRefs.length
|
|
1002
|
+
const ingressCount = ingressRefs.length
|
|
1003
|
+
const routeCount = routeRefs.length
|
|
1004
|
+
const hasExternal = ingressCount + routeCount > 0
|
|
1005
|
+
if (serviceCount + ingressCount + routeCount === 0) return null
|
|
1006
|
+
|
|
1007
|
+
return (
|
|
1008
|
+
<ApplicationPanel title="Entrypoints">
|
|
1009
|
+
<div className="space-y-3">
|
|
1010
|
+
<div>
|
|
1011
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1012
|
+
<h3 className="text-sm font-semibold text-theme-text-primary">
|
|
1013
|
+
{hasExternal
|
|
1014
|
+
? "External entrypoints configured"
|
|
1015
|
+
: "Internal services configured"}
|
|
1016
|
+
</h3>
|
|
1017
|
+
<span
|
|
1018
|
+
className={`${CHIP} ${hasExternal ? CHIP_TONE.blue : CHIP_TONE.muted}`}
|
|
1019
|
+
>
|
|
1020
|
+
{hasExternal ? "External" : "Internal"}
|
|
1021
|
+
</span>
|
|
1022
|
+
</div>
|
|
1023
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
1024
|
+
{hasExternal
|
|
1025
|
+
? "Traffic can enter this application through ingress or route resources."
|
|
1026
|
+
: "Services target this application, but no ingress or route was detected."}
|
|
1027
|
+
</p>
|
|
1028
|
+
</div>
|
|
1029
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
1030
|
+
<ApplicationFact
|
|
1031
|
+
variant="bare"
|
|
1032
|
+
label="Services"
|
|
1033
|
+
value={String(serviceCount)}
|
|
1034
|
+
/>
|
|
1035
|
+
<ApplicationFact
|
|
1036
|
+
variant="bare"
|
|
1037
|
+
label="Ingresses"
|
|
1038
|
+
value={String(ingressCount)}
|
|
1039
|
+
/>
|
|
1040
|
+
<ApplicationFact
|
|
1041
|
+
variant="bare"
|
|
1042
|
+
label="Routes"
|
|
1043
|
+
value={String(routeCount)}
|
|
1044
|
+
/>
|
|
1045
|
+
</div>
|
|
1046
|
+
<div className="space-y-3 border-t border-theme-border pt-3">
|
|
1047
|
+
<ApplicationRelatedNameGroup
|
|
1048
|
+
label="Services"
|
|
1049
|
+
refs={serviceRefs}
|
|
1050
|
+
onNavigateToResource={onNavigateToResource}
|
|
1051
|
+
/>
|
|
1052
|
+
<ApplicationRelatedNameGroup
|
|
1053
|
+
label="Ingresses"
|
|
1054
|
+
refs={ingressRefs}
|
|
1055
|
+
onNavigateToResource={onNavigateToResource}
|
|
1056
|
+
/>
|
|
1057
|
+
<ApplicationRelatedNameGroup label="Routes" refs={routeRefs} onNavigateToResource={onNavigateToResource} />
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
</ApplicationPanel>
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function ApplicationDependencies({
|
|
1065
|
+
relationships,
|
|
1066
|
+
onNavigateToResource,
|
|
1067
|
+
}: {
|
|
1068
|
+
relationships: AppRow['relationships']
|
|
1069
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
1070
|
+
}) {
|
|
1071
|
+
const configs = relationshipRefs(relationships, 'config')
|
|
1072
|
+
const scalers = relationshipRefs(relationships, 'scaler')
|
|
1073
|
+
const storage = relationshipRefs(relationships, 'storage')
|
|
1074
|
+
const pdbs = relationshipRefs(relationships, 'pdb')
|
|
1075
|
+
const networkPolicies = relationshipRefs(relationships, 'networkPolicy')
|
|
1076
|
+
if (configs.length + scalers.length + storage.length + pdbs.length + networkPolicies.length === 0) return null
|
|
1077
|
+
|
|
1078
|
+
return (
|
|
1079
|
+
<ApplicationPanel title="Dependencies">
|
|
1080
|
+
<div className="space-y-3">
|
|
1081
|
+
<ApplicationDependencyRow label="Configuration" refs={configs} totalCount={relationships?.configs} detail="ConfigMaps and Secrets referenced by app workloads." onNavigateToResource={onNavigateToResource} />
|
|
1082
|
+
<ApplicationDependencyRow label="Autoscaling" refs={scalers} totalCount={relationships?.scalers} detail="Autoscalers controlling app workloads." onNavigateToResource={onNavigateToResource} />
|
|
1083
|
+
<ApplicationDependencyRow label="Storage" refs={storage} totalCount={relationships?.storage} detail="PersistentVolumeClaims mounted by app workloads." onNavigateToResource={onNavigateToResource} />
|
|
1084
|
+
<ApplicationDependencyRow label="Availability policy" refs={pdbs} totalCount={relationships?.pdbs} detail="PodDisruptionBudgets protecting app workloads." onNavigateToResource={onNavigateToResource} />
|
|
1085
|
+
<ApplicationDependencyRow label="Network policy" refs={networkPolicies} totalCount={relationships?.networkPolicies} detail="Network policies selecting app workloads." onNavigateToResource={onNavigateToResource} />
|
|
1086
|
+
</div>
|
|
1087
|
+
</ApplicationPanel>
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function ApplicationDependencyRow({
|
|
1092
|
+
label,
|
|
1093
|
+
refs,
|
|
1094
|
+
totalCount,
|
|
1095
|
+
detail,
|
|
1096
|
+
onNavigateToResource,
|
|
1097
|
+
}: {
|
|
1098
|
+
label: string
|
|
1099
|
+
refs: ResourceRef[]
|
|
1100
|
+
totalCount?: number
|
|
1101
|
+
detail: string
|
|
1102
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
1103
|
+
}) {
|
|
1104
|
+
if (refs.length === 0) return null
|
|
1105
|
+
const count = Math.max(totalCount ?? refs.length, refs.length)
|
|
1106
|
+
return (
|
|
1107
|
+
<div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
|
|
1108
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
1109
|
+
<div className="text-sm font-semibold text-theme-text-primary">
|
|
1110
|
+
{label}
|
|
1111
|
+
</div>
|
|
1112
|
+
<span className={`${CHIP} ${CHIP_TONE.muted}`}>
|
|
1113
|
+
{pluralize(count, "resource")}
|
|
1114
|
+
</span>
|
|
1115
|
+
</div>
|
|
1116
|
+
<div className="mt-1 text-xs text-theme-text-tertiary">{detail}</div>
|
|
1117
|
+
<ApplicationRelatedNameGroup label={label} refs={refs} totalCount={count} onNavigateToResource={onNavigateToResource} hideLabel />
|
|
1118
|
+
</div>
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function ApplicationRelatedNameGroup({
|
|
1123
|
+
label,
|
|
1124
|
+
refs,
|
|
1125
|
+
totalCount,
|
|
1126
|
+
onNavigateToResource,
|
|
1127
|
+
hideLabel,
|
|
1128
|
+
}: {
|
|
1129
|
+
label: string
|
|
1130
|
+
refs?: ResourceRef[]
|
|
1131
|
+
totalCount?: number
|
|
1132
|
+
onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
1133
|
+
hideLabel?: boolean
|
|
1134
|
+
}) {
|
|
1135
|
+
if (!refs || refs.length === 0) return null
|
|
1136
|
+
|
|
1137
|
+
const visible = refs.slice(0, 12)
|
|
1138
|
+
const count = Math.max(totalCount ?? refs.length, refs.length)
|
|
1139
|
+
const overflow = Math.max(0, count - visible.length)
|
|
1140
|
+
return (
|
|
1141
|
+
<div className={hideLabel ? 'mt-2' : undefined}>
|
|
1142
|
+
{!hideLabel && <div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}{count > 1 ? ` (${count})` : ''}</div>}
|
|
1143
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1144
|
+
{visible.map((ref) => (
|
|
1145
|
+
<ResourceRefBadge
|
|
1146
|
+
key={`${ref.group || ''}/${ref.kind}/${ref.namespace}/${ref.name}`}
|
|
1147
|
+
resourceRef={ref}
|
|
1148
|
+
onClick={onNavigateToResource ? (clicked) => onNavigateToResource(refToSelectedResource(clicked)) : undefined}
|
|
1149
|
+
/>
|
|
1150
|
+
))}
|
|
1151
|
+
{overflow > 0 && <span className={`${CHIP} ${CHIP_TONE.muted}`}>+{overflow} more</span>}
|
|
1152
|
+
</div>
|
|
1153
|
+
</div>
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
type RelationshipGroup = 'service' | 'ingress' | 'route' | 'config' | 'scaler' | 'storage' | 'pdb' | 'networkPolicy'
|
|
1158
|
+
|
|
1159
|
+
function relationshipRefs(relationships: AppRow['relationships'] | undefined, group: RelationshipGroup): ResourceRef[] {
|
|
1160
|
+
if (!relationships) return []
|
|
1161
|
+
switch (group) {
|
|
1162
|
+
case 'service':
|
|
1163
|
+
return relationships.serviceRefs ?? []
|
|
1164
|
+
case 'ingress':
|
|
1165
|
+
return relationships.ingressRefs ?? []
|
|
1166
|
+
case 'route':
|
|
1167
|
+
return relationships.routeRefs ?? []
|
|
1168
|
+
case 'config':
|
|
1169
|
+
return relationships.configRefs ?? []
|
|
1170
|
+
case 'scaler':
|
|
1171
|
+
return relationships.scalerRefs ?? []
|
|
1172
|
+
case 'storage':
|
|
1173
|
+
return relationships.storageRefs ?? []
|
|
1174
|
+
case 'pdb':
|
|
1175
|
+
return relationships.pdbRefs ?? []
|
|
1176
|
+
case 'networkPolicy':
|
|
1177
|
+
return relationships.networkPolicyRefs ?? []
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function ApplicationNow({
|
|
1182
|
+
issues,
|
|
1183
|
+
onSelectWorkload,
|
|
1184
|
+
}: {
|
|
1185
|
+
issues: AppIssue[]
|
|
1186
|
+
onSelectWorkload: (workload: AppWorkload) => void
|
|
1187
|
+
}) {
|
|
1188
|
+
if (issues.length === 0) return null
|
|
1189
|
+
|
|
1190
|
+
const top = issues[0];
|
|
1191
|
+
return (
|
|
1192
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
1193
|
+
<div className="flex flex-wrap items-start gap-3 border-b border-theme-border px-4 py-3">
|
|
1194
|
+
<span
|
|
1195
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${issueTone(top.severity)}`}
|
|
1196
|
+
>
|
|
1197
|
+
<AlertTriangle className="h-4 w-4" aria-hidden />
|
|
1198
|
+
</span>
|
|
1199
|
+
<div className="min-w-0 flex-1">
|
|
1200
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1201
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">
|
|
1202
|
+
{top.title}
|
|
1203
|
+
</h2>
|
|
1204
|
+
<span className={`${CHIP} ${issueTone(top.severity)}`}>
|
|
1205
|
+
{top.severity === "error" ? "Needs attention" : "Warning"}
|
|
1206
|
+
</span>
|
|
1207
|
+
</div>
|
|
1208
|
+
{top.detail && (
|
|
1209
|
+
<p className="mt-0.5 text-sm text-theme-text-secondary">
|
|
1210
|
+
{top.detail}
|
|
1211
|
+
</p>
|
|
1212
|
+
)}
|
|
1213
|
+
</div>
|
|
1214
|
+
{top.workload && (
|
|
1215
|
+
<button
|
|
1216
|
+
type="button"
|
|
1217
|
+
onClick={() => onSelectWorkload(top.workload!)}
|
|
1218
|
+
className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
|
|
1219
|
+
>
|
|
1220
|
+
Open workload
|
|
1221
|
+
</button>
|
|
1222
|
+
)}
|
|
1223
|
+
</div>
|
|
1224
|
+
{issues.length > 1 && (
|
|
1225
|
+
<div className="divide-y divide-theme-border px-4">
|
|
1226
|
+
{issues.slice(1, 5).map((issue) => (
|
|
1227
|
+
<div key={issue.key} className="flex items-start gap-3 py-3">
|
|
1228
|
+
<StatusDot
|
|
1229
|
+
tone={
|
|
1230
|
+
issue.severity === "error"
|
|
1231
|
+
? "unhealthy"
|
|
1232
|
+
: issue.severity === "warning"
|
|
1233
|
+
? "degraded"
|
|
1234
|
+
: "neutral"
|
|
1235
|
+
}
|
|
1236
|
+
className="mt-1"
|
|
1237
|
+
/>
|
|
1238
|
+
<div className="min-w-0 flex-1">
|
|
1239
|
+
<div className="truncate text-sm font-medium text-theme-text-primary">
|
|
1240
|
+
{issue.title}
|
|
1241
|
+
</div>
|
|
1242
|
+
{issue.detail && (
|
|
1243
|
+
<div className="truncate text-sm text-theme-text-tertiary">
|
|
1244
|
+
{issue.detail}
|
|
1245
|
+
</div>
|
|
1246
|
+
)}
|
|
1247
|
+
</div>
|
|
1248
|
+
{issue.workload && (
|
|
1249
|
+
<button
|
|
1250
|
+
type="button"
|
|
1251
|
+
onClick={() => onSelectWorkload(issue.workload!)}
|
|
1252
|
+
className="shrink-0 text-sm font-medium text-accent-text hover:underline"
|
|
1253
|
+
>
|
|
1254
|
+
Open
|
|
1255
|
+
</button>
|
|
1256
|
+
)}
|
|
1257
|
+
</div>
|
|
1258
|
+
))}
|
|
1259
|
+
</div>
|
|
1260
|
+
)}
|
|
1261
|
+
</section>
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function ApplicationBatchOverview({
|
|
1266
|
+
activity,
|
|
1267
|
+
}: {
|
|
1268
|
+
activity: AppBatchActivity[];
|
|
1269
|
+
}) {
|
|
1270
|
+
if (activity.length === 0) return null;
|
|
1271
|
+
|
|
1272
|
+
const stats = batchOverviewStats(activity);
|
|
1273
|
+
|
|
1274
|
+
return (
|
|
1275
|
+
<ApplicationPanel title="Batch activity">
|
|
1276
|
+
<div className="grid gap-4">
|
|
1277
|
+
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
1278
|
+
<ApplicationFact
|
|
1279
|
+
variant="bare"
|
|
1280
|
+
label="Latest run"
|
|
1281
|
+
value={stats.latestValue}
|
|
1282
|
+
detail={stats.latestDetail}
|
|
1283
|
+
/>
|
|
1284
|
+
<ApplicationFact
|
|
1285
|
+
variant="bare"
|
|
1286
|
+
label="Retained runs"
|
|
1287
|
+
value={stats.retainedValue}
|
|
1288
|
+
detail="Kubernetes-retained history"
|
|
1289
|
+
/>
|
|
1290
|
+
<ApplicationFact
|
|
1291
|
+
variant="bare"
|
|
1292
|
+
label="Active work"
|
|
1293
|
+
value={stats.activeValue}
|
|
1294
|
+
detail={stats.activeDetail}
|
|
1295
|
+
/>
|
|
1296
|
+
<ApplicationFact
|
|
1297
|
+
variant="bare"
|
|
1298
|
+
label="Schedules"
|
|
1299
|
+
value={stats.scheduleValue}
|
|
1300
|
+
detail={stats.scheduleDetail}
|
|
1301
|
+
monoValue={stats.scheduleMono}
|
|
1302
|
+
/>
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
</ApplicationPanel>
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function latestBatchRuns(activity: AppBatchActivity[]): AppBatchActivity[] {
|
|
1310
|
+
return [...activity].sort(
|
|
1311
|
+
(a, b) =>
|
|
1312
|
+
latestRunTime(b) - latestRunTime(a) ||
|
|
1313
|
+
a.workload.name.localeCompare(b.workload.name),
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function latestRunTimestamp(item: AppBatchActivity): string | undefined {
|
|
1318
|
+
return (
|
|
1319
|
+
item.latestStartedAt ||
|
|
1320
|
+
item.latestFinishedAt ||
|
|
1321
|
+
item.lastScheduledAt ||
|
|
1322
|
+
item.lastSuccessfulAt
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function latestRunTime(item: AppBatchActivity): number {
|
|
1327
|
+
const value = latestRunTimestamp(item);
|
|
1328
|
+
if (!value) return 0;
|
|
1329
|
+
const t = Date.parse(value);
|
|
1330
|
+
return Number.isNaN(t) ? 0 : t;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function batchOverviewStats(activity: AppBatchActivity[]) {
|
|
1334
|
+
const latest = latestBatchRuns(activity)[0];
|
|
1335
|
+
const schedules = Array.from(
|
|
1336
|
+
new Set(
|
|
1337
|
+
activity
|
|
1338
|
+
.map((item) => item.schedule)
|
|
1339
|
+
.filter((s): s is string => Boolean(s)),
|
|
1340
|
+
),
|
|
1341
|
+
);
|
|
1342
|
+
const retained = activity.reduce((n, item) => n + item.retainedRuns, 0);
|
|
1343
|
+
const succeeded = activity.reduce(
|
|
1344
|
+
(n, item) => n + (item.workload.batch?.succeededRuns ?? 0),
|
|
1345
|
+
0,
|
|
1346
|
+
);
|
|
1347
|
+
const failed = activity.reduce((n, item) => n + item.failedRuns, 0);
|
|
1348
|
+
const active = activity.reduce((n, item) => n + item.activeRuns, 0);
|
|
1349
|
+
|
|
1350
|
+
return {
|
|
1351
|
+
latestValue: latest?.latestRunPhase || latest?.label || "None",
|
|
1352
|
+
latestDetail: latest
|
|
1353
|
+
? `${latest.latestRunName || latest.workload.name}${latestRunTimestamp(latest) ? ` · ${relativeTime(latestRunTimestamp(latest)!)} ` : ""}`.trim()
|
|
1354
|
+
: "No retained runs",
|
|
1355
|
+
retainedValue:
|
|
1356
|
+
retained > 0 ? `${succeeded} succeeded / ${failed} failed` : "0 retained",
|
|
1357
|
+
activeValue: active > 0 ? `${active} running` : "None",
|
|
1358
|
+
activeDetail:
|
|
1359
|
+
active > 0 ? activeWorkloadNames(activity) : "No active batch work",
|
|
1360
|
+
scheduleValue:
|
|
1361
|
+
schedules.length === 0
|
|
1362
|
+
? "None"
|
|
1363
|
+
: schedules.length === 1
|
|
1364
|
+
? schedules[0]
|
|
1365
|
+
: `${schedules.length} schedules`,
|
|
1366
|
+
scheduleDetail:
|
|
1367
|
+
schedules.length === 1 && latest?.lastSuccessfulAt
|
|
1368
|
+
? `last success ${relativeTime(latest.lastSuccessfulAt)}`
|
|
1369
|
+
: undefined,
|
|
1370
|
+
scheduleMono: schedules.length === 1,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function activeWorkloadNames(activity: AppBatchActivity[]): string {
|
|
1375
|
+
const active = activity
|
|
1376
|
+
.filter((item) => item.activeRuns > 0)
|
|
1377
|
+
.map((item) => item.workload.name);
|
|
1378
|
+
if (active.length <= 2) return active.join(", ");
|
|
1379
|
+
return `${active.slice(0, 2).join(", ")} +${active.length - 2} more`;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function relativeTime(value: string): string {
|
|
1383
|
+
const t = Date.parse(value);
|
|
1384
|
+
if (Number.isNaN(t)) return value;
|
|
1385
|
+
const diff = Date.now() - t;
|
|
1386
|
+
if (diff < 60_000) return "just now";
|
|
1387
|
+
const minutes = Math.floor(diff / 60_000);
|
|
1388
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1389
|
+
const hours = Math.floor(minutes / 60);
|
|
1390
|
+
if (hours < 48) return `${hours}h ago`;
|
|
1391
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function ApplicationLatestHistory({
|
|
1395
|
+
history,
|
|
1396
|
+
sourceRef,
|
|
1397
|
+
showIncidentPreview,
|
|
1398
|
+
onSelectHistory,
|
|
1399
|
+
onOpenSource,
|
|
1400
|
+
}: {
|
|
1401
|
+
history?: AppHistory
|
|
1402
|
+
sourceRef?: AppSourceRef
|
|
1403
|
+
showIncidentPreview?: boolean
|
|
1404
|
+
onSelectHistory: () => void
|
|
1405
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
1406
|
+
}) {
|
|
1407
|
+
const summary = history?.summary
|
|
1408
|
+
const showIncident = summary?.state === 'incident' && showIncidentPreview
|
|
1409
|
+
if (!summary || !showIncident) return null
|
|
1410
|
+
const resolvedSource = sourceRef ?? history?.sourceRef
|
|
1411
|
+
const tone = CHIP_TONE.amber
|
|
1412
|
+
|
|
1413
|
+
return (
|
|
1414
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
1415
|
+
<div className="flex flex-wrap items-start gap-3">
|
|
1416
|
+
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${tone}`}>
|
|
1417
|
+
<AlertTriangle className="h-4 w-4" aria-hidden />
|
|
1418
|
+
</span>
|
|
1419
|
+
<div className="min-w-0 flex-1">
|
|
1420
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1421
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">{summary.title}</h2>
|
|
1422
|
+
<span className={`${CHIP} ${tone}`}>Latest incident</span>
|
|
1423
|
+
</div>
|
|
1424
|
+
{summary.detail && (
|
|
1425
|
+
<p className="mt-0.5 line-clamp-2 text-sm text-theme-text-secondary">
|
|
1426
|
+
{summary.detail}
|
|
1427
|
+
</p>
|
|
1428
|
+
)}
|
|
1429
|
+
{summary.timestamp && (
|
|
1430
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
1431
|
+
{formatAppEventTime(summary.timestamp)}
|
|
1432
|
+
</p>
|
|
1433
|
+
)}
|
|
1434
|
+
</div>
|
|
1435
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1436
|
+
{resolvedSource && !sourceRef && onOpenSource && (
|
|
1437
|
+
<button type="button" onClick={() => onOpenSource(resolvedSource)} className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover">
|
|
1438
|
+
{sourceLinkLabel(resolvedSource)}
|
|
1439
|
+
</button>
|
|
1440
|
+
)}
|
|
1441
|
+
<button
|
|
1442
|
+
type="button"
|
|
1443
|
+
onClick={onSelectHistory}
|
|
1444
|
+
className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
|
|
1445
|
+
>
|
|
1446
|
+
View history
|
|
1447
|
+
</button>
|
|
1448
|
+
</div>
|
|
1449
|
+
</div>
|
|
1450
|
+
</section>
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function ApplicationLatestChangeFact({
|
|
1455
|
+
summary,
|
|
1456
|
+
sourceRef,
|
|
1457
|
+
onSelectHistory,
|
|
1458
|
+
onOpenSource,
|
|
1459
|
+
}: {
|
|
1460
|
+
summary: NonNullable<AppHistory['summary']>
|
|
1461
|
+
sourceRef?: AppSourceRef
|
|
1462
|
+
onSelectHistory: () => void
|
|
1463
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
1464
|
+
}) {
|
|
1465
|
+
return (
|
|
1466
|
+
<div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm md:col-span-2 2xl:col-span-1">
|
|
1467
|
+
<div className="flex items-center justify-between gap-2">
|
|
1468
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Latest change</div>
|
|
1469
|
+
<button type="button" onClick={onSelectHistory} className="shrink-0 text-xs font-medium text-accent-text hover:underline">History</button>
|
|
1470
|
+
</div>
|
|
1471
|
+
<div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">{summary.title}</div>
|
|
1472
|
+
{summary.detail && <div className="mt-0.5 truncate font-mono text-xs text-theme-text-tertiary">{summary.detail}</div>}
|
|
1473
|
+
{sourceRef && onOpenSource && (
|
|
1474
|
+
<button type="button" onClick={() => onOpenSource(sourceRef)} className="mt-1 text-xs font-medium text-accent-text hover:underline">
|
|
1475
|
+
{sourceLinkLabel(sourceRef)}
|
|
1476
|
+
</button>
|
|
1477
|
+
)}
|
|
1478
|
+
</div>
|
|
1479
|
+
)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function WorkloadsMatrix({ workloads, onSelectWorkload }: { workloads: AppWorkload[]; onSelectWorkload: (workload: AppWorkload) => void }) {
|
|
1483
|
+
if (workloads.length === 0) {
|
|
1484
|
+
return <EmptyState variant="inline" headline="No inspectable workloads." />;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
return (
|
|
1488
|
+
<div className="overflow-x-auto">
|
|
1489
|
+
<table className="w-full min-w-[760px] table-fixed text-sm">
|
|
1490
|
+
<thead>
|
|
1491
|
+
<tr className="border-b border-theme-border text-left text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1492
|
+
<th className="w-[34%] px-2 py-2 font-semibold">Workload</th>
|
|
1493
|
+
<th className="w-[13%] px-2 py-2 font-semibold">Kind</th>
|
|
1494
|
+
<th className="w-[12%] px-2 py-2 font-semibold">Class</th>
|
|
1495
|
+
<th className="w-[14%] px-2 py-2 font-semibold">Status</th>
|
|
1496
|
+
<th className="w-[18%] px-2 py-2 font-semibold">Runtime</th>
|
|
1497
|
+
<th className="w-[16%] px-2 py-2 font-semibold">Version</th>
|
|
1498
|
+
</tr>
|
|
1499
|
+
</thead>
|
|
1500
|
+
<tbody>
|
|
1501
|
+
{workloads.map((w) => {
|
|
1502
|
+
const status = workloadRuntimeStatus(w)
|
|
1503
|
+
const reason = w.reason === 'Completed' && w.kind !== 'Job' ? undefined : w.reason
|
|
1504
|
+
return (
|
|
1505
|
+
<tr
|
|
1506
|
+
key={workloadKey(w)}
|
|
1507
|
+
className="border-b border-theme-border last:border-b-0 hover:bg-theme-hover"
|
|
1508
|
+
>
|
|
1509
|
+
<td className="truncate px-2 py-2">
|
|
1510
|
+
<button
|
|
1511
|
+
type="button"
|
|
1512
|
+
onClick={() => onSelectWorkload(w)}
|
|
1513
|
+
className="truncate font-medium text-theme-text-primary hover:text-accent-text hover:underline"
|
|
1514
|
+
>
|
|
1515
|
+
{w.name}
|
|
1516
|
+
</button>
|
|
1517
|
+
{reason && <div className="truncate text-xs text-theme-text-tertiary">{reason}</div>}
|
|
1518
|
+
</td>
|
|
1519
|
+
<td className="px-2 py-2 text-theme-text-secondary">{w.kind}</td>
|
|
1520
|
+
<td className="px-2 py-2 text-theme-text-secondary">{workloadClassOf(w.workload_class)}</td>
|
|
1521
|
+
<td className="px-2 py-2">
|
|
1522
|
+
<span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
|
|
1523
|
+
<StatusDot tone={mapHealthToTone(status.health)} />
|
|
1524
|
+
{status.label}
|
|
1525
|
+
</span>
|
|
1526
|
+
</td>
|
|
1527
|
+
<td className="px-2 py-2 text-xs text-theme-text-secondary">
|
|
1528
|
+
{workloadRuntimeDetail(w)}
|
|
1529
|
+
</td>
|
|
1530
|
+
<td className="truncate px-2 py-2 font-mono text-xs text-theme-text-secondary">
|
|
1531
|
+
{w.version || w.appVersion || "-"}
|
|
1532
|
+
</td>
|
|
1533
|
+
</tr>
|
|
1534
|
+
);
|
|
1535
|
+
})}
|
|
1536
|
+
</tbody>
|
|
1537
|
+
</table>
|
|
1538
|
+
</div>
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function workloadRuntimeStatus(workload: AppWorkload): {
|
|
1543
|
+
label: string;
|
|
1544
|
+
health: AppHealth;
|
|
1545
|
+
} {
|
|
1546
|
+
const batch = workload.batch;
|
|
1547
|
+
if (!batch) {
|
|
1548
|
+
const health = healthOf(workload.health);
|
|
1549
|
+
return { label: HEALTH_META[health].label, health };
|
|
1550
|
+
}
|
|
1551
|
+
if ((batch.activeRuns ?? 0) > 0 || batch.latestRunPhase === "Running")
|
|
1552
|
+
return { label: "Running", health: "neutral" };
|
|
1553
|
+
if (batch.latestRunPhase === "Failed" || batch.latestRunPhase === "Error")
|
|
1554
|
+
return { label: "Failed", health: "unhealthy" };
|
|
1555
|
+
if (batch.latestRunPhase === "Succeeded")
|
|
1556
|
+
return { label: "Succeeded", health: "healthy" };
|
|
1557
|
+
if (batch.suspended) return { label: "Suspended", health: "neutral" };
|
|
1558
|
+
return { label: "Idle", health: "neutral" };
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function workloadRuntimeDetail(workload: AppWorkload): string {
|
|
1562
|
+
const batch = workload.batch;
|
|
1563
|
+
if (!batch) return `${workload.ready}/${workload.desired} ready`;
|
|
1564
|
+
if ((batch.activeRuns ?? 0) > 0)
|
|
1565
|
+
return pluralize(batch.activeRuns ?? 0, "active run");
|
|
1566
|
+
const at =
|
|
1567
|
+
batch.latestStartedAt || batch.latestFinishedAt || batch.lastScheduledAt;
|
|
1568
|
+
if (batch.latestRunName)
|
|
1569
|
+
return `${midTruncate(batch.latestRunName, 28)}${at ? ` · ${relativeTime(at)}` : ""}`;
|
|
1570
|
+
if (batch.schedule) return `Schedule ${batch.schedule}`;
|
|
1571
|
+
return "No retained runs";
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function ApplicationTopology({
|
|
1575
|
+
topology,
|
|
1576
|
+
loading,
|
|
1577
|
+
available,
|
|
1578
|
+
workloads,
|
|
1579
|
+
colorByWorkload,
|
|
1580
|
+
focusNodeId,
|
|
1581
|
+
focusedOwnerId,
|
|
1582
|
+
onNodeClick,
|
|
1583
|
+
onNodeHover,
|
|
1584
|
+
onFocusWorkload,
|
|
1585
|
+
onSelectWorkload,
|
|
1586
|
+
deploymentLayer,
|
|
1587
|
+
deploymentSource,
|
|
1588
|
+
onOpenSource,
|
|
1589
|
+
onToggleReplicaSets,
|
|
1590
|
+
}: {
|
|
1591
|
+
topology: Topology | null
|
|
1592
|
+
loading?: boolean
|
|
1593
|
+
available: boolean
|
|
1594
|
+
workloads: AppWorkload[]
|
|
1595
|
+
colorByWorkload: Map<string, number> | null
|
|
1596
|
+
focusNodeId?: string
|
|
1597
|
+
focusedOwnerId: WorkloadFocus
|
|
1598
|
+
onNodeClick: (node: TopologyNode) => void
|
|
1599
|
+
onNodeHover: (node: TopologyNode | null) => void
|
|
1600
|
+
onFocusWorkload: (owner: WorkloadFocus) => void
|
|
1601
|
+
onSelectWorkload: (workload: AppWorkload) => void
|
|
1602
|
+
deploymentLayer: DeploymentTopologyLayer | null
|
|
1603
|
+
deploymentSource?: AppSourceRef
|
|
1604
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
1605
|
+
onToggleReplicaSets: (ownerID: string) => void
|
|
1606
|
+
}) {
|
|
1607
|
+
const hasSharedOrUnscopedNodes = useMemo(
|
|
1608
|
+
() => topology?.nodes.some((node) => {
|
|
1609
|
+
if (node.data?.deploymentMembership === 'source-only') return false
|
|
1610
|
+
const stamp = ownershipOf(node.data)
|
|
1611
|
+
return !stamp.ownerWorkloadId && stamp.focusWorkloadIds.length !== 1
|
|
1612
|
+
}) ?? false,
|
|
1613
|
+
[topology],
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1616
|
+
return (
|
|
1617
|
+
<div className="relative min-h-0 flex-1 bg-theme-surface">
|
|
1618
|
+
{available && topology ? (
|
|
1619
|
+
<>
|
|
1620
|
+
<TopologyGraph
|
|
1621
|
+
topology={topology}
|
|
1622
|
+
viewMode="resources"
|
|
1623
|
+
groupingMode="namespace"
|
|
1624
|
+
hideGroupHeader
|
|
1625
|
+
onNodeClick={onNodeClick}
|
|
1626
|
+
showExportButton={false}
|
|
1627
|
+
focusNodeId={focusNodeId}
|
|
1628
|
+
focusedOwnerId={focusedOwnerId}
|
|
1629
|
+
onNodeHover={onNodeHover}
|
|
1630
|
+
onToggleReplicaSets={onToggleReplicaSets}
|
|
1631
|
+
fitViewPadding={{ top: 0.08, right: 0.05, bottom: 0.08, left: '360px' }}
|
|
1632
|
+
/>
|
|
1633
|
+
<TopologyWorkloadLegend
|
|
1634
|
+
workloads={workloads}
|
|
1635
|
+
colorByWorkload={colorByWorkload}
|
|
1636
|
+
focusedOwnerId={focusedOwnerId}
|
|
1637
|
+
showSharedOrUnscoped={hasSharedOrUnscopedNodes}
|
|
1638
|
+
onFocus={onFocusWorkload}
|
|
1639
|
+
onSelectWorkload={onSelectWorkload}
|
|
1640
|
+
deploymentLayer={deploymentLayer}
|
|
1641
|
+
deploymentSource={deploymentSource}
|
|
1642
|
+
onOpenSource={onOpenSource}
|
|
1643
|
+
/>
|
|
1644
|
+
</>
|
|
1645
|
+
) : loading ? (
|
|
1646
|
+
<PaneLoader label="Loading topology..." className="absolute inset-0" />
|
|
1647
|
+
) : (
|
|
1648
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
1649
|
+
<EmptyState
|
|
1650
|
+
headline="No topology available"
|
|
1651
|
+
body="Radar could not build an application topology for this app."
|
|
1652
|
+
/>
|
|
1653
|
+
</div>
|
|
1654
|
+
)}
|
|
1655
|
+
</div>
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function ApplicationHistoryView({
|
|
1660
|
+
history,
|
|
1661
|
+
loading,
|
|
1662
|
+
fallbackEvents,
|
|
1663
|
+
workloads,
|
|
1664
|
+
onSelectWorkload,
|
|
1665
|
+
onOpenSource,
|
|
1666
|
+
}: {
|
|
1667
|
+
history?: AppHistory;
|
|
1668
|
+
loading?: boolean;
|
|
1669
|
+
fallbackEvents: NonNullable<AppRow["events"]>;
|
|
1670
|
+
workloads: AppWorkload[];
|
|
1671
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
1672
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
1673
|
+
}) {
|
|
1674
|
+
const anchors = history?.anchors ?? [];
|
|
1675
|
+
const incidents =
|
|
1676
|
+
history?.incidents ??
|
|
1677
|
+
fallbackEvents.map((event) => ({
|
|
1678
|
+
severity: "warning",
|
|
1679
|
+
title: event.object ? `${event.reason} on ${event.object}` : event.reason,
|
|
1680
|
+
object: event.object,
|
|
1681
|
+
message: event.message,
|
|
1682
|
+
count: event.count,
|
|
1683
|
+
firstSeen: event.firstSeen,
|
|
1684
|
+
lastSeen: event.lastSeen,
|
|
1685
|
+
}));
|
|
1686
|
+
const empty = !loading && anchors.length === 0 && incidents.length === 0;
|
|
1687
|
+
|
|
1688
|
+
return (
|
|
1689
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
1690
|
+
<div className="w-full max-w-[2400px] space-y-4 p-4 sm:p-6">
|
|
1691
|
+
{loading && (
|
|
1692
|
+
<ApplicationPanel title="History">
|
|
1693
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
1694
|
+
Loading application history...
|
|
1695
|
+
</div>
|
|
1696
|
+
</ApplicationPanel>
|
|
1697
|
+
)}
|
|
1698
|
+
{history?.partialSources && history.partialSources.length > 0 && (
|
|
1699
|
+
<ApplicationPanel title="Partial history">
|
|
1700
|
+
<div className="space-y-1 text-sm text-theme-text-secondary">
|
|
1701
|
+
{history.partialSources.map((msg, idx) => (
|
|
1702
|
+
<div key={`${msg}-${idx}`}>{msg}</div>
|
|
1703
|
+
))}
|
|
1704
|
+
</div>
|
|
1705
|
+
</ApplicationPanel>
|
|
1706
|
+
)}
|
|
1707
|
+
{incidents.length > 0 && (
|
|
1708
|
+
<ApplicationPanel title="Current incidents">
|
|
1709
|
+
<div className="divide-y divide-theme-border">
|
|
1710
|
+
{incidents.map((incident, idx) => {
|
|
1711
|
+
const workload = directWorkloadForEvent(
|
|
1712
|
+
{
|
|
1713
|
+
object: incident.object,
|
|
1714
|
+
reason: "",
|
|
1715
|
+
count: 1,
|
|
1716
|
+
type: "Warning",
|
|
1717
|
+
},
|
|
1718
|
+
workloads,
|
|
1719
|
+
);
|
|
1720
|
+
return (
|
|
1721
|
+
<HistoryIncidentLine
|
|
1722
|
+
key={`${incident.object}-${incident.title}-${idx}`}
|
|
1723
|
+
incident={incident}
|
|
1724
|
+
action={
|
|
1725
|
+
workload ? (
|
|
1726
|
+
<button
|
|
1727
|
+
type="button"
|
|
1728
|
+
onClick={() => onSelectWorkload(workload)}
|
|
1729
|
+
className="text-xs font-medium text-accent-text hover:underline"
|
|
1730
|
+
>
|
|
1731
|
+
Open workload
|
|
1732
|
+
</button>
|
|
1733
|
+
) : undefined
|
|
1734
|
+
}
|
|
1735
|
+
/>
|
|
1736
|
+
);
|
|
1737
|
+
})}
|
|
1738
|
+
</div>
|
|
1739
|
+
</ApplicationPanel>
|
|
1740
|
+
)}
|
|
1741
|
+
{anchors.length > 0 && (
|
|
1742
|
+
<ApplicationPanel
|
|
1743
|
+
title={
|
|
1744
|
+
<span className="flex items-center justify-between gap-3">
|
|
1745
|
+
<span>Deployment history</span>
|
|
1746
|
+
{history?.sourceRef && onOpenSource && (
|
|
1747
|
+
<button
|
|
1748
|
+
type="button"
|
|
1749
|
+
onClick={() => onOpenSource(history.sourceRef!)}
|
|
1750
|
+
className="text-xs font-medium text-accent-text hover:underline"
|
|
1751
|
+
>
|
|
1752
|
+
{sourceLinkLabel(history.sourceRef)}
|
|
1753
|
+
</button>
|
|
1754
|
+
)}
|
|
1755
|
+
</span>
|
|
1756
|
+
}
|
|
1757
|
+
>
|
|
1758
|
+
<div className="divide-y divide-theme-border">
|
|
1759
|
+
{anchors.map((anchor, idx) => (
|
|
1760
|
+
<HistoryAnchorLine
|
|
1761
|
+
key={`${anchor.type}-${anchor.timestamp}-${anchor.revision}-${idx}`}
|
|
1762
|
+
anchor={anchor}
|
|
1763
|
+
/>
|
|
1764
|
+
))}
|
|
1765
|
+
</div>
|
|
1766
|
+
</ApplicationPanel>
|
|
1767
|
+
)}
|
|
1768
|
+
{empty && (
|
|
1769
|
+
<ApplicationPanel title="History">
|
|
1770
|
+
<EmptyState
|
|
1771
|
+
variant="inline"
|
|
1772
|
+
headline="No retained deployment history."
|
|
1773
|
+
body="Current application state is still available in Overview."
|
|
1774
|
+
/>
|
|
1775
|
+
</ApplicationPanel>
|
|
1776
|
+
)}
|
|
1777
|
+
</div>
|
|
1778
|
+
</div>
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function HistoryAnchorLine({
|
|
1783
|
+
anchor,
|
|
1784
|
+
}: {
|
|
1785
|
+
anchor: NonNullable<AppHistory["anchors"]>[number];
|
|
1786
|
+
}) {
|
|
1787
|
+
return (
|
|
1788
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 py-3 first:pt-0 last:pb-0">
|
|
1789
|
+
<div className="min-w-0">
|
|
1790
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1791
|
+
<span className="font-medium text-theme-text-primary">
|
|
1792
|
+
{anchor.title}
|
|
1793
|
+
</span>
|
|
1794
|
+
{anchor.status && (
|
|
1795
|
+
<span className={`${CHIP} ${historyStatusTone(anchor.status)}`}>
|
|
1796
|
+
{anchor.status}
|
|
1797
|
+
</span>
|
|
1798
|
+
)}
|
|
1799
|
+
</div>
|
|
1800
|
+
{anchor.revision && (
|
|
1801
|
+
<div className="truncate font-mono text-sm text-theme-text-secondary">
|
|
1802
|
+
{anchor.revision}
|
|
1803
|
+
</div>
|
|
1804
|
+
)}
|
|
1805
|
+
{anchor.message && (
|
|
1806
|
+
<div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
|
|
1807
|
+
{anchor.message}
|
|
1808
|
+
</div>
|
|
1809
|
+
)}
|
|
1810
|
+
{anchor.source && (
|
|
1811
|
+
<div className="mt-1 truncate text-xs text-theme-text-tertiary">
|
|
1812
|
+
{anchor.source}
|
|
1813
|
+
</div>
|
|
1814
|
+
)}
|
|
1815
|
+
</div>
|
|
1816
|
+
<div className="text-right text-xs text-theme-text-tertiary">
|
|
1817
|
+
{formatAppEventTime(anchor.timestamp)}
|
|
1818
|
+
{anchor.initiatedBy && <div>{anchor.initiatedBy}</div>}
|
|
1819
|
+
</div>
|
|
1820
|
+
</div>
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function HistoryIncidentLine({
|
|
1825
|
+
incident,
|
|
1826
|
+
action,
|
|
1827
|
+
}: {
|
|
1828
|
+
incident: NonNullable<AppHistory["incidents"]>[number];
|
|
1829
|
+
action?: ReactNode;
|
|
1830
|
+
}) {
|
|
1831
|
+
return (
|
|
1832
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 py-3 first:pt-0 last:pb-0">
|
|
1833
|
+
<div className="min-w-0">
|
|
1834
|
+
<div className="truncate font-medium text-theme-text-primary">
|
|
1835
|
+
{incident.title}
|
|
1836
|
+
</div>
|
|
1837
|
+
{incident.object && (
|
|
1838
|
+
<div className="truncate text-sm text-theme-text-secondary">
|
|
1839
|
+
{incident.object}
|
|
1840
|
+
</div>
|
|
1841
|
+
)}
|
|
1842
|
+
{incident.message && (
|
|
1843
|
+
<div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
|
|
1844
|
+
{incident.message}
|
|
1845
|
+
</div>
|
|
1846
|
+
)}
|
|
1847
|
+
</div>
|
|
1848
|
+
<div className="text-right text-xs text-theme-text-tertiary">
|
|
1849
|
+
{formatAppEventTime(incident.lastSeen)}
|
|
1850
|
+
{incident.count && incident.count > 1 && <div>{incident.count}x</div>}
|
|
1851
|
+
{action && <div className="mt-1">{action}</div>}
|
|
1852
|
+
</div>
|
|
1853
|
+
</div>
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function buildAppIssues(
|
|
1858
|
+
workloads: AppWorkload[],
|
|
1859
|
+
events: NonNullable<AppRow["events"]>,
|
|
1860
|
+
): AppIssue[] {
|
|
1861
|
+
const issues: AppIssue[] = [];
|
|
1862
|
+
const issueByWorkload = new Map<string, AppIssue>();
|
|
1863
|
+
|
|
1864
|
+
for (const workload of workloads) {
|
|
1865
|
+
const health = healthOf(workload.health);
|
|
1866
|
+
const notReady = workload.desired > 0 && workload.ready < workload.desired;
|
|
1867
|
+
const hasRestarts = (workload.restarts ?? 0) > 0;
|
|
1868
|
+
const hasReason = Boolean(workload.reason);
|
|
1869
|
+
const hasHealthProblem = health === "degraded" || health === "unhealthy";
|
|
1870
|
+
if (!hasHealthProblem && !notReady && !hasRestarts && !hasReason) continue;
|
|
1871
|
+
|
|
1872
|
+
const severity: AppIssueSeverity =
|
|
1873
|
+
health === "unhealthy" || (workload.desired > 0 && workload.ready === 0)
|
|
1874
|
+
? "error"
|
|
1875
|
+
: "warning";
|
|
1876
|
+
const detail = [
|
|
1877
|
+
notReady ? `${workload.ready}/${workload.desired} ready` : undefined,
|
|
1878
|
+
hasRestarts ? pluralize(workload.restarts, "restart") : undefined,
|
|
1879
|
+
workload.reason,
|
|
1880
|
+
]
|
|
1881
|
+
.filter(Boolean)
|
|
1882
|
+
.join(" · ");
|
|
1883
|
+
const issue: AppIssue = {
|
|
1884
|
+
key: `workload:${workloadKey(workload)}`,
|
|
1885
|
+
severity,
|
|
1886
|
+
title: `${workload.name} ${health === "unhealthy" ? "is down" : health === "degraded" || notReady ? "is degraded" : "needs attention"}`,
|
|
1887
|
+
detail,
|
|
1888
|
+
workload,
|
|
1889
|
+
};
|
|
1890
|
+
issueByWorkload.set(workloadKey(workload), issue);
|
|
1891
|
+
issues.push(issue);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
for (const event of events) {
|
|
1895
|
+
const workload = directWorkloadForEvent(event, workloads);
|
|
1896
|
+
if (workload) {
|
|
1897
|
+
const existing = issueByWorkload.get(workloadKey(workload));
|
|
1898
|
+
if (existing) {
|
|
1899
|
+
existing.detail = [existing.detail, event.reason]
|
|
1900
|
+
.filter(Boolean)
|
|
1901
|
+
.join(" · ");
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
issues.push({
|
|
1905
|
+
key: `event:${event.object}:${event.reason}`,
|
|
1906
|
+
severity: "warning",
|
|
1907
|
+
title: `${event.reason} on ${workload.name}`,
|
|
1908
|
+
detail: event.message,
|
|
1909
|
+
workload,
|
|
1910
|
+
});
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
issues.push({
|
|
1914
|
+
key: `event:${event.object}:${event.reason}`,
|
|
1915
|
+
severity: "warning",
|
|
1916
|
+
title: `${event.reason} on ${event.object}`,
|
|
1917
|
+
detail: event.message,
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
const rank: Record<AppIssueSeverity, number> = {
|
|
1922
|
+
error: 0,
|
|
1923
|
+
warning: 1,
|
|
1924
|
+
info: 2,
|
|
1925
|
+
};
|
|
1926
|
+
return issues.sort(
|
|
1927
|
+
(a, b) =>
|
|
1928
|
+
rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title),
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function directWorkloadForEvent(
|
|
1933
|
+
event: NonNullable<AppRow["events"]>[number],
|
|
1934
|
+
workloads: AppWorkload[],
|
|
1935
|
+
): AppWorkload | undefined {
|
|
1936
|
+
const parsed = parseEventObject(event.object);
|
|
1937
|
+
if (!parsed) return undefined;
|
|
1938
|
+
const matches = workloads.filter(
|
|
1939
|
+
(w) =>
|
|
1940
|
+
w.kind.toLowerCase() === parsed.kind.toLowerCase() &&
|
|
1941
|
+
w.name === parsed.name,
|
|
1942
|
+
);
|
|
1943
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function parseEventObject(
|
|
1947
|
+
object: string,
|
|
1948
|
+
): { kind: string; name: string } | null {
|
|
1949
|
+
const slash = object.indexOf("/");
|
|
1950
|
+
if (slash <= 0 || slash === object.length - 1) return null;
|
|
1951
|
+
return { kind: object.slice(0, slash), name: object.slice(slash + 1) };
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function issueTone(severity: AppIssueSeverity): string {
|
|
1955
|
+
if (severity === "error") return CHIP_TONE.rose;
|
|
1956
|
+
if (severity === "warning") return CHIP_TONE.amber;
|
|
1957
|
+
return CHIP_TONE.blue;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function historyStatusTone(status: string): string {
|
|
1961
|
+
const s = status.toLowerCase();
|
|
1962
|
+
if (s.includes("fail") || s.includes("error")) return CHIP_TONE.rose;
|
|
1963
|
+
if (s.includes("running") || s.includes("pending") || s.includes("progress"))
|
|
1964
|
+
return CHIP_TONE.amber;
|
|
1965
|
+
if (s.includes("succeed") || s.includes("deployed") || s.includes("ready"))
|
|
1966
|
+
return CHIP_TONE.emerald;
|
|
1967
|
+
return CHIP_TONE.muted;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function sourceLinkLabel(source: AppSourceRef): string {
|
|
1971
|
+
return `View ${sourceObjectLabel(source)}`
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
function sourceObjectLabel(source: AppSourceRef): string {
|
|
1975
|
+
if (source.type === 'helm') return 'Helm release'
|
|
1976
|
+
if (source.tool === 'argocd' || source.kind.toLowerCase() === 'application') return 'Argo CD application'
|
|
1977
|
+
if (source.kind.toLowerCase() === 'helmrelease') return 'Flux HelmRelease'
|
|
1978
|
+
if (source.kind.toLowerCase() === 'kustomization') return 'Flux Kustomization'
|
|
1979
|
+
return 'GitOps source'
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function sourceInventoryLabel(source: AppSourceRef): string {
|
|
1983
|
+
if (source.type === 'helm') return 'Helm'
|
|
1984
|
+
if (source.tool === 'argocd' || source.kind.toLowerCase() === 'application') return 'Argo CD'
|
|
1985
|
+
if (source.tool === 'fluxcd') return 'Flux'
|
|
1986
|
+
return 'GitOps'
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function SourceDeliveryStatus({ status }: { status: NonNullable<AppRow['sourceStatus']> }) {
|
|
1990
|
+
const values = [
|
|
1991
|
+
status.sync ? { label: status.sync, tone: sourceSyncHealth(status.sync) } : null,
|
|
1992
|
+
status.health ? { label: status.health, tone: sourceReportedHealth(status.health) } : null,
|
|
1993
|
+
].filter((value): value is { label: string; tone: AppHealth } => value !== null)
|
|
1994
|
+
|
|
1995
|
+
return (
|
|
1996
|
+
<span className="inline-flex min-w-0 items-center gap-2">
|
|
1997
|
+
{values.map((value, index) => (
|
|
1998
|
+
<span key={`${value.label}-${index}`} className="inline-flex min-w-0 items-center gap-1.5">
|
|
1999
|
+
{index > 0 && <span className="text-theme-text-tertiary" aria-hidden>·</span>}
|
|
2000
|
+
<StatusDot tone={mapHealthToTone(value.tone)} />
|
|
2001
|
+
<span className="truncate">{value.label}</span>
|
|
2002
|
+
</span>
|
|
2003
|
+
))}
|
|
2004
|
+
</span>
|
|
2005
|
+
)
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function formatAppEventTime(value?: string): string {
|
|
2009
|
+
if (!value || value.startsWith("0001-01-01T00:00:00")) return "";
|
|
2010
|
+
return value;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function ApplicationPanel({
|
|
2014
|
+
title,
|
|
2015
|
+
children,
|
|
2016
|
+
}: {
|
|
2017
|
+
title: ReactNode;
|
|
2018
|
+
children: ReactNode;
|
|
2019
|
+
}) {
|
|
2020
|
+
return (
|
|
2021
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
|
|
2022
|
+
<h2 className="mb-3 text-sm font-semibold text-theme-text-primary">
|
|
2023
|
+
{title}
|
|
2024
|
+
</h2>
|
|
2025
|
+
{children}
|
|
2026
|
+
</section>
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function ApplicationFact({
|
|
2031
|
+
label,
|
|
2032
|
+
value,
|
|
2033
|
+
detail,
|
|
2034
|
+
monoValue,
|
|
2035
|
+
monoDetail,
|
|
2036
|
+
variant = "card",
|
|
2037
|
+
}: {
|
|
2038
|
+
label: string
|
|
2039
|
+
value: ReactNode
|
|
2040
|
+
detail?: string
|
|
2041
|
+
monoValue?: boolean
|
|
2042
|
+
monoDetail?: boolean
|
|
2043
|
+
variant?: 'card' | 'row' | 'bare'
|
|
2044
|
+
}) {
|
|
2045
|
+
if (variant === "row") {
|
|
2046
|
+
return (
|
|
2047
|
+
<div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
|
|
2048
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2049
|
+
{label}
|
|
2050
|
+
</div>
|
|
2051
|
+
<div
|
|
2052
|
+
className={clsx(
|
|
2053
|
+
"mt-1 truncate text-sm font-semibold text-theme-text-primary",
|
|
2054
|
+
monoValue && "font-mono",
|
|
2055
|
+
)}
|
|
2056
|
+
>
|
|
2057
|
+
{value}
|
|
2058
|
+
</div>
|
|
2059
|
+
{detail && (
|
|
2060
|
+
<div
|
|
2061
|
+
className={clsx(
|
|
2062
|
+
"mt-0.5 truncate text-xs text-theme-text-tertiary",
|
|
2063
|
+
monoDetail && "font-mono",
|
|
2064
|
+
)}
|
|
2065
|
+
>
|
|
2066
|
+
{detail}
|
|
2067
|
+
</div>
|
|
2068
|
+
)}
|
|
2069
|
+
</div>
|
|
2070
|
+
);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
if (variant === "bare") {
|
|
2074
|
+
return (
|
|
2075
|
+
<div className="min-w-0">
|
|
2076
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2077
|
+
{label}
|
|
2078
|
+
</div>
|
|
2079
|
+
<div
|
|
2080
|
+
className={clsx(
|
|
2081
|
+
"mt-1 truncate text-sm font-semibold text-theme-text-primary",
|
|
2082
|
+
monoValue && "font-mono",
|
|
2083
|
+
)}
|
|
2084
|
+
>
|
|
2085
|
+
{value}
|
|
2086
|
+
</div>
|
|
2087
|
+
{detail && (
|
|
2088
|
+
<div
|
|
2089
|
+
className={clsx(
|
|
2090
|
+
"mt-0.5 truncate text-xs text-theme-text-tertiary",
|
|
2091
|
+
monoDetail && "font-mono",
|
|
2092
|
+
)}
|
|
2093
|
+
>
|
|
2094
|
+
{detail}
|
|
2095
|
+
</div>
|
|
2096
|
+
)}
|
|
2097
|
+
</div>
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
return (
|
|
2102
|
+
<div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
2103
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2104
|
+
{label}
|
|
2105
|
+
</div>
|
|
2106
|
+
<div
|
|
2107
|
+
className={clsx(
|
|
2108
|
+
"mt-1 truncate text-sm font-semibold text-theme-text-primary",
|
|
2109
|
+
monoValue && "font-mono",
|
|
2110
|
+
)}
|
|
2111
|
+
>
|
|
2112
|
+
{value}
|
|
2113
|
+
</div>
|
|
2114
|
+
{detail && (
|
|
2115
|
+
<div
|
|
2116
|
+
className={clsx(
|
|
2117
|
+
"mt-0.5 truncate text-xs text-theme-text-tertiary",
|
|
2118
|
+
monoDetail && "font-mono",
|
|
2119
|
+
)}
|
|
2120
|
+
>
|
|
2121
|
+
{detail}
|
|
2122
|
+
</div>
|
|
2123
|
+
)}
|
|
2124
|
+
</div>
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function ApplicationScopeSelector({
|
|
2129
|
+
workloads,
|
|
2130
|
+
selectedWorkload,
|
|
2131
|
+
onSelect,
|
|
2132
|
+
onFocus,
|
|
2133
|
+
}: {
|
|
2134
|
+
workloads: AppWorkload[];
|
|
2135
|
+
selectedWorkload: AppWorkload | null;
|
|
2136
|
+
onSelect: (key: string | null) => void;
|
|
2137
|
+
onFocus: (owner: WorkloadFocus) => void;
|
|
2138
|
+
}) {
|
|
2139
|
+
const [open, setOpen] = useState(false);
|
|
2140
|
+
const [query, setQuery] = useState("");
|
|
2141
|
+
const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
|
|
2142
|
+
const selectedKey = selectedWorkload ? workloadKey(selectedWorkload) : null;
|
|
2143
|
+
const filteredWorkloads = useMemo(() => {
|
|
2144
|
+
const q = query.trim().toLowerCase();
|
|
2145
|
+
if (!q) return workloads;
|
|
2146
|
+
return workloads.filter((w) =>
|
|
2147
|
+
`${w.kind} ${w.namespace} ${w.name}`.toLowerCase().includes(q),
|
|
2148
|
+
);
|
|
2149
|
+
}, [query, workloads]);
|
|
2150
|
+
|
|
2151
|
+
if (workloads.length <= 1) {
|
|
2152
|
+
return selectedWorkload ? (
|
|
2153
|
+
<StaticWorkloadScope workload={selectedWorkload} />
|
|
2154
|
+
) : (
|
|
2155
|
+
<span className="inline-flex max-w-[min(48vw,34rem)] items-center gap-2 rounded-md bg-theme-base px-2.5 py-1.5 text-sm ring-1 ring-inset ring-theme-border">
|
|
2156
|
+
<Boxes
|
|
2157
|
+
className="h-4 w-4 shrink-0 text-theme-text-secondary"
|
|
2158
|
+
aria-hidden
|
|
2159
|
+
/>
|
|
2160
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
2161
|
+
Application
|
|
2162
|
+
</span>
|
|
2163
|
+
</span>
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
return (
|
|
2168
|
+
<div ref={rootRef} className="relative min-w-0">
|
|
2169
|
+
<button
|
|
2170
|
+
type="button"
|
|
2171
|
+
onClick={() => setOpen((o) => !o)}
|
|
2172
|
+
aria-haspopup="listbox"
|
|
2173
|
+
aria-expanded={open}
|
|
2174
|
+
className="inline-flex max-w-[min(48vw,34rem)] items-center gap-2 rounded-md bg-theme-base px-2.5 py-1.5 text-sm ring-1 ring-inset ring-theme-border hover:bg-theme-hover"
|
|
2175
|
+
>
|
|
2176
|
+
{selectedWorkload ? (
|
|
2177
|
+
<WorkloadKindIcon workload={selectedWorkload} />
|
|
2178
|
+
) : (
|
|
2179
|
+
<Boxes
|
|
2180
|
+
className="h-4 w-4 shrink-0 text-theme-text-secondary"
|
|
2181
|
+
aria-hidden
|
|
2182
|
+
/>
|
|
2183
|
+
)}
|
|
2184
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
2185
|
+
{selectedWorkload ? selectedWorkload.name : "Application"}
|
|
2186
|
+
</span>
|
|
2187
|
+
{selectedWorkload && (
|
|
2188
|
+
<span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
|
|
2189
|
+
{selectedWorkload.kind}
|
|
2190
|
+
</span>
|
|
2191
|
+
)}
|
|
2192
|
+
<ChevronDown
|
|
2193
|
+
className={clsx(
|
|
2194
|
+
"h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform",
|
|
2195
|
+
open && "rotate-180",
|
|
2196
|
+
)}
|
|
2197
|
+
aria-hidden
|
|
2198
|
+
/>
|
|
2199
|
+
</button>
|
|
2200
|
+
{open && (
|
|
2201
|
+
<div
|
|
2202
|
+
role="listbox"
|
|
2203
|
+
className="absolute left-0 top-full z-50 mt-1 w-[min(32rem,calc(100vw-2rem))] overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-md"
|
|
2204
|
+
onMouseLeave={() => onFocus(null)}
|
|
2205
|
+
>
|
|
2206
|
+
<div className="border-b border-theme-border p-1">
|
|
2207
|
+
<ScopeOption
|
|
2208
|
+
active={selectedKey === null}
|
|
2209
|
+
icon={
|
|
2210
|
+
<Boxes
|
|
2211
|
+
className="h-4 w-4 text-theme-text-secondary"
|
|
2212
|
+
aria-hidden
|
|
2213
|
+
/>
|
|
2214
|
+
}
|
|
2215
|
+
title="Application"
|
|
2216
|
+
subtitle="App scope"
|
|
2217
|
+
onClick={() => {
|
|
2218
|
+
setOpen(false);
|
|
2219
|
+
onSelect(null);
|
|
2220
|
+
}}
|
|
2221
|
+
onMouseEnter={() => onFocus(null)}
|
|
2222
|
+
/>
|
|
2223
|
+
</div>
|
|
2224
|
+
<div className="border-b border-theme-border px-2 py-1.5">
|
|
2225
|
+
<div className="flex items-center justify-between gap-3">
|
|
2226
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2227
|
+
Workloads
|
|
2228
|
+
</span>
|
|
2229
|
+
<span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
|
|
2230
|
+
{workloads.length}
|
|
2231
|
+
</span>
|
|
2232
|
+
</div>
|
|
2233
|
+
{workloads.length > 8 && (
|
|
2234
|
+
<label className="mt-2 flex items-center gap-2 rounded-md bg-theme-base px-2 py-1 ring-1 ring-inset ring-theme-border">
|
|
2235
|
+
<Search
|
|
2236
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
2237
|
+
aria-hidden
|
|
2238
|
+
/>
|
|
2239
|
+
<input
|
|
2240
|
+
value={query}
|
|
2241
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2242
|
+
placeholder="Filter workloads..."
|
|
2243
|
+
className="min-w-0 flex-1 bg-transparent text-sm text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
|
|
2244
|
+
/>
|
|
2245
|
+
</label>
|
|
2246
|
+
)}
|
|
2247
|
+
</div>
|
|
2248
|
+
<div className="max-h-80 overflow-y-auto p-1">
|
|
2249
|
+
{filteredWorkloads.length === 0 ? (
|
|
2250
|
+
<div className="px-2 py-4 text-center text-sm text-theme-text-tertiary">
|
|
2251
|
+
No workloads match.
|
|
2252
|
+
</div>
|
|
2253
|
+
) : (
|
|
2254
|
+
filteredWorkloads.map((w) => {
|
|
2255
|
+
const key = workloadKey(w);
|
|
2256
|
+
return (
|
|
2257
|
+
<ScopeOption
|
|
2258
|
+
key={key}
|
|
2259
|
+
active={selectedKey === key}
|
|
2260
|
+
icon={<WorkloadKindIcon workload={w} />}
|
|
2261
|
+
title={w.name}
|
|
2262
|
+
subtitle={`${w.kind} · ${w.ready}/${w.desired} ready${w.reason ? ` · ${w.reason}` : ""}`}
|
|
2263
|
+
onClick={() => {
|
|
2264
|
+
setOpen(false);
|
|
2265
|
+
onSelect(key);
|
|
2266
|
+
}}
|
|
2267
|
+
onMouseEnter={() => onFocus(key)}
|
|
2268
|
+
/>
|
|
2269
|
+
);
|
|
2270
|
+
})
|
|
2271
|
+
)}
|
|
2272
|
+
</div>
|
|
2273
|
+
</div>
|
|
2274
|
+
)}
|
|
2275
|
+
</div>
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function TopologyWorkloadLegend({
|
|
2280
|
+
workloads,
|
|
2281
|
+
colorByWorkload,
|
|
2282
|
+
focusedOwnerId,
|
|
2283
|
+
showSharedOrUnscoped,
|
|
2284
|
+
onFocus,
|
|
2285
|
+
onSelectWorkload,
|
|
2286
|
+
deploymentLayer,
|
|
2287
|
+
deploymentSource,
|
|
2288
|
+
onOpenSource,
|
|
2289
|
+
}: {
|
|
2290
|
+
workloads: AppWorkload[]
|
|
2291
|
+
colorByWorkload: Map<string, number> | null
|
|
2292
|
+
focusedOwnerId: WorkloadFocus
|
|
2293
|
+
showSharedOrUnscoped: boolean
|
|
2294
|
+
onFocus: (owner: WorkloadFocus) => void
|
|
2295
|
+
onSelectWorkload: (workload: AppWorkload) => void
|
|
2296
|
+
deploymentLayer: DeploymentTopologyLayer | null
|
|
2297
|
+
deploymentSource?: AppSourceRef
|
|
2298
|
+
onOpenSource?: (source: AppSourceRef) => void
|
|
2299
|
+
}) {
|
|
2300
|
+
const managedOnlyCount = deploymentLayer?.managedOnly.length ?? 0
|
|
2301
|
+
const runtimeOnlyCount = deploymentLayer?.runtimeOnlyCount ?? 0
|
|
2302
|
+
const showSourceDifferences = !!deploymentSource && managedOnlyCount + runtimeOnlyCount > 0
|
|
2303
|
+
const sourceLabel = deploymentSource ? sourceInventoryLabel(deploymentSource) : ''
|
|
2304
|
+
|
|
2305
|
+
return (
|
|
2306
|
+
<div className="absolute left-4 top-4 z-10 w-[min(18.5rem,calc(100%-2rem))] 2xl:w-[min(22rem,calc(100%-2rem))] overflow-hidden rounded-lg border border-theme-border bg-theme-surface/95 shadow-theme-md backdrop-blur">
|
|
2307
|
+
<div className="flex items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-3 py-2">
|
|
2308
|
+
<span className="text-xs font-semibold text-theme-text-primary">
|
|
2309
|
+
Workloads
|
|
2310
|
+
</span>
|
|
2311
|
+
<span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
|
|
2312
|
+
{workloads.length}
|
|
2313
|
+
</span>
|
|
2314
|
+
</div>
|
|
2315
|
+
<div
|
|
2316
|
+
className="max-h-72 overflow-y-auto p-1"
|
|
2317
|
+
onMouseLeave={() => onFocus(null)}
|
|
2318
|
+
>
|
|
2319
|
+
{workloads.map((w) => {
|
|
2320
|
+
const key = workloadKey(w);
|
|
2321
|
+
const idx = colorByWorkload?.get(key);
|
|
2322
|
+
const hue = idx != null ? workloadHue(idx) : null;
|
|
2323
|
+
return (
|
|
2324
|
+
<ScopeOption
|
|
2325
|
+
key={key}
|
|
2326
|
+
compact
|
|
2327
|
+
active={focusedOwnerId === key}
|
|
2328
|
+
accentColor={hue?.swatch}
|
|
2329
|
+
icon={<WorkloadKindIcon workload={w} compact />}
|
|
2330
|
+
title={w.name}
|
|
2331
|
+
subtitle={`${w.kind} · ${w.ready}/${w.desired}`}
|
|
2332
|
+
onClick={() => onSelectWorkload(w)}
|
|
2333
|
+
onMouseEnter={() => onFocus(key)}
|
|
2334
|
+
/>
|
|
2335
|
+
);
|
|
2336
|
+
})}
|
|
2337
|
+
{showSharedOrUnscoped && (
|
|
2338
|
+
<ScopeOption
|
|
2339
|
+
compact
|
|
2340
|
+
muted
|
|
2341
|
+
active={focusedOwnerId === NEUTRAL_OWNER}
|
|
2342
|
+
icon={<SharedScopeMarker />}
|
|
2343
|
+
title="Shared / unscoped"
|
|
2344
|
+
onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
|
|
2345
|
+
/>
|
|
2346
|
+
)}
|
|
2347
|
+
</div>
|
|
2348
|
+
{showSourceDifferences && deploymentSource && (
|
|
2349
|
+
<div className="border-t border-theme-border px-3 py-2.5">
|
|
2350
|
+
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Source differences</div>
|
|
2351
|
+
<div className="space-y-1">
|
|
2352
|
+
{managedOnlyCount > 0 && (
|
|
2353
|
+
<div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
|
|
2354
|
+
<DeploymentSourceLogo source={deploymentSource} />
|
|
2355
|
+
<span className="min-w-0 flex-1">
|
|
2356
|
+
<span className="block">{managedOnlyCount} only in {sourceLabel}</span>
|
|
2357
|
+
{deploymentLayer?.managedOnlySummary && (
|
|
2358
|
+
<span className="mt-0.5 block text-[10px] text-theme-text-tertiary">{deploymentLayer.managedOnlySummary}</span>
|
|
2359
|
+
)}
|
|
2360
|
+
</span>
|
|
2361
|
+
{onOpenSource && (
|
|
2362
|
+
<Tooltip content={`Open ${sourceLabel} view`} delay={150}>
|
|
2363
|
+
<button type="button" onClick={() => onOpenSource(deploymentSource)} className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary">
|
|
2364
|
+
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
2365
|
+
</button>
|
|
2366
|
+
</Tooltip>
|
|
2367
|
+
)}
|
|
2368
|
+
</div>
|
|
2369
|
+
)}
|
|
2370
|
+
{runtimeOnlyCount > 0 && (
|
|
2371
|
+
<div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
|
|
2372
|
+
<Radio className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
|
|
2373
|
+
<span>{pluralize(runtimeOnlyCount, 'runtime-only resource')}</span>
|
|
2374
|
+
</div>
|
|
2375
|
+
)}
|
|
2376
|
+
</div>
|
|
2377
|
+
</div>
|
|
2378
|
+
)}
|
|
2379
|
+
</div>
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function DeploymentSourceLogo({ source }: { source: AppSourceRef }) {
|
|
2384
|
+
const sourceLabel = sourceInventoryLabel(source)
|
|
2385
|
+
const logo = sourceLabel === 'Argo CD' ? argoCdLogo : sourceLabel === 'Flux' ? fluxLogo : null
|
|
2386
|
+
if (!logo) return <Layers className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
|
|
2387
|
+
return <img src={logo} alt="" className="h-4 w-4 shrink-0 object-contain" />
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function StaticWorkloadScope({ workload }: { workload: AppWorkload }) {
|
|
2391
|
+
return (
|
|
2392
|
+
<span className="inline-flex max-w-[min(48vw,34rem)] items-center gap-2 rounded-md bg-theme-base px-2.5 py-1.5 text-sm ring-1 ring-inset ring-theme-border">
|
|
2393
|
+
<WorkloadKindIcon workload={workload} />
|
|
2394
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
2395
|
+
{workload.name}
|
|
2396
|
+
</span>
|
|
2397
|
+
<span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
|
|
2398
|
+
{workload.kind}
|
|
2399
|
+
</span>
|
|
2400
|
+
</span>
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
function WorkloadKindIcon({
|
|
2405
|
+
workload,
|
|
2406
|
+
compact,
|
|
2407
|
+
}: {
|
|
2408
|
+
workload: AppWorkload;
|
|
2409
|
+
compact?: boolean;
|
|
2410
|
+
}) {
|
|
2411
|
+
const KindIcon = getTopologyIcon(workload.kind);
|
|
2412
|
+
return (
|
|
2413
|
+
<span
|
|
2414
|
+
className={clsx(
|
|
2415
|
+
"relative flex shrink-0 items-center justify-center",
|
|
2416
|
+
compact ? "h-4 w-4" : "h-5 w-5",
|
|
2417
|
+
)}
|
|
2418
|
+
aria-hidden
|
|
2419
|
+
>
|
|
2420
|
+
<KindIcon
|
|
2421
|
+
className={clsx(
|
|
2422
|
+
"text-theme-text-secondary",
|
|
2423
|
+
compact ? "h-3.5 w-3.5" : "h-4 w-4",
|
|
2424
|
+
)}
|
|
2425
|
+
/>
|
|
2426
|
+
<StatusDot
|
|
2427
|
+
tone={mapHealthToTone(healthOf(workload.health))}
|
|
2428
|
+
size="xs"
|
|
2429
|
+
className="absolute -bottom-0.5 -right-0.5 ring-2 ring-theme-base"
|
|
2430
|
+
/>
|
|
2431
|
+
</span>
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function SharedScopeMarker() {
|
|
2436
|
+
return (
|
|
2437
|
+
<span className="flex w-5 shrink-0 items-center gap-1" aria-hidden>
|
|
2438
|
+
<span className="block h-4 w-1 rounded-full bg-theme-border" />
|
|
2439
|
+
<span className="h-1.5 w-1.5 rounded-full bg-theme-border" />
|
|
2440
|
+
</span>
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function ScopeOption({
|
|
2445
|
+
active,
|
|
2446
|
+
muted,
|
|
2447
|
+
compact,
|
|
2448
|
+
onClick,
|
|
2449
|
+
onMouseEnter,
|
|
2450
|
+
icon,
|
|
2451
|
+
title,
|
|
2452
|
+
subtitle,
|
|
2453
|
+
accentColor,
|
|
2454
|
+
}: {
|
|
2455
|
+
active?: boolean;
|
|
2456
|
+
muted?: boolean;
|
|
2457
|
+
compact?: boolean;
|
|
2458
|
+
onClick?: () => void;
|
|
2459
|
+
onMouseEnter?: () => void;
|
|
2460
|
+
icon: ReactNode;
|
|
2461
|
+
title: string;
|
|
2462
|
+
subtitle?: string;
|
|
2463
|
+
accentColor?: string;
|
|
2464
|
+
}) {
|
|
2465
|
+
const className = clsx(
|
|
2466
|
+
"relative flex w-full items-center gap-2 rounded-md text-left transition-colors",
|
|
2467
|
+
compact ? "px-2 py-1.5" : "px-2 py-2",
|
|
2468
|
+
active ? "selection selection-ring" : onClick && "hover:bg-theme-hover",
|
|
2469
|
+
!onClick && "cursor-default",
|
|
2470
|
+
);
|
|
2471
|
+
const inner = (
|
|
2472
|
+
<>
|
|
2473
|
+
{accentColor && (
|
|
2474
|
+
<span
|
|
2475
|
+
className="absolute inset-y-2 left-0 w-1 rounded-full"
|
|
2476
|
+
style={{ background: accentColor }}
|
|
2477
|
+
aria-hidden
|
|
2478
|
+
/>
|
|
2479
|
+
)}
|
|
2480
|
+
{icon}
|
|
2481
|
+
<span className="min-w-0 flex-1">
|
|
2482
|
+
<span
|
|
2483
|
+
className={clsx(
|
|
2484
|
+
"block truncate text-sm",
|
|
2485
|
+
muted
|
|
2486
|
+
? "text-theme-text-tertiary"
|
|
2487
|
+
: "font-medium text-theme-text-primary",
|
|
2488
|
+
)}
|
|
2489
|
+
>
|
|
2490
|
+
{title}
|
|
2491
|
+
</span>
|
|
2492
|
+
{subtitle && (
|
|
2493
|
+
<span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
2494
|
+
{subtitle}
|
|
2495
|
+
</span>
|
|
2496
|
+
)}
|
|
2497
|
+
</span>
|
|
2498
|
+
</>
|
|
2499
|
+
);
|
|
2500
|
+
return onClick ? (
|
|
2501
|
+
<button
|
|
2502
|
+
type="button"
|
|
2503
|
+
onClick={onClick}
|
|
2504
|
+
onMouseEnter={onMouseEnter}
|
|
2505
|
+
className={className}
|
|
2506
|
+
>
|
|
2507
|
+
{inner}
|
|
2508
|
+
</button>
|
|
2509
|
+
) : (
|
|
2510
|
+
<div onMouseEnter={onMouseEnter} className={className}>
|
|
2511
|
+
{inner}
|
|
2512
|
+
</div>
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
function useDismissablePopover<T extends HTMLElement>(
|
|
2517
|
+
open: boolean,
|
|
2518
|
+
setOpen: (open: boolean) => void,
|
|
2519
|
+
) {
|
|
2520
|
+
const rootRef = useRef<T>(null);
|
|
2521
|
+
useEffect(() => {
|
|
2522
|
+
if (!open) return;
|
|
2523
|
+
const onDown = (e: MouseEvent) => {
|
|
2524
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node))
|
|
2525
|
+
setOpen(false);
|
|
2526
|
+
};
|
|
2527
|
+
const onKey = (e: KeyboardEvent) => {
|
|
2528
|
+
if (e.key === "Escape") setOpen(false);
|
|
2529
|
+
};
|
|
2530
|
+
document.addEventListener("mousedown", onDown);
|
|
2531
|
+
document.addEventListener("keydown", onKey);
|
|
2532
|
+
return () => {
|
|
2533
|
+
document.removeEventListener("mousedown", onDown);
|
|
2534
|
+
document.removeEventListener("keydown", onKey);
|
|
2535
|
+
};
|
|
2536
|
+
}, [open, setOpen]);
|
|
2537
|
+
return rootRef;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function renderRuntime(
|
|
2541
|
+
workload: SelectedAppWorkload | undefined,
|
|
2542
|
+
renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
|
|
2543
|
+
): ReactNode {
|
|
2544
|
+
if (!workload) {
|
|
2545
|
+
return (
|
|
2546
|
+
<div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
|
|
2547
|
+
This application has no inspectable workloads.
|
|
2548
|
+
</div>
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
return (
|
|
2552
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
2553
|
+
<div key={workloadKey(workload)} className="min-h-0 flex-1">
|
|
2554
|
+
{renderWorkload(workload)}
|
|
2555
|
+
</div>
|
|
2556
|
+
</div>
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function restartWarning(
|
|
2561
|
+
workloads: AppWorkload[],
|
|
2562
|
+
): { restarts: number; reason?: string; workload: string } | null {
|
|
2563
|
+
let worst: { restarts: number; reason?: string; workload: string } | null =
|
|
2564
|
+
null;
|
|
2565
|
+
for (const w of workloads) {
|
|
2566
|
+
const r = w.restarts ?? 0;
|
|
2567
|
+
if (r > 0 && (!worst || r > worst.restarts)) {
|
|
2568
|
+
worst = {
|
|
2569
|
+
restarts: r,
|
|
2570
|
+
reason: w.reason,
|
|
2571
|
+
workload: `${w.kind}/${w.name}`,
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return worst;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// EnvSwitcher — the Environment fact's interactive form when one app runs in
|
|
2579
|
+
// several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
|
|
2580
|
+
// ladder). More: a picker popover with the full ladder-ordered list — same
|
|
2581
|
+
// affordance at 5 envs or 100. Always ends with the evidence chip
|
|
2582
|
+
// (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
|
|
2583
|
+
// the amber lag chip.
|
|
2584
|
+
const MAX_INLINE_ENVS = 4;
|
|
2585
|
+
|
|
2586
|
+
function EnvSwitcher({
|
|
2587
|
+
identityKey,
|
|
2588
|
+
instances,
|
|
2589
|
+
activeKey,
|
|
2590
|
+
onSwitch,
|
|
2591
|
+
}: {
|
|
2592
|
+
identityKey: string;
|
|
2593
|
+
instances: AppIdentityInstance[];
|
|
2594
|
+
activeKey: string;
|
|
2595
|
+
onSwitch?: (appKey: string) => void;
|
|
2596
|
+
}) {
|
|
2597
|
+
const [open, setOpen] = useState(false);
|
|
2598
|
+
const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
|
|
2599
|
+
|
|
2600
|
+
const envInstances = useMemo(
|
|
2601
|
+
() => collapseIdentityInstances(instances, activeKey),
|
|
2602
|
+
[instances, activeKey],
|
|
2603
|
+
);
|
|
2604
|
+
const lag = appGroupLagMessage(envInstances);
|
|
2605
|
+
const active =
|
|
2606
|
+
envInstances.find((i) => i.appKey === activeKey) ??
|
|
2607
|
+
instances.find((i) => i.appKey === activeKey);
|
|
2608
|
+
const envCounts = useMemo(() => {
|
|
2609
|
+
const counts = new Map<string, number>();
|
|
2610
|
+
for (const inst of instances)
|
|
2611
|
+
counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1);
|
|
2612
|
+
return counts;
|
|
2613
|
+
}, [instances]);
|
|
2614
|
+
const evidenceChip = (
|
|
2615
|
+
<Tooltip
|
|
2616
|
+
content={
|
|
2617
|
+
<AppIdentityTooltip
|
|
2618
|
+
identityKey={identityKey}
|
|
2619
|
+
members={instances.map((i) => ({
|
|
2620
|
+
name: i.name,
|
|
2621
|
+
env: i.env,
|
|
2622
|
+
confidence: i.confidence,
|
|
2623
|
+
evidence: i.evidence,
|
|
2624
|
+
}))}
|
|
2625
|
+
/>
|
|
2626
|
+
}
|
|
2627
|
+
delay={150}
|
|
2628
|
+
>
|
|
2629
|
+
<span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
|
|
2630
|
+
<Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
|
|
2631
|
+
</span>
|
|
2632
|
+
</Tooltip>
|
|
2633
|
+
);
|
|
2634
|
+
const lagChip = lag && (
|
|
2635
|
+
<span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
|
|
2636
|
+
);
|
|
2637
|
+
|
|
2638
|
+
if (envInstances.length <= MAX_INLINE_ENVS) {
|
|
2639
|
+
return (
|
|
2640
|
+
<span className="flex flex-wrap items-center gap-1">
|
|
2641
|
+
{instances.map((inst) => {
|
|
2642
|
+
const isActive = inst.appKey === activeKey;
|
|
2643
|
+
const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1;
|
|
2644
|
+
return (
|
|
2645
|
+
<Tooltip
|
|
2646
|
+
key={inst.appKey}
|
|
2647
|
+
content={`${inst.name}${inst.version ? ` · ${inst.version}` : ""}`}
|
|
2648
|
+
delay={150}
|
|
2649
|
+
>
|
|
2650
|
+
<button
|
|
2651
|
+
type="button"
|
|
2652
|
+
disabled={isActive}
|
|
2653
|
+
onClick={() => !isActive && onSwitch?.(inst.appKey)}
|
|
2654
|
+
className={clsx(
|
|
2655
|
+
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors",
|
|
2656
|
+
isActive
|
|
2657
|
+
? "selection selection-ring font-medium"
|
|
2658
|
+
: "bg-theme-surface ring-theme-border hover:bg-theme-hover",
|
|
2659
|
+
)}
|
|
2660
|
+
>
|
|
2661
|
+
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
2662
|
+
{inst.env}
|
|
2663
|
+
{duplicateEnv ? (
|
|
2664
|
+
<span className="max-w-24 truncate text-theme-text-tertiary">
|
|
2665
|
+
{inst.name}
|
|
2666
|
+
</span>
|
|
2667
|
+
) : null}
|
|
2668
|
+
</button>
|
|
2669
|
+
</Tooltip>
|
|
2670
|
+
);
|
|
2671
|
+
})}
|
|
2672
|
+
{evidenceChip}
|
|
2673
|
+
{lagChip}
|
|
2674
|
+
</span>
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
return (
|
|
2679
|
+
<div ref={rootRef} className="relative flex items-center gap-1">
|
|
2680
|
+
<button
|
|
2681
|
+
type="button"
|
|
2682
|
+
onClick={() => setOpen((o) => !o)}
|
|
2683
|
+
aria-haspopup="listbox"
|
|
2684
|
+
aria-expanded={open}
|
|
2685
|
+
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"
|
|
2686
|
+
>
|
|
2687
|
+
{active && <StatusDot tone={mapHealthToTone(active.health)} />}
|
|
2688
|
+
<span className="font-medium">{active?.env ?? "—"}</span>
|
|
2689
|
+
<span className="text-theme-text-tertiary">
|
|
2690
|
+
· {envInstances.length} environments
|
|
2691
|
+
</span>
|
|
2692
|
+
<ChevronDown
|
|
2693
|
+
className={clsx(
|
|
2694
|
+
"h-3 w-3 text-theme-text-tertiary transition-transform",
|
|
2695
|
+
open && "rotate-180",
|
|
2696
|
+
)}
|
|
2697
|
+
aria-hidden
|
|
2698
|
+
/>
|
|
2699
|
+
</button>
|
|
2700
|
+
{evidenceChip}
|
|
2701
|
+
{lagChip}
|
|
2702
|
+
{open && (
|
|
2703
|
+
<div
|
|
2704
|
+
role="listbox"
|
|
2705
|
+
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"
|
|
2706
|
+
>
|
|
2707
|
+
{instances.map((inst) => {
|
|
2708
|
+
const isActive = inst.appKey === activeKey;
|
|
2709
|
+
return (
|
|
2710
|
+
<button
|
|
2711
|
+
key={inst.appKey}
|
|
2712
|
+
type="button"
|
|
2713
|
+
role="option"
|
|
2714
|
+
aria-selected={isActive}
|
|
2715
|
+
onClick={() => {
|
|
2716
|
+
setOpen(false);
|
|
2717
|
+
if (!isActive) onSwitch?.(inst.appKey);
|
|
2718
|
+
}}
|
|
2719
|
+
className={clsx(
|
|
2720
|
+
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs",
|
|
2721
|
+
isActive
|
|
2722
|
+
? "selection selection-ring"
|
|
2723
|
+
: "hover:bg-theme-hover",
|
|
2724
|
+
)}
|
|
2725
|
+
>
|
|
2726
|
+
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
2727
|
+
<span className="w-20 shrink-0 font-medium text-theme-text-primary">
|
|
2728
|
+
{inst.env}
|
|
2729
|
+
</span>
|
|
2730
|
+
<span className="min-w-0 flex-1 truncate text-theme-text-secondary">
|
|
2731
|
+
{inst.name}
|
|
2732
|
+
</span>
|
|
2733
|
+
{inst.version && (
|
|
2734
|
+
<span className="font-mono text-[10px] text-theme-text-tertiary">
|
|
2735
|
+
{midTruncate(inst.version, 18)}
|
|
2736
|
+
</span>
|
|
2737
|
+
)}
|
|
2738
|
+
</button>
|
|
2739
|
+
);
|
|
2740
|
+
})}
|
|
2741
|
+
</div>
|
|
2742
|
+
)}
|
|
2743
|
+
</div>
|
|
2744
|
+
);
|
|
678
2745
|
}
|