@skyhook-io/k8s-ui 1.8.10 → 1.8.12
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 +7 -6
- 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 +697 -0
- package/src/components/applications/ApplicationDetail.tsx +3269 -474
- package/src/components/applications/ApplicationsView.tsx +62 -10
- 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 +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +13 -6
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
- package/src/components/gitops/insights/insights-helpers.ts +74 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
- package/src/components/issues/IssuesView.tsx +91 -52
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
- package/src/components/issues/ResourceIssuesSection.tsx +46 -144
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +3 -0
- 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 +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- 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-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -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 +142 -111
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +375 -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 +88 -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/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +149 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +25 -17
- package/src/components/topology/index.ts +2 -0
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +6 -1
- 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/SelectMenu.tsx +81 -0
- package/src/components/ui/drawer-components.tsx +48 -32
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2706 -475
- package/src/components/workload/index.ts +1 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +2 -1
- package/src/types/core.ts +57 -4
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +201 -1
- package/src/utils/applications.ts +1182 -393
- package/src/utils/index.ts +3 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +1024 -1
- package/src/utils/resource-hierarchy.ts +925 -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
|
@@ -1,17 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import {
|
|
2
|
+
useMemo,
|
|
3
|
+
useState,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
AlertTriangle,
|
|
11
|
+
Activity,
|
|
12
|
+
ArrowLeft,
|
|
13
|
+
Boxes,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
Clock3,
|
|
16
|
+
DollarSign,
|
|
17
|
+
ExternalLink,
|
|
18
|
+
GitCommit,
|
|
19
|
+
Layers,
|
|
20
|
+
Radio,
|
|
21
|
+
RefreshCw,
|
|
22
|
+
Search,
|
|
23
|
+
} from "lucide-react";
|
|
24
|
+
import { clsx } from "clsx";
|
|
25
|
+
import type { ResourceRef, Topology, TopologyNode } from "../../types";
|
|
26
|
+
import argoCdLogo from "../../assets/gitops/argocd.png";
|
|
27
|
+
import fluxLogo from "../../assets/gitops/flux.svg";
|
|
28
|
+
import { StatusDot, mapHealthToTone } from "../ui/status-tone";
|
|
29
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
30
|
+
import { EmptyState } from "../ui/EmptyState";
|
|
31
|
+
import { ResourceRefBadge } from "../ui/drawer-components";
|
|
32
|
+
import { TopologyGraph } from "../topology/TopologyGraph";
|
|
33
|
+
import { pluralize } from "../../utils/pluralize";
|
|
34
|
+
import { kindToPlural, refToSelectedResource } from "../../utils/navigation";
|
|
35
|
+
import {
|
|
36
|
+
batchRunParentNodes,
|
|
37
|
+
tagWorkloadOwnership,
|
|
38
|
+
seedNodeIds,
|
|
39
|
+
ownershipOf,
|
|
40
|
+
workloadKey,
|
|
41
|
+
type NeighborhoodSeed,
|
|
42
|
+
} from "../../utils/topology-neighborhood";
|
|
43
|
+
import {
|
|
44
|
+
workloadHue,
|
|
45
|
+
NEUTRAL_OWNER,
|
|
46
|
+
type WorkloadFocus,
|
|
47
|
+
} from "../../utils/workload-colors";
|
|
48
|
+
import { getTopologyIcon } from "../../utils/resource-icons";
|
|
49
|
+
import { getKindLabel } from "../../utils/api-resources";
|
|
12
50
|
import {
|
|
13
51
|
type AppRow,
|
|
52
|
+
type AppHistory,
|
|
53
|
+
type AppSourceRef,
|
|
14
54
|
type AppWorkload,
|
|
55
|
+
type AppBatchActivity,
|
|
15
56
|
type AppHealth,
|
|
16
57
|
CHIP,
|
|
17
58
|
CHIP_TONE,
|
|
@@ -23,197 +64,433 @@ import {
|
|
|
23
64
|
identityEnvInferred,
|
|
24
65
|
workloadClassOf,
|
|
25
66
|
classCompositionOf,
|
|
67
|
+
batchSignalForApp,
|
|
68
|
+
batchActivityForApp,
|
|
69
|
+
batchRuntimeForApp,
|
|
70
|
+
applicationDisplayHealth,
|
|
71
|
+
sourceReportedHealth,
|
|
72
|
+
sourceSyncHealth,
|
|
73
|
+
servingReadiness,
|
|
26
74
|
worstHealth,
|
|
27
75
|
appGroupLagMessage,
|
|
28
76
|
compareVersions,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
appSourceLabel,
|
|
78
|
+
overlayProvenance,
|
|
79
|
+
} from "../../utils/applications";
|
|
80
|
+
import { PaneLoader } from "../ui/PaneLoader";
|
|
81
|
+
import { midTruncate } from "../../utils/format";
|
|
82
|
+
import { VersionTooltip, AppIdentityTooltip } from "./AppTooltips";
|
|
83
|
+
import {
|
|
84
|
+
ProvenanceBadge,
|
|
85
|
+
ClassBadge,
|
|
86
|
+
CategoryChip,
|
|
87
|
+
VersionInfo,
|
|
88
|
+
BatchSignalChip,
|
|
89
|
+
} from "./AppChips";
|
|
90
|
+
import { ReadyBar } from "./ReadyBar";
|
|
91
|
+
import {
|
|
92
|
+
collapseStableReplicaSets,
|
|
93
|
+
layerDeploymentInventory,
|
|
94
|
+
topologyGroup,
|
|
95
|
+
type DeploymentInventory,
|
|
96
|
+
type DeploymentTopologyLayer,
|
|
97
|
+
} from "../../utils/application-topology";
|
|
98
|
+
import {
|
|
99
|
+
buildApplicationHistoryItems,
|
|
100
|
+
type ApplicationHistoryCategory,
|
|
101
|
+
type ApplicationHistoryItem,
|
|
102
|
+
type ApplicationHistoryRange,
|
|
103
|
+
} from "../../utils/application-history";
|
|
104
|
+
|
|
105
|
+
// ApplicationDetail owns the application chrome and scope switcher. The selected
|
|
106
|
+
// scope decides the one tab row shown in the detail pane: app scope gets
|
|
107
|
+
// Overview/Topology/History; workload scope delegates to the host's WorkloadView.
|
|
108
|
+
|
|
109
|
+
export type SelectedAppWorkload = NeighborhoodSeed;
|
|
50
110
|
|
|
51
111
|
/** One env instance in the identity switcher — a sibling app row's digest. */
|
|
52
112
|
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
|
|
113
|
+
appKey: string;
|
|
114
|
+
name: string;
|
|
115
|
+
env: string;
|
|
116
|
+
health: AppHealth;
|
|
117
|
+
version?: string;
|
|
118
|
+
confidence: string;
|
|
119
|
+
evidence: string;
|
|
120
|
+
count?: number;
|
|
61
121
|
}
|
|
62
122
|
|
|
63
123
|
/** Workload selection is either fully controlled (key + callback, the host
|
|
64
124
|
* wires it to the URL so back/forward works) or fully internal — providing
|
|
65
|
-
* only half silently freezes the
|
|
66
|
-
*
|
|
125
|
+
* only half silently freezes the selector, so the types forbid it. `null` means
|
|
126
|
+
* the application itself is selected; a key (see `workloadKey`) selects a
|
|
127
|
+
* workload scope. */
|
|
67
128
|
type SelectionProps =
|
|
68
|
-
| {
|
|
69
|
-
|
|
129
|
+
| {
|
|
130
|
+
selectedWorkloadKey: string | null;
|
|
131
|
+
onSelectWorkload: (
|
|
132
|
+
key: string | null,
|
|
133
|
+
options?: AppWorkloadSelectionOptions,
|
|
134
|
+
) => void;
|
|
135
|
+
}
|
|
136
|
+
| { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined };
|
|
137
|
+
|
|
138
|
+
export interface AppWorkloadSelectionOptions {
|
|
139
|
+
tab?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type CanonicalApplicationView = "overview" | "topology" | "history";
|
|
143
|
+
|
|
144
|
+
export type ApplicationView = CanonicalApplicationView;
|
|
145
|
+
|
|
146
|
+
const DEFAULT_APPLICATION_VIEW: CanonicalApplicationView = "overview";
|
|
147
|
+
|
|
148
|
+
const APPLICATION_VIEWS: Array<{
|
|
149
|
+
id: CanonicalApplicationView;
|
|
150
|
+
label: string;
|
|
151
|
+
}> = [
|
|
152
|
+
{ id: "overview", label: "Overview" },
|
|
153
|
+
{ id: "topology", label: "Topology" },
|
|
154
|
+
{ id: "history", label: "History" },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
function canonicalApplicationView(
|
|
158
|
+
view?: ApplicationView,
|
|
159
|
+
): CanonicalApplicationView {
|
|
160
|
+
return view ?? DEFAULT_APPLICATION_VIEW;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type ViewProps =
|
|
164
|
+
| {
|
|
165
|
+
selectedView: ApplicationView;
|
|
166
|
+
onSelectView: (view: ApplicationView) => void;
|
|
167
|
+
}
|
|
168
|
+
| { selectedView?: undefined; onSelectView?: undefined };
|
|
70
169
|
|
|
71
170
|
export type ApplicationDetailProps = {
|
|
72
|
-
app: AppRow
|
|
73
|
-
onBack: () => void
|
|
171
|
+
app: AppRow;
|
|
172
|
+
onBack: () => void;
|
|
74
173
|
/** Render the host's WorkloadView for the chosen workload. */
|
|
75
|
-
renderWorkload: (workload: SelectedAppWorkload) => ReactNode
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
174
|
+
renderWorkload: (workload: SelectedAppWorkload) => ReactNode;
|
|
175
|
+
/** Optional host-rendered app-scope Cost view. When absent, no Cost tab is shown. */
|
|
176
|
+
renderCostView?: (props: {
|
|
177
|
+
app: AppRow;
|
|
178
|
+
workloads: AppWorkload[];
|
|
179
|
+
onSelectWorkload: (
|
|
180
|
+
workload: AppWorkload,
|
|
181
|
+
options?: AppWorkloadSelectionOptions,
|
|
182
|
+
) => void;
|
|
183
|
+
}) => ReactNode;
|
|
184
|
+
costViewSelected?: boolean;
|
|
185
|
+
onSelectCostView?: () => void;
|
|
186
|
+
/** Render host-scoped operational issues for the app overview. */
|
|
187
|
+
renderOverviewIssues?: () => ReactNode;
|
|
188
|
+
/** Whether the host-scoped issue surface contains current issues. */
|
|
189
|
+
hasOverviewIssues?: boolean;
|
|
190
|
+
/** Resources-view topology spanning the app's namespaces. When present, it
|
|
191
|
+
* powers the application Topology view and workload hover focus. */
|
|
192
|
+
topology?: Topology;
|
|
79
193
|
/** True while the host's topology fetch is in flight. Without it, a
|
|
80
|
-
* multi-workload
|
|
81
|
-
|
|
82
|
-
|
|
194
|
+
* multi-workload app can briefly show an empty topology while topology loads. */
|
|
195
|
+
topologyLoading?: boolean;
|
|
196
|
+
/** Exact resources reported by the app's Argo CD, Flux, or Helm deployment source. */
|
|
197
|
+
deploymentInventory?: DeploymentInventory;
|
|
83
198
|
/** Open a related (non-workload) resource clicked in the app graph. */
|
|
84
|
-
onNavigateToResource?: (resource: {
|
|
199
|
+
onNavigateToResource?: (resource: {
|
|
200
|
+
kind: string;
|
|
201
|
+
namespace: string;
|
|
202
|
+
name: string;
|
|
203
|
+
group?: string;
|
|
204
|
+
}) => void;
|
|
205
|
+
/** Open a generated Job or Workflow in the run history of its parent workload. */
|
|
206
|
+
onSelectWorkloadRun?: (
|
|
207
|
+
workload: SelectedAppWorkload,
|
|
208
|
+
run: TopologyNode,
|
|
209
|
+
) => void;
|
|
85
210
|
/** App identity siblings (this instance included, ladder-ordered) — turns the
|
|
86
211
|
* context strip's Environment fact into a switcher. Identity grouping is
|
|
87
212
|
* classification, not an address: it switches between REAL instances, never
|
|
88
213
|
* an aggregate page. */
|
|
89
|
-
identityInstances?: AppIdentityInstance[] | null
|
|
214
|
+
identityInstances?: AppIdentityInstance[] | null;
|
|
90
215
|
/** Switch to a sibling instance (host swaps ?app= and, when it can match
|
|
91
216
|
* the current workload in the target, preserves ?workload= + ?tab=). */
|
|
92
|
-
onSwitchInstance?: (appKey: string) => void
|
|
217
|
+
onSwitchInstance?: (appKey: string) => void;
|
|
93
218
|
/** Env tokens the cluster proved (the list derives the same set) — keeps a
|
|
94
219
|
* ungrouped app's Environment fact consistent between list and detail. */
|
|
95
|
-
discoveredEnvs?: ReadonlySet<string
|
|
220
|
+
discoveredEnvs?: ReadonlySet<string>;
|
|
96
221
|
/** Which instance in `identityInstances` is active, by its `appKey`. Defaults
|
|
97
222
|
* to `app.key` — correct for the OSS case, where each instance IS a distinct
|
|
98
223
|
* app row keyed the same way. A host that keys instances on something other
|
|
99
224
|
* than the app key (e.g. the Cloud fleet keys per cluster, while `app.key`
|
|
100
225
|
* must stay the logical app key for provenance) passes the active instance's
|
|
101
226
|
* key here so the switcher marks it. */
|
|
102
|
-
activeInstanceKey?: string
|
|
103
|
-
|
|
227
|
+
activeInstanceKey?: string;
|
|
228
|
+
history?: AppHistory;
|
|
229
|
+
historyLoading?: boolean;
|
|
230
|
+
historyItems?: ApplicationHistoryItem[];
|
|
231
|
+
historyRuntimeLoading?: boolean;
|
|
232
|
+
historyRuntimeError?: boolean;
|
|
233
|
+
historyMode?: "local" | "retained";
|
|
234
|
+
historyRange?: ApplicationHistoryRange;
|
|
235
|
+
historyRangeOptions?: Array<{
|
|
236
|
+
value: ApplicationHistoryRange;
|
|
237
|
+
label: string;
|
|
238
|
+
}>;
|
|
239
|
+
historyCoverageRecordCount?: number;
|
|
240
|
+
historyRuntimeLimited?: boolean;
|
|
241
|
+
onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
|
|
242
|
+
onOpenTimeline?: (timestamp?: string) => void;
|
|
243
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
244
|
+
} & SelectionProps &
|
|
245
|
+
ViewProps;
|
|
104
246
|
|
|
105
|
-
function ContextFact({
|
|
247
|
+
function ContextFact({
|
|
248
|
+
label,
|
|
249
|
+
children,
|
|
250
|
+
}: {
|
|
251
|
+
label: string;
|
|
252
|
+
children: ReactNode;
|
|
253
|
+
}) {
|
|
106
254
|
return (
|
|
107
255
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
108
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
109
|
-
|
|
256
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
257
|
+
{label}
|
|
258
|
+
</span>
|
|
259
|
+
<span className="min-w-0 truncate text-xs text-theme-text-secondary">
|
|
260
|
+
{children}
|
|
261
|
+
</span>
|
|
110
262
|
</div>
|
|
111
|
-
)
|
|
263
|
+
);
|
|
112
264
|
}
|
|
113
265
|
|
|
114
|
-
function collapseIdentityInstances(
|
|
115
|
-
|
|
266
|
+
function collapseIdentityInstances(
|
|
267
|
+
instances: AppIdentityInstance[],
|
|
268
|
+
activeKey: string,
|
|
269
|
+
): AppIdentityInstance[] {
|
|
270
|
+
const byEnv = new Map<string, AppIdentityInstance[]>();
|
|
116
271
|
for (const inst of instances) {
|
|
117
|
-
byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst])
|
|
272
|
+
byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst]);
|
|
118
273
|
}
|
|
119
274
|
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
|
-
|
|
275
|
+
const active = group.find((i) => i.appKey === activeKey);
|
|
276
|
+
const newest = group.reduce<AppIdentityInstance | undefined>(
|
|
277
|
+
(best, inst) => {
|
|
278
|
+
if (!best) return inst;
|
|
279
|
+
return compareDefinedVersions(inst.version, best.version) === 1
|
|
280
|
+
? inst
|
|
281
|
+
: best;
|
|
282
|
+
},
|
|
283
|
+
undefined,
|
|
284
|
+
);
|
|
285
|
+
const display = active ?? newest ?? group[0];
|
|
126
286
|
return {
|
|
127
287
|
...display,
|
|
128
288
|
health: worstHealth(group.map((inst) => inst.health)),
|
|
129
289
|
version: newest?.version,
|
|
130
290
|
count: group.length,
|
|
131
|
-
}
|
|
132
|
-
})
|
|
291
|
+
};
|
|
292
|
+
});
|
|
133
293
|
}
|
|
134
294
|
|
|
135
|
-
function compareDefinedVersions(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
295
|
+
function compareDefinedVersions(
|
|
296
|
+
a: string | undefined,
|
|
297
|
+
b: string | undefined,
|
|
298
|
+
): number {
|
|
299
|
+
if (!a && !b) return 0;
|
|
300
|
+
if (a && !b) return 1;
|
|
301
|
+
if (!a || !b) return -1;
|
|
302
|
+
return compareVersions(a, b) ?? 0;
|
|
140
303
|
}
|
|
141
304
|
|
|
142
|
-
export function ApplicationDetail({
|
|
305
|
+
export function ApplicationDetail({
|
|
306
|
+
app,
|
|
307
|
+
onBack,
|
|
308
|
+
renderWorkload,
|
|
309
|
+
renderCostView,
|
|
310
|
+
costViewSelected,
|
|
311
|
+
onSelectCostView,
|
|
312
|
+
renderOverviewIssues,
|
|
313
|
+
hasOverviewIssues,
|
|
314
|
+
topology,
|
|
315
|
+
topologyLoading,
|
|
316
|
+
deploymentInventory,
|
|
317
|
+
onNavigateToResource,
|
|
318
|
+
onSelectWorkloadRun,
|
|
319
|
+
identityInstances,
|
|
320
|
+
onSwitchInstance,
|
|
321
|
+
discoveredEnvs,
|
|
322
|
+
activeInstanceKey,
|
|
323
|
+
history,
|
|
324
|
+
historyLoading,
|
|
325
|
+
historyItems,
|
|
326
|
+
historyRuntimeLoading,
|
|
327
|
+
historyRuntimeError,
|
|
328
|
+
historyMode,
|
|
329
|
+
historyRange,
|
|
330
|
+
historyRangeOptions,
|
|
331
|
+
historyCoverageRecordCount,
|
|
332
|
+
historyRuntimeLimited,
|
|
333
|
+
onHistoryRangeChange,
|
|
334
|
+
onOpenTimeline,
|
|
335
|
+
onOpenSource,
|
|
336
|
+
selectedWorkloadKey,
|
|
337
|
+
onSelectWorkload,
|
|
338
|
+
selectedView,
|
|
339
|
+
onSelectView,
|
|
340
|
+
}: ApplicationDetailProps) {
|
|
143
341
|
// Stable order regardless of API ordering: rail rows and the per-workload
|
|
144
342
|
// color assignment both follow this array, so an order flap between
|
|
145
343
|
// refetches must not reshuffle rows or reassign a workload's hue.
|
|
146
344
|
const workloads = useMemo(
|
|
147
345
|
() =>
|
|
148
346
|
[...(app.workloads ?? [])].sort(
|
|
149
|
-
(a, b) =>
|
|
347
|
+
(a, b) =>
|
|
348
|
+
a.name.localeCompare(b.name) ||
|
|
349
|
+
a.kind.localeCompare(b.kind) ||
|
|
350
|
+
a.namespace.localeCompare(b.namespace),
|
|
150
351
|
),
|
|
151
352
|
[app.workloads],
|
|
152
|
-
)
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
353
|
+
);
|
|
354
|
+
const workloadClass = workloadClassOf(app.workload_class);
|
|
355
|
+
const batchRuntime = batchRuntimeForApp(app);
|
|
356
|
+
const overall = applicationDisplayHealth(app);
|
|
357
|
+
const verdictTone = HEALTH_META[overall].pill;
|
|
358
|
+
const verdictLabel =
|
|
359
|
+
workloadClass === "job" && overall === batchRuntime.health
|
|
360
|
+
? batchRuntime.label
|
|
361
|
+
: HEALTH_META[overall].label;
|
|
362
|
+
const versions = useMemo(
|
|
363
|
+
() => Array.from(new Set((app.versions || []).filter(Boolean))),
|
|
364
|
+
[app.versions],
|
|
365
|
+
);
|
|
366
|
+
const { ready, desired } = servingReadiness(workloads);
|
|
367
|
+
const restartSignal = restartWarning(workloads);
|
|
368
|
+
const batchSignal = workloadClass === "job" ? null : batchSignalForApp(app);
|
|
161
369
|
// Resolve namespace the same way the list does (the workloads' shared
|
|
162
370
|
// namespace) so env/namespace match across list and detail. Multi-namespace
|
|
163
371
|
// 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
|
-
const
|
|
372
|
+
const namespace = namespaceOf(app);
|
|
373
|
+
const namespaces = namespacesOf(app);
|
|
374
|
+
const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs);
|
|
375
|
+
const env = app.identity?.env ?? resolvedEnv.env;
|
|
376
|
+
const inferred = app.identity
|
|
377
|
+
? identityEnvInferred(app.identity)
|
|
378
|
+
: resolvedEnv.inferred;
|
|
379
|
+
const [internalView, setInternalView] = useState<CanonicalApplicationView>(
|
|
380
|
+
DEFAULT_APPLICATION_VIEW,
|
|
381
|
+
);
|
|
382
|
+
const [internalCostSelected, setInternalCostSelected] = useState(false);
|
|
383
|
+
const activeView = canonicalApplicationView(selectedView ?? internalView);
|
|
384
|
+
const costSelected = Boolean(
|
|
385
|
+
renderCostView && (costViewSelected ?? internalCostSelected),
|
|
386
|
+
);
|
|
387
|
+
const setView = useCallback(
|
|
388
|
+
(view: CanonicalApplicationView) => {
|
|
389
|
+
setInternalCostSelected(false);
|
|
390
|
+
if (onSelectView) onSelectView(view);
|
|
391
|
+
else setInternalView(view);
|
|
392
|
+
},
|
|
393
|
+
[onSelectView],
|
|
394
|
+
);
|
|
395
|
+
const selectCostView = useCallback(() => {
|
|
396
|
+
if (!renderCostView) return;
|
|
397
|
+
if (onSelectCostView) onSelectCostView();
|
|
398
|
+
else setInternalCostSelected(true);
|
|
399
|
+
}, [onSelectCostView, renderCostView]);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (selectedView === undefined) setInternalView(DEFAULT_APPLICATION_VIEW);
|
|
402
|
+
setInternalCostSelected(false);
|
|
403
|
+
}, [app.key, selectedView]);
|
|
404
|
+
|
|
405
|
+
const [internalSelected, setInternalSelected] = useState<string | null>(null);
|
|
406
|
+
const implicitSingleWorkloadKey =
|
|
407
|
+
workloads.length === 1 ? workloadKey(workloads[0]) : null;
|
|
408
|
+
const rawSelected =
|
|
409
|
+
selectedWorkloadKey !== undefined
|
|
410
|
+
? selectedWorkloadKey
|
|
411
|
+
: (internalSelected ?? implicitSingleWorkloadKey);
|
|
180
412
|
const setSelected = useCallback(
|
|
181
|
-
(key: string | null
|
|
413
|
+
(key: string | null, options?: AppWorkloadSelectionOptions) =>
|
|
414
|
+
onSelectWorkload
|
|
415
|
+
? onSelectWorkload(key, options)
|
|
416
|
+
: setInternalSelected(key),
|
|
182
417
|
[onSelectWorkload],
|
|
183
|
-
)
|
|
184
|
-
const selectedWorkload = rawSelected
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected
|
|
418
|
+
);
|
|
419
|
+
const selectedWorkload = rawSelected
|
|
420
|
+
? workloads.find((w) => workloadKey(w) === rawSelected)
|
|
421
|
+
: undefined;
|
|
422
|
+
const singleWorkloadScope = workloads.length === 1 && !!selectedWorkload;
|
|
189
423
|
useEffect(() => {
|
|
190
|
-
if (
|
|
191
|
-
|
|
424
|
+
if (
|
|
425
|
+
selectedWorkloadKey !== undefined &&
|
|
426
|
+
selectedWorkloadKey !== null &&
|
|
427
|
+
!selectedWorkload
|
|
428
|
+
) {
|
|
429
|
+
onSelectWorkload?.(null);
|
|
192
430
|
}
|
|
193
|
-
}, [selectedWorkloadKey, selectedWorkload, onSelectWorkload])
|
|
431
|
+
}, [selectedWorkloadKey, selectedWorkload, onSelectWorkload]);
|
|
194
432
|
useEffect(() => {
|
|
195
|
-
if (selectedWorkloadKey === undefined) setInternalSelected(null)
|
|
196
|
-
}, [app.key, selectedWorkloadKey])
|
|
433
|
+
if (selectedWorkloadKey === undefined) setInternalSelected(null);
|
|
434
|
+
}, [app.key, selectedWorkloadKey]);
|
|
197
435
|
|
|
198
436
|
// Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
|
|
199
437
|
// while the rest of the graph dims. Driven by the rail and, reciprocally, by
|
|
200
438
|
// hovering a node.
|
|
201
|
-
const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
|
|
439
|
+
const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null);
|
|
440
|
+
const [expandedReplicaSetOwners, setExpandedReplicaSetOwners] = useState<
|
|
441
|
+
Set<string>
|
|
442
|
+
>(new Set());
|
|
443
|
+
useEffect(() => setExpandedReplicaSetOwners(new Set()), [app.key]);
|
|
444
|
+
const toggleReplicaSets = useCallback((ownerID: string) => {
|
|
445
|
+
setExpandedReplicaSetOwners((current) => {
|
|
446
|
+
const next = new Set(current);
|
|
447
|
+
if (next.has(ownerID)) next.delete(ownerID);
|
|
448
|
+
else next.add(ownerID);
|
|
449
|
+
return next;
|
|
450
|
+
});
|
|
451
|
+
}, []);
|
|
202
452
|
|
|
203
453
|
const appSeeds = useMemo(
|
|
204
|
-
() =>
|
|
454
|
+
() =>
|
|
455
|
+
workloads.map((w) => ({
|
|
456
|
+
kind: w.kind,
|
|
457
|
+
namespace: w.namespace,
|
|
458
|
+
name: w.name,
|
|
459
|
+
group: w.group,
|
|
460
|
+
})),
|
|
205
461
|
[workloads],
|
|
206
|
-
)
|
|
462
|
+
);
|
|
207
463
|
// Neighborhood subgraph + per-workload color/ownership tagging in one pass.
|
|
464
|
+
const composedTopology = useMemo(
|
|
465
|
+
() =>
|
|
466
|
+
topology
|
|
467
|
+
? collapseStableReplicaSets(topology, expandedReplicaSetOwners)
|
|
468
|
+
: null,
|
|
469
|
+
[topology, expandedReplicaSetOwners],
|
|
470
|
+
);
|
|
208
471
|
const ownership = useMemo(
|
|
209
|
-
() =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
472
|
+
() =>
|
|
473
|
+
composedTopology
|
|
474
|
+
? tagWorkloadOwnership(composedTopology, appSeeds)
|
|
475
|
+
: null,
|
|
476
|
+
[composedTopology, appSeeds],
|
|
477
|
+
);
|
|
478
|
+
const appGraph = ownership?.topology ?? null;
|
|
479
|
+
const deploymentLayer = useMemo(
|
|
480
|
+
() =>
|
|
481
|
+
appGraph && deploymentInventory && app.sourceRef
|
|
482
|
+
? layerDeploymentInventory(
|
|
483
|
+
appGraph,
|
|
484
|
+
deploymentInventory,
|
|
485
|
+
sourceInventoryLabel(app.sourceRef),
|
|
486
|
+
)
|
|
487
|
+
: null,
|
|
488
|
+
[appGraph, deploymentInventory, app.sourceRef],
|
|
489
|
+
);
|
|
213
490
|
const appGraphFocusId = useMemo(
|
|
214
491
|
() => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
|
|
215
492
|
[topology, appSeeds],
|
|
216
|
-
)
|
|
493
|
+
);
|
|
217
494
|
|
|
218
495
|
// Hovering a node lights up its owning workload (and the rail row). An
|
|
219
496
|
// unowned node related to exactly ONE workload (a GitOps manager over a
|
|
@@ -221,65 +498,139 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
221
498
|
// focus. Truly shared nodes clear instead of dimming everything.
|
|
222
499
|
const handleNodeHover = useCallback((node: TopologyNode | null) => {
|
|
223
500
|
if (!node) {
|
|
224
|
-
setFocusedOwnerId(null)
|
|
225
|
-
return
|
|
501
|
+
setFocusedOwnerId(null);
|
|
502
|
+
return;
|
|
226
503
|
}
|
|
227
|
-
const stamp = ownershipOf(node.data)
|
|
228
|
-
setFocusedOwnerId(
|
|
229
|
-
|
|
504
|
+
const stamp = ownershipOf(node.data);
|
|
505
|
+
setFocusedOwnerId(
|
|
506
|
+
stamp.ownerWorkloadId ??
|
|
507
|
+
(stamp.focusWorkloadIds.length === 1
|
|
508
|
+
? stamp.focusWorkloadIds[0]
|
|
509
|
+
: NEUTRAL_OWNER),
|
|
510
|
+
);
|
|
511
|
+
}, []);
|
|
230
512
|
|
|
231
513
|
// A node click in the app graph either drills into one of the app's workloads
|
|
232
514
|
// (its runtime) or opens a related resource (Service/config/…) via the host.
|
|
233
515
|
const handleAppNodeClick = useCallback(
|
|
234
516
|
(node: TopologyNode) => {
|
|
235
|
-
|
|
236
|
-
|
|
517
|
+
if (node.data?.sourceInventoryGroup && app.sourceRef) {
|
|
518
|
+
onOpenSource?.(app.sourceRef);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const ns = (node.data?.namespace as string) || "";
|
|
522
|
+
const match = workloads.find(
|
|
523
|
+
(w) =>
|
|
524
|
+
w.kind === node.kind && w.name === node.name && w.namespace === ns,
|
|
525
|
+
);
|
|
237
526
|
if (match) {
|
|
238
|
-
setSelected(workloadKey(match))
|
|
239
|
-
return
|
|
527
|
+
setSelected(workloadKey(match));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const parents = appGraph ? batchRunParentNodes(appGraph, node) : [];
|
|
531
|
+
const parentWorkload = parents.flatMap((parent) => {
|
|
532
|
+
const parentNamespace = (parent.data?.namespace as string) || "";
|
|
533
|
+
const workload = workloads.find(
|
|
534
|
+
(candidate) =>
|
|
535
|
+
candidate.kind === parent.kind &&
|
|
536
|
+
candidate.name === parent.name &&
|
|
537
|
+
candidate.namespace === parentNamespace,
|
|
538
|
+
);
|
|
539
|
+
return workload ? [workload] : [];
|
|
540
|
+
})[0];
|
|
541
|
+
if (parentWorkload && onSelectWorkloadRun) {
|
|
542
|
+
onSelectWorkloadRun(parentWorkload, node);
|
|
543
|
+
return;
|
|
240
544
|
}
|
|
241
545
|
onNavigateToResource?.({
|
|
242
546
|
kind: kindToPlural(node.kind),
|
|
243
547
|
namespace: ns,
|
|
244
548
|
name: node.name,
|
|
245
|
-
group:
|
|
246
|
-
})
|
|
549
|
+
group: topologyGroup(node),
|
|
550
|
+
});
|
|
247
551
|
},
|
|
248
|
-
[
|
|
249
|
-
|
|
552
|
+
[
|
|
553
|
+
app.sourceRef,
|
|
554
|
+
appGraph,
|
|
555
|
+
workloads,
|
|
556
|
+
onNavigateToResource,
|
|
557
|
+
onOpenSource,
|
|
558
|
+
onSelectWorkloadRun,
|
|
559
|
+
setSelected,
|
|
560
|
+
],
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const appTopology = deploymentLayer?.topology ?? appGraph ?? null;
|
|
564
|
+
const appTopologyAvailable = !!appTopology && appTopology.nodes.length > 0;
|
|
565
|
+
const colorByWorkload = ownership?.colorByWorkload ?? null;
|
|
250
566
|
|
|
251
567
|
return (
|
|
252
|
-
<div className="flex w-full flex-col">
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
568
|
+
<div className="flex min-h-0 w-full flex-1 flex-col bg-theme-base">
|
|
569
|
+
<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">
|
|
570
|
+
<button
|
|
571
|
+
type="button"
|
|
572
|
+
onClick={onBack}
|
|
573
|
+
className="flex shrink-0 items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
574
|
+
>
|
|
256
575
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden /> Applications
|
|
257
576
|
</button>
|
|
258
|
-
<span
|
|
259
|
-
|
|
577
|
+
<span
|
|
578
|
+
className="hidden h-6 w-px bg-theme-border sm:block"
|
|
579
|
+
aria-hidden
|
|
580
|
+
/>
|
|
581
|
+
<span
|
|
582
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}
|
|
583
|
+
>
|
|
260
584
|
<Boxes className="h-4 w-4" aria-hidden />
|
|
261
585
|
</span>
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
586
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
587
|
+
<h1 className="min-w-0 truncate text-xl font-semibold text-theme-text-primary lg:text-2xl">
|
|
588
|
+
{app.name}
|
|
589
|
+
</h1>
|
|
590
|
+
{!singleWorkloadScope && (
|
|
591
|
+
<>
|
|
592
|
+
<span className="shrink-0 text-theme-text-tertiary">/</span>
|
|
593
|
+
<ApplicationScopeSelector
|
|
594
|
+
workloads={workloads}
|
|
595
|
+
selectedWorkload={selectedWorkload ?? null}
|
|
596
|
+
onSelect={setSelected}
|
|
597
|
+
onFocus={setFocusedOwnerId}
|
|
598
|
+
/>
|
|
599
|
+
</>
|
|
600
|
+
)}
|
|
601
|
+
</div>
|
|
602
|
+
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
|
603
|
+
<span
|
|
604
|
+
className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}
|
|
605
|
+
>
|
|
268
606
|
<StatusDot tone={mapHealthToTone(overall)} />
|
|
269
607
|
<span className="text-sm font-semibold">{verdictLabel}</span>
|
|
270
608
|
</span>
|
|
271
609
|
{restartSignal && (
|
|
272
|
-
<Tooltip
|
|
273
|
-
|
|
274
|
-
|
|
610
|
+
<Tooltip
|
|
611
|
+
content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, "restart")}`}
|
|
612
|
+
delay={150}
|
|
613
|
+
>
|
|
614
|
+
<span
|
|
615
|
+
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}
|
|
616
|
+
>
|
|
617
|
+
Pod warning: {restartSignal.reason || "Restarts"} ·{" "}
|
|
618
|
+
{pluralize(restartSignal.restarts, "restart")}
|
|
275
619
|
</span>
|
|
276
620
|
</Tooltip>
|
|
277
621
|
)}
|
|
278
622
|
{/* Amber only on real skew (same image, different tags) — the context
|
|
279
623
|
strip already covers the multi-image "N versions" case neutrally. */}
|
|
280
624
|
{app.versionSkew && versions.length > 1 && (
|
|
281
|
-
<Tooltip
|
|
282
|
-
<
|
|
625
|
+
<Tooltip
|
|
626
|
+
content={<VersionTooltip workloads={workloads} />}
|
|
627
|
+
delay={150}
|
|
628
|
+
>
|
|
629
|
+
<span
|
|
630
|
+
className={`inline-flex items-center rounded-md px-2 py-1 font-mono text-xs ring-1 ring-inset ${CHIP_TONE.amber}`}
|
|
631
|
+
>
|
|
632
|
+
{versions.length} versions
|
|
633
|
+
</span>
|
|
283
634
|
</Tooltip>
|
|
284
635
|
)}
|
|
285
636
|
</div>
|
|
@@ -287,18 +638,46 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
287
638
|
|
|
288
639
|
{/* Context strip */}
|
|
289
640
|
<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">
|
|
641
|
+
<ProvenanceBadge
|
|
642
|
+
tier={app.tier}
|
|
643
|
+
appKey={app.key}
|
|
644
|
+
confidence={app.confidence}
|
|
645
|
+
/>
|
|
646
|
+
<CategoryChip category={app.category} addonReason={app.addonReason} />
|
|
647
|
+
<ClassBadge
|
|
648
|
+
workloadClass={workloadClass}
|
|
649
|
+
composition={classCompositionOf(app)}
|
|
650
|
+
/>
|
|
651
|
+
<BatchSignalChip signal={batchSignal} />
|
|
652
|
+
{singleWorkloadScope && selectedWorkload.name !== app.name && (
|
|
653
|
+
<ContextFact label="Workload">
|
|
654
|
+
<span className="font-mono">
|
|
655
|
+
{selectedWorkload.kind}/{selectedWorkload.name}
|
|
656
|
+
</span>
|
|
657
|
+
</ContextFact>
|
|
658
|
+
)}
|
|
290
659
|
{identityInstances && identityInstances.length > 1 ? (
|
|
291
660
|
// The Environment fact IS the switcher when this app runs in several
|
|
292
661
|
// envs — prominent, in existing header space, no extra row. Inline
|
|
293
662
|
// pills for a handful; a picker beyond that (scales to ~any count).
|
|
294
663
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
295
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
296
|
-
|
|
664
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
665
|
+
Environment
|
|
666
|
+
</span>
|
|
667
|
+
<EnvSwitcher
|
|
668
|
+
identityKey={app.identity?.key ?? ""}
|
|
669
|
+
instances={identityInstances}
|
|
670
|
+
activeKey={activeInstanceKey ?? app.key}
|
|
671
|
+
onSwitch={onSwitchInstance}
|
|
672
|
+
/>
|
|
297
673
|
</div>
|
|
298
674
|
) : env ? (
|
|
299
675
|
<ContextFact label="Environment">
|
|
300
676
|
{inferred ? (
|
|
301
|
-
<Tooltip
|
|
677
|
+
<Tooltip
|
|
678
|
+
content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`}
|
|
679
|
+
delay={150}
|
|
680
|
+
>
|
|
302
681
|
<span className="italic">~{env}</span>
|
|
303
682
|
</Tooltip>
|
|
304
683
|
) : (
|
|
@@ -312,14 +691,18 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
312
691
|
</ContextFact>
|
|
313
692
|
) : namespaces.length > 1 ? (
|
|
314
693
|
<ContextFact label="Namespaces">
|
|
315
|
-
<Tooltip content={namespaces.join(
|
|
694
|
+
<Tooltip content={namespaces.join(", ")} delay={150}>
|
|
316
695
|
<span>{namespaces.length} namespaces</span>
|
|
317
696
|
</Tooltip>
|
|
318
697
|
</ContextFact>
|
|
319
698
|
) : null}
|
|
320
|
-
|
|
321
|
-
<
|
|
322
|
-
|
|
699
|
+
{workloadClass === "job" ? (
|
|
700
|
+
<ContextFact label="Runtime">{batchRuntime.label}</ContextFact>
|
|
701
|
+
) : desired > 0 ? (
|
|
702
|
+
<ContextFact label="Ready">
|
|
703
|
+
<ReadyBar ready={ready} desired={desired} width="w-16" />
|
|
704
|
+
</ContextFact>
|
|
705
|
+
) : null}
|
|
323
706
|
{(app.appVersion || versions.length > 0) && (
|
|
324
707
|
<ContextFact label="Version">
|
|
325
708
|
<VersionInfo app={app} variant="fact" />
|
|
@@ -327,352 +710,2764 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
|
|
|
327
710
|
)}
|
|
328
711
|
</div>
|
|
329
712
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
713
|
+
<div className="min-h-0 flex-1 overflow-hidden bg-theme-base">
|
|
714
|
+
{selectedWorkload ? (
|
|
715
|
+
renderRuntime(selectedWorkload, renderWorkload)
|
|
716
|
+
) : (
|
|
717
|
+
<ApplicationWorkspace
|
|
718
|
+
app={app}
|
|
719
|
+
activeView={activeView}
|
|
720
|
+
costSelected={costSelected}
|
|
721
|
+
onViewChange={setView}
|
|
722
|
+
onCostViewChange={selectCostView}
|
|
723
|
+
renderCostView={renderCostView}
|
|
336
724
|
workloads={workloads}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
725
|
+
ready={ready}
|
|
726
|
+
desired={desired}
|
|
727
|
+
versions={versions}
|
|
728
|
+
topology={appTopology}
|
|
729
|
+
topologyLoading={topologyLoading}
|
|
730
|
+
deploymentLayer={deploymentLayer}
|
|
731
|
+
deploymentSource={app.sourceRef}
|
|
732
|
+
topologyAvailable={appTopologyAvailable}
|
|
733
|
+
focusNodeId={appGraphFocusId}
|
|
342
734
|
focusedOwnerId={focusedOwnerId}
|
|
343
|
-
|
|
344
|
-
|
|
735
|
+
colorByWorkload={colorByWorkload}
|
|
736
|
+
onNodeClick={handleAppNodeClick}
|
|
737
|
+
onNodeHover={handleNodeHover}
|
|
738
|
+
onFocusWorkload={setFocusedOwnerId}
|
|
739
|
+
onSelectWorkload={(workload, options) =>
|
|
740
|
+
setSelected(workloadKey(workload), options)
|
|
741
|
+
}
|
|
742
|
+
onNavigateToResource={onNavigateToResource}
|
|
743
|
+
history={history}
|
|
744
|
+
historyLoading={historyLoading}
|
|
745
|
+
historyItems={historyItems}
|
|
746
|
+
historyRuntimeLoading={historyRuntimeLoading}
|
|
747
|
+
historyRuntimeError={historyRuntimeError}
|
|
748
|
+
historyMode={historyMode}
|
|
749
|
+
historyRange={historyRange}
|
|
750
|
+
historyRangeOptions={historyRangeOptions}
|
|
751
|
+
historyCoverageRecordCount={historyCoverageRecordCount}
|
|
752
|
+
historyRuntimeLimited={historyRuntimeLimited}
|
|
753
|
+
onHistoryRangeChange={onHistoryRangeChange}
|
|
754
|
+
onOpenTimeline={onOpenTimeline}
|
|
755
|
+
onOpenSource={onOpenSource}
|
|
756
|
+
onToggleReplicaSets={toggleReplicaSets}
|
|
757
|
+
renderOverviewIssues={renderOverviewIssues}
|
|
758
|
+
hasOverviewIssues={hasOverviewIssues}
|
|
345
759
|
/>
|
|
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
|
-
)}
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
374
762
|
</div>
|
|
375
|
-
)
|
|
763
|
+
);
|
|
376
764
|
}
|
|
377
765
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
766
|
+
function ApplicationWorkspace({
|
|
767
|
+
app,
|
|
768
|
+
activeView,
|
|
769
|
+
costSelected,
|
|
770
|
+
onViewChange,
|
|
771
|
+
onCostViewChange,
|
|
772
|
+
renderCostView,
|
|
383
773
|
workloads,
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
774
|
+
ready,
|
|
775
|
+
desired,
|
|
776
|
+
versions,
|
|
777
|
+
topology,
|
|
778
|
+
topologyLoading,
|
|
779
|
+
deploymentLayer,
|
|
780
|
+
deploymentSource,
|
|
781
|
+
topologyAvailable,
|
|
782
|
+
focusNodeId,
|
|
387
783
|
focusedOwnerId,
|
|
388
|
-
|
|
389
|
-
|
|
784
|
+
colorByWorkload,
|
|
785
|
+
onNodeClick,
|
|
786
|
+
onNodeHover,
|
|
787
|
+
onFocusWorkload,
|
|
788
|
+
onSelectWorkload,
|
|
789
|
+
onNavigateToResource,
|
|
790
|
+
history,
|
|
791
|
+
historyLoading,
|
|
792
|
+
historyItems,
|
|
793
|
+
historyRuntimeLoading,
|
|
794
|
+
historyRuntimeError,
|
|
795
|
+
historyMode,
|
|
796
|
+
historyRange,
|
|
797
|
+
historyRangeOptions,
|
|
798
|
+
historyCoverageRecordCount,
|
|
799
|
+
historyRuntimeLimited,
|
|
800
|
+
onHistoryRangeChange,
|
|
801
|
+
onOpenTimeline,
|
|
802
|
+
onOpenSource,
|
|
803
|
+
onToggleReplicaSets,
|
|
804
|
+
renderOverviewIssues,
|
|
805
|
+
hasOverviewIssues,
|
|
390
806
|
}: {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
807
|
+
app: AppRow;
|
|
808
|
+
activeView: CanonicalApplicationView;
|
|
809
|
+
costSelected: boolean;
|
|
810
|
+
onViewChange: (view: CanonicalApplicationView) => void;
|
|
811
|
+
onCostViewChange: () => void;
|
|
812
|
+
renderCostView?: ApplicationDetailProps["renderCostView"];
|
|
813
|
+
workloads: AppWorkload[];
|
|
814
|
+
ready: number;
|
|
815
|
+
desired: number;
|
|
816
|
+
versions: string[];
|
|
817
|
+
topology: Topology | null;
|
|
818
|
+
topologyLoading?: boolean;
|
|
819
|
+
deploymentLayer: DeploymentTopologyLayer | null;
|
|
820
|
+
deploymentSource?: AppSourceRef;
|
|
821
|
+
topologyAvailable: boolean;
|
|
822
|
+
focusNodeId?: string;
|
|
823
|
+
focusedOwnerId: WorkloadFocus;
|
|
824
|
+
colorByWorkload: Map<string, number> | null;
|
|
825
|
+
onNodeClick: (node: TopologyNode) => void;
|
|
826
|
+
onNodeHover: (node: TopologyNode | null) => void;
|
|
827
|
+
onFocusWorkload: (owner: WorkloadFocus) => void;
|
|
828
|
+
onSelectWorkload: (
|
|
829
|
+
workload: AppWorkload,
|
|
830
|
+
options?: AppWorkloadSelectionOptions,
|
|
831
|
+
) => void;
|
|
832
|
+
onNavigateToResource?: (resource: {
|
|
833
|
+
kind: string;
|
|
834
|
+
namespace: string;
|
|
835
|
+
name: string;
|
|
836
|
+
group?: string;
|
|
837
|
+
}) => void;
|
|
838
|
+
history?: AppHistory;
|
|
839
|
+
historyLoading?: boolean;
|
|
840
|
+
historyItems?: ApplicationHistoryItem[];
|
|
841
|
+
historyRuntimeLoading?: boolean;
|
|
842
|
+
historyRuntimeError?: boolean;
|
|
843
|
+
historyMode?: "local" | "retained";
|
|
844
|
+
historyRange?: ApplicationHistoryRange;
|
|
845
|
+
historyRangeOptions?: Array<{
|
|
846
|
+
value: ApplicationHistoryRange;
|
|
847
|
+
label: string;
|
|
848
|
+
}>;
|
|
849
|
+
historyCoverageRecordCount?: number;
|
|
850
|
+
historyRuntimeLimited?: boolean;
|
|
851
|
+
onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
|
|
852
|
+
onOpenTimeline?: (timestamp?: string) => void;
|
|
853
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
854
|
+
onToggleReplicaSets: (ownerID: string) => void;
|
|
855
|
+
renderOverviewIssues?: () => ReactNode;
|
|
856
|
+
hasOverviewIssues?: boolean;
|
|
398
857
|
}) {
|
|
399
858
|
return (
|
|
400
|
-
<div
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
859
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
860
|
+
<ApplicationViewTabs
|
|
861
|
+
activeView={activeView}
|
|
862
|
+
costSelected={costSelected}
|
|
863
|
+
costAvailable={Boolean(renderCostView)}
|
|
864
|
+
onChange={onViewChange}
|
|
865
|
+
onCostChange={onCostViewChange}
|
|
866
|
+
/>
|
|
867
|
+
{costSelected && renderCostView && (
|
|
868
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">
|
|
869
|
+
{renderCostView({ app, workloads, onSelectWorkload })}
|
|
870
|
+
</div>
|
|
871
|
+
)}
|
|
872
|
+
{!costSelected && activeView === "overview" && (
|
|
873
|
+
<ApplicationOverview
|
|
874
|
+
app={app}
|
|
875
|
+
workloads={workloads}
|
|
876
|
+
ready={ready}
|
|
877
|
+
desired={desired}
|
|
878
|
+
versions={versions}
|
|
879
|
+
onSelectWorkload={onSelectWorkload}
|
|
880
|
+
onNavigateToResource={onNavigateToResource}
|
|
881
|
+
history={history}
|
|
882
|
+
onSelectHistory={() => onViewChange("history")}
|
|
883
|
+
onOpenSource={onOpenSource}
|
|
884
|
+
renderOverviewIssues={renderOverviewIssues}
|
|
885
|
+
hasOverviewIssues={hasOverviewIssues}
|
|
416
886
|
/>
|
|
417
887
|
)}
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
888
|
+
{!costSelected && activeView === "topology" && (
|
|
889
|
+
<ApplicationTopology
|
|
890
|
+
topology={topology}
|
|
891
|
+
loading={topologyLoading}
|
|
892
|
+
available={topologyAvailable}
|
|
893
|
+
workloads={workloads}
|
|
894
|
+
colorByWorkload={colorByWorkload}
|
|
895
|
+
focusNodeId={focusNodeId}
|
|
896
|
+
focusedOwnerId={focusedOwnerId}
|
|
897
|
+
onNodeClick={onNodeClick}
|
|
898
|
+
onNodeHover={onNodeHover}
|
|
899
|
+
onFocusWorkload={onFocusWorkload}
|
|
900
|
+
onSelectWorkload={onSelectWorkload}
|
|
901
|
+
deploymentLayer={deploymentLayer}
|
|
902
|
+
deploymentSource={deploymentSource}
|
|
903
|
+
onOpenSource={onOpenSource}
|
|
904
|
+
onToggleReplicaSets={onToggleReplicaSets}
|
|
905
|
+
/>
|
|
906
|
+
)}
|
|
907
|
+
{!costSelected && activeView === "history" && (
|
|
908
|
+
<ApplicationHistoryView
|
|
909
|
+
history={history}
|
|
910
|
+
sourceLoading={historyLoading}
|
|
911
|
+
items={historyItems}
|
|
912
|
+
runtimeLoading={historyRuntimeLoading}
|
|
913
|
+
runtimeError={historyRuntimeError}
|
|
914
|
+
mode={historyMode}
|
|
915
|
+
range={historyRange}
|
|
916
|
+
rangeOptions={historyRangeOptions}
|
|
917
|
+
coverageRecordCount={historyCoverageRecordCount}
|
|
918
|
+
runtimeLimited={historyRuntimeLimited}
|
|
919
|
+
workloads={workloads}
|
|
920
|
+
onSelectWorkload={onSelectWorkload}
|
|
921
|
+
onNavigateToResource={onNavigateToResource}
|
|
922
|
+
onRangeChange={onHistoryRangeChange}
|
|
923
|
+
onOpenTimeline={onOpenTimeline}
|
|
924
|
+
onOpenSource={onOpenSource}
|
|
451
925
|
/>
|
|
452
926
|
)}
|
|
453
927
|
</div>
|
|
454
|
-
)
|
|
928
|
+
);
|
|
455
929
|
}
|
|
456
930
|
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
swatch,
|
|
464
|
-
title,
|
|
465
|
-
tooltip,
|
|
466
|
-
subtitle,
|
|
467
|
-
trailing,
|
|
931
|
+
function ApplicationViewTabs({
|
|
932
|
+
activeView,
|
|
933
|
+
costSelected,
|
|
934
|
+
costAvailable,
|
|
935
|
+
onChange,
|
|
936
|
+
onCostChange,
|
|
468
937
|
}: {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
938
|
+
activeView: CanonicalApplicationView;
|
|
939
|
+
costSelected: boolean;
|
|
940
|
+
costAvailable: boolean;
|
|
941
|
+
onChange: (view: CanonicalApplicationView) => void;
|
|
942
|
+
onCostChange: () => void;
|
|
480
943
|
}) {
|
|
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
|
-
|
|
944
|
+
return (
|
|
945
|
+
<div
|
|
946
|
+
className="flex shrink-0 items-center border-b border-theme-border px-4 sm:px-6"
|
|
947
|
+
role="tablist"
|
|
948
|
+
aria-label="Application views"
|
|
949
|
+
>
|
|
950
|
+
<div className="flex min-w-0 gap-1 overflow-x-auto">
|
|
951
|
+
{APPLICATION_VIEWS.map((view) => {
|
|
952
|
+
const active = !costSelected && view.id === activeView;
|
|
953
|
+
return (
|
|
954
|
+
<button
|
|
955
|
+
key={view.id}
|
|
956
|
+
type="button"
|
|
957
|
+
role="tab"
|
|
958
|
+
aria-selected={active}
|
|
959
|
+
onClick={() => onChange(view.id)}
|
|
960
|
+
className={clsx(
|
|
961
|
+
"flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
|
962
|
+
active
|
|
963
|
+
? "border-skyhook-500 text-theme-text-primary"
|
|
964
|
+
: "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
|
|
965
|
+
)}
|
|
966
|
+
>
|
|
967
|
+
{view.label}
|
|
968
|
+
</button>
|
|
969
|
+
);
|
|
970
|
+
})}
|
|
971
|
+
{costAvailable && (
|
|
972
|
+
<button
|
|
973
|
+
type="button"
|
|
974
|
+
role="tab"
|
|
975
|
+
aria-selected={costSelected}
|
|
976
|
+
onClick={onCostChange}
|
|
977
|
+
className={clsx(
|
|
978
|
+
"flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
|
979
|
+
costSelected
|
|
980
|
+
? "border-skyhook-500 text-theme-text-primary"
|
|
981
|
+
: "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
|
|
982
|
+
)}
|
|
983
|
+
>
|
|
984
|
+
<DollarSign className="h-4 w-4" />
|
|
985
|
+
Cost
|
|
986
|
+
</button>
|
|
987
|
+
)}
|
|
988
|
+
</div>
|
|
989
|
+
</div>
|
|
990
|
+
);
|
|
506
991
|
}
|
|
507
992
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
993
|
+
type AppIssueSeverity = "error" | "warning" | "info";
|
|
994
|
+
|
|
995
|
+
type AppIssue = {
|
|
996
|
+
key: string;
|
|
997
|
+
severity: AppIssueSeverity;
|
|
998
|
+
title: string;
|
|
999
|
+
detail?: string;
|
|
1000
|
+
workload?: AppWorkload;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
function ApplicationOverview({
|
|
1004
|
+
app,
|
|
1005
|
+
workloads,
|
|
1006
|
+
ready,
|
|
1007
|
+
desired,
|
|
1008
|
+
versions,
|
|
1009
|
+
onSelectWorkload,
|
|
1010
|
+
onNavigateToResource,
|
|
1011
|
+
history,
|
|
1012
|
+
onSelectHistory,
|
|
1013
|
+
onOpenSource,
|
|
1014
|
+
renderOverviewIssues,
|
|
1015
|
+
hasOverviewIssues,
|
|
1016
|
+
}: {
|
|
1017
|
+
app: AppRow;
|
|
1018
|
+
workloads: AppWorkload[];
|
|
1019
|
+
ready: number;
|
|
1020
|
+
desired: number;
|
|
1021
|
+
versions: string[];
|
|
1022
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
1023
|
+
onNavigateToResource?: (resource: {
|
|
1024
|
+
kind: string;
|
|
1025
|
+
namespace: string;
|
|
1026
|
+
name: string;
|
|
1027
|
+
group?: string;
|
|
1028
|
+
}) => void;
|
|
1029
|
+
history?: AppHistory;
|
|
1030
|
+
onSelectHistory: () => void;
|
|
1031
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
1032
|
+
renderOverviewIssues?: () => ReactNode;
|
|
1033
|
+
hasOverviewIssues?: boolean;
|
|
1034
|
+
}) {
|
|
1035
|
+
const rel = app.relationships;
|
|
1036
|
+
const hasEntrypoints = Boolean(
|
|
1037
|
+
rel &&
|
|
1038
|
+
(relationshipRefs(rel, "service").length > 0 ||
|
|
1039
|
+
relationshipRefs(rel, "ingress").length > 0 ||
|
|
1040
|
+
relationshipRefs(rel, "route").length > 0),
|
|
1041
|
+
);
|
|
1042
|
+
const hasDependencies = Boolean(
|
|
1043
|
+
rel &&
|
|
1044
|
+
(relationshipRefs(rel, "config").length > 0 ||
|
|
1045
|
+
relationshipRefs(rel, "scaler").length > 0 ||
|
|
1046
|
+
relationshipRefs(rel, "storage").length > 0 ||
|
|
1047
|
+
relationshipRefs(rel, "pdb").length > 0 ||
|
|
1048
|
+
relationshipRefs(rel, "networkPolicy").length > 0),
|
|
1049
|
+
);
|
|
1050
|
+
const composition = classCompositionOf(app)
|
|
1051
|
+
.map(({ cls, count }) => `${count} ${cls}`)
|
|
1052
|
+
.join(" / ");
|
|
1053
|
+
const issues = useMemo(
|
|
1054
|
+
() => buildAppIssues(workloads, app.events ?? []),
|
|
1055
|
+
[workloads, app.events],
|
|
1056
|
+
);
|
|
1057
|
+
const currentIssuesPresent = renderOverviewIssues
|
|
1058
|
+
? (hasOverviewIssues ?? issues.length > 0)
|
|
1059
|
+
: issues.length > 0;
|
|
1060
|
+
const batchActivity = useMemo(() => batchActivityForApp(app), [app]);
|
|
1061
|
+
const batchStats = batchOverviewStats(batchActivity);
|
|
1062
|
+
const pureBatch = workloadClassOf(app.workload_class) === "job";
|
|
1063
|
+
const workloadComposition = workloadKindComposition(workloads);
|
|
1064
|
+
const latestChange =
|
|
1065
|
+
history?.summary?.state === "change" ? history.summary : undefined;
|
|
1066
|
+
const runtimeHealth = healthOf(
|
|
1067
|
+
app.runtimeHealth ??
|
|
1068
|
+
worstHealth(workloads.map((workload) => workload.health)),
|
|
1069
|
+
);
|
|
1070
|
+
const hasDeliveryStatus = Boolean(
|
|
1071
|
+
app.sourceStatus?.sync || app.sourceStatus?.health,
|
|
1072
|
+
);
|
|
1073
|
+
const extraFactCount =
|
|
1074
|
+
Number(Boolean(latestChange)) + Number(hasDeliveryStatus);
|
|
1075
|
+
const factGrid = pureBatch
|
|
1076
|
+
? extraFactCount === 2
|
|
1077
|
+
? "2xl:grid-cols-6"
|
|
1078
|
+
: extraFactCount === 1
|
|
1079
|
+
? "2xl:grid-cols-5"
|
|
1080
|
+
: "2xl:grid-cols-4"
|
|
1081
|
+
: extraFactCount === 2
|
|
1082
|
+
? "2xl:grid-cols-5"
|
|
1083
|
+
: extraFactCount === 1
|
|
1084
|
+
? "2xl:grid-cols-4"
|
|
1085
|
+
: "2xl:grid-cols-3";
|
|
1086
|
+
|
|
522
1087
|
return (
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1088
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
1089
|
+
<div className="grid w-full max-w-[2400px] gap-4 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
|
1090
|
+
<div className="min-w-0 space-y-4">
|
|
1091
|
+
{renderOverviewIssues ? (
|
|
1092
|
+
renderOverviewIssues()
|
|
1093
|
+
) : (
|
|
1094
|
+
<ApplicationNow
|
|
1095
|
+
issues={issues}
|
|
1096
|
+
onSelectWorkload={onSelectWorkload}
|
|
1097
|
+
/>
|
|
1098
|
+
)}
|
|
1099
|
+
<ApplicationLatestHistory
|
|
1100
|
+
history={history}
|
|
1101
|
+
sourceRef={app.sourceRef}
|
|
1102
|
+
showIncidentPreview={!currentIssuesPresent}
|
|
1103
|
+
onSelectHistory={onSelectHistory}
|
|
1104
|
+
onOpenSource={onOpenSource}
|
|
1105
|
+
/>
|
|
1106
|
+
<div className={clsx("grid gap-3 md:grid-cols-2", factGrid)}>
|
|
1107
|
+
<ApplicationFact
|
|
1108
|
+
label="Runtime"
|
|
1109
|
+
value={
|
|
1110
|
+
pureBatch
|
|
1111
|
+
? batchRuntimeForApp(app).label
|
|
1112
|
+
: HEALTH_META[runtimeHealth].label
|
|
1113
|
+
}
|
|
1114
|
+
detail={
|
|
1115
|
+
pureBatch
|
|
1116
|
+
? batchStats.activeDetail
|
|
1117
|
+
: desired > 0
|
|
1118
|
+
? `${ready}/${desired} ready`
|
|
1119
|
+
: "No desired replicas"
|
|
1120
|
+
}
|
|
1121
|
+
/>
|
|
1122
|
+
{hasDeliveryStatus && (
|
|
1123
|
+
<ApplicationFact
|
|
1124
|
+
label="Delivery"
|
|
1125
|
+
value={<SourceDeliveryStatus status={app.sourceStatus!} />}
|
|
1126
|
+
detail={
|
|
1127
|
+
app.sourceRef ? sourceObjectLabel(app.sourceRef) : undefined
|
|
1128
|
+
}
|
|
1129
|
+
/>
|
|
1130
|
+
)}
|
|
1131
|
+
{pureBatch ? (
|
|
1132
|
+
<>
|
|
1133
|
+
<ApplicationFact
|
|
1134
|
+
label="Batch resources"
|
|
1135
|
+
value={String(workloads.length)}
|
|
1136
|
+
detail={workloadComposition || "No workloads"}
|
|
1137
|
+
/>
|
|
1138
|
+
<ApplicationFact
|
|
1139
|
+
label="Active runs"
|
|
1140
|
+
value={batchStats.activeValue}
|
|
1141
|
+
detail={batchStats.activeDetail}
|
|
1142
|
+
/>
|
|
1143
|
+
<ApplicationFact
|
|
1144
|
+
label="Retained runs"
|
|
1145
|
+
value={batchStats.retainedValue}
|
|
1146
|
+
detail="Kubernetes-retained history"
|
|
1147
|
+
/>
|
|
1148
|
+
</>
|
|
1149
|
+
) : (
|
|
1150
|
+
<>
|
|
1151
|
+
<ApplicationFact
|
|
1152
|
+
label="Workloads"
|
|
1153
|
+
value={String(workloads.length)}
|
|
1154
|
+
detail={composition || "No workloads"}
|
|
1155
|
+
/>
|
|
1156
|
+
<ApplicationFact
|
|
1157
|
+
label="Version"
|
|
1158
|
+
value={
|
|
1159
|
+
app.appVersion ||
|
|
1160
|
+
(versions.length === 1
|
|
1161
|
+
? versions[0]
|
|
1162
|
+
: versions.length > 1
|
|
1163
|
+
? `${versions.length} versions`
|
|
1164
|
+
: "Unknown")
|
|
1165
|
+
}
|
|
1166
|
+
/>
|
|
1167
|
+
</>
|
|
1168
|
+
)}
|
|
1169
|
+
{latestChange && (
|
|
1170
|
+
<ApplicationLatestChangeFact
|
|
1171
|
+
summary={latestChange}
|
|
1172
|
+
sourceRef={app.sourceRef ? undefined : history?.sourceRef}
|
|
1173
|
+
onSelectHistory={onSelectHistory}
|
|
1174
|
+
onOpenSource={onOpenSource}
|
|
1175
|
+
/>
|
|
1176
|
+
)}
|
|
1177
|
+
</div>
|
|
1178
|
+
{hasEntrypoints && (
|
|
1179
|
+
<ApplicationEntrypoints
|
|
1180
|
+
relationships={rel}
|
|
1181
|
+
onNavigateToResource={onNavigateToResource}
|
|
1182
|
+
/>
|
|
1183
|
+
)}
|
|
1184
|
+
<ApplicationPanel title="Workloads">
|
|
1185
|
+
<WorkloadsMatrix
|
|
1186
|
+
workloads={workloads}
|
|
1187
|
+
onSelectWorkload={onSelectWorkload}
|
|
1188
|
+
/>
|
|
1189
|
+
</ApplicationPanel>
|
|
1190
|
+
{!pureBatch && <ApplicationBatchOverview activity={batchActivity} />}
|
|
1191
|
+
</div>
|
|
1192
|
+
<aside
|
|
1193
|
+
className={clsx(
|
|
1194
|
+
"min-w-0 gap-4 xl:block xl:space-y-4",
|
|
1195
|
+
hasDependencies ? "grid md:grid-cols-2" : "max-w-xl xl:max-w-none",
|
|
1196
|
+
)}
|
|
1197
|
+
>
|
|
1198
|
+
<ApplicationSourceProvenance app={app} onOpenSource={onOpenSource} />
|
|
1199
|
+
{hasDependencies && (
|
|
1200
|
+
<ApplicationDependencies
|
|
1201
|
+
relationships={rel}
|
|
1202
|
+
onNavigateToResource={onNavigateToResource}
|
|
1203
|
+
/>
|
|
1204
|
+
)}
|
|
1205
|
+
</aside>
|
|
1206
|
+
</div>
|
|
528
1207
|
</div>
|
|
529
|
-
)
|
|
1208
|
+
);
|
|
530
1209
|
}
|
|
531
1210
|
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
for (const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
worst = { restarts: r, reason: w.reason, workload: `${w.kind}/${w.name}` }
|
|
538
|
-
}
|
|
1211
|
+
function workloadKindComposition(workloads: AppWorkload[]): string {
|
|
1212
|
+
const counts = new Map<string, number>();
|
|
1213
|
+
for (const workload of workloads) {
|
|
1214
|
+
if (!workload.kind) continue;
|
|
1215
|
+
counts.set(workload.kind, (counts.get(workload.kind) ?? 0) + 1);
|
|
539
1216
|
}
|
|
540
|
-
return
|
|
1217
|
+
return [...counts.entries()]
|
|
1218
|
+
.sort(
|
|
1219
|
+
([aKind, aCount], [bKind, bCount]) =>
|
|
1220
|
+
bCount - aCount || aKind.localeCompare(bKind),
|
|
1221
|
+
)
|
|
1222
|
+
.map(([kind, count]) => pluralize(count, kind))
|
|
1223
|
+
.join(" / ");
|
|
541
1224
|
}
|
|
542
1225
|
|
|
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,
|
|
1226
|
+
function ApplicationSourceProvenance({
|
|
1227
|
+
app,
|
|
1228
|
+
onOpenSource,
|
|
556
1229
|
}: {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
activeKey: string
|
|
560
|
-
onSwitch?: (appKey: string) => void
|
|
1230
|
+
app: AppRow;
|
|
1231
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
561
1232
|
}) {
|
|
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) {
|
|
1233
|
+
if (app.sourceRef) {
|
|
1234
|
+
const sourceName = `${app.sourceRef.namespace}/${app.sourceRef.name}`;
|
|
601
1235
|
return (
|
|
602
|
-
<
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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>
|
|
1236
|
+
<ApplicationPanel title="Deployment source">
|
|
1237
|
+
<div className="space-y-3">
|
|
1238
|
+
<div>
|
|
1239
|
+
<div className="text-xs font-semibold uppercase text-theme-text-tertiary">
|
|
1240
|
+
{sourceObjectLabel(app.sourceRef)}
|
|
1241
|
+
</div>
|
|
1242
|
+
<Tooltip content={sourceName} delay={150}>
|
|
1243
|
+
<div className="mt-1 truncate font-mono text-sm font-medium text-theme-text-primary">
|
|
1244
|
+
{sourceName}
|
|
1245
|
+
</div>
|
|
621
1246
|
</Tooltip>
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1247
|
+
</div>
|
|
1248
|
+
{app.sourceStatus && (
|
|
1249
|
+
<SourceDeliveryStatus status={app.sourceStatus} />
|
|
1250
|
+
)}
|
|
1251
|
+
{onOpenSource && (
|
|
1252
|
+
<button
|
|
1253
|
+
type="button"
|
|
1254
|
+
onClick={() => onOpenSource(app.sourceRef!)}
|
|
1255
|
+
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"
|
|
1256
|
+
>
|
|
1257
|
+
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
1258
|
+
{sourceLinkLabel(app.sourceRef)}
|
|
1259
|
+
</button>
|
|
1260
|
+
)}
|
|
1261
|
+
</div>
|
|
1262
|
+
</ApplicationPanel>
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (app.sourceConflict) {
|
|
1267
|
+
return (
|
|
1268
|
+
<ApplicationPanel title="Deployment source">
|
|
1269
|
+
<div className="space-y-2">
|
|
1270
|
+
<div className="flex items-center gap-2">
|
|
1271
|
+
<AlertTriangle
|
|
1272
|
+
className="h-4 w-4 shrink-0 text-amber-500"
|
|
1273
|
+
aria-hidden
|
|
1274
|
+
/>
|
|
1275
|
+
<span className="text-sm font-semibold text-theme-text-primary">
|
|
1276
|
+
Multiple deployment sources detected
|
|
1277
|
+
</span>
|
|
1278
|
+
</div>
|
|
1279
|
+
<p className="text-sm text-theme-text-secondary">
|
|
1280
|
+
Workloads in this application do not share one deployment manager.
|
|
1281
|
+
</p>
|
|
1282
|
+
</div>
|
|
1283
|
+
</ApplicationPanel>
|
|
1284
|
+
);
|
|
628
1285
|
}
|
|
629
1286
|
|
|
1287
|
+
const inferred = app.identity?.confidence === "medium";
|
|
1288
|
+
const identityLabel = app.identity?.source
|
|
1289
|
+
? appSourceLabel(app.identity.source)
|
|
1290
|
+
: undefined;
|
|
1291
|
+
const tierLabel =
|
|
1292
|
+
!app.identity && app.tier ? overlayProvenance(app.tier) : undefined;
|
|
630
1293
|
return (
|
|
631
|
-
<
|
|
632
|
-
<
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
})}
|
|
1294
|
+
<ApplicationPanel title="Application identity">
|
|
1295
|
+
<div className="space-y-2">
|
|
1296
|
+
<div className="flex items-center gap-2">
|
|
1297
|
+
{inferred && (
|
|
1298
|
+
<AlertTriangle
|
|
1299
|
+
className="h-4 w-4 shrink-0 text-amber-500"
|
|
1300
|
+
aria-hidden
|
|
1301
|
+
/>
|
|
1302
|
+
)}
|
|
1303
|
+
<span className="text-sm font-semibold text-theme-text-primary">
|
|
1304
|
+
{identityLabel
|
|
1305
|
+
? inferred
|
|
1306
|
+
? "Inferred application boundary"
|
|
1307
|
+
: `Identified by ${identityLabel}`
|
|
1308
|
+
: tierLabel
|
|
1309
|
+
? `Grouped by ${tierLabel} metadata`
|
|
1310
|
+
: "Grouped from Kubernetes ownership"}
|
|
1311
|
+
</span>
|
|
674
1312
|
</div>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1313
|
+
{inferred && identityLabel && (
|
|
1314
|
+
<p className="text-sm text-theme-text-secondary">
|
|
1315
|
+
Matched using {identityLabel}.
|
|
1316
|
+
</p>
|
|
1317
|
+
)}
|
|
1318
|
+
{app.identity?.evidence && (
|
|
1319
|
+
<Tooltip content={app.identity.evidence} delay={150}>
|
|
1320
|
+
<div className="truncate font-mono text-xs text-theme-text-tertiary">
|
|
1321
|
+
{app.identity.evidence}
|
|
1322
|
+
</div>
|
|
1323
|
+
</Tooltip>
|
|
1324
|
+
)}
|
|
1325
|
+
</div>
|
|
1326
|
+
</ApplicationPanel>
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function ApplicationEntrypoints({
|
|
1331
|
+
relationships,
|
|
1332
|
+
onNavigateToResource,
|
|
1333
|
+
}: {
|
|
1334
|
+
relationships: AppRow["relationships"];
|
|
1335
|
+
onNavigateToResource?: (resource: {
|
|
1336
|
+
kind: string;
|
|
1337
|
+
namespace: string;
|
|
1338
|
+
name: string;
|
|
1339
|
+
group?: string;
|
|
1340
|
+
}) => void;
|
|
1341
|
+
}) {
|
|
1342
|
+
const serviceRefs = relationshipRefs(relationships, "service");
|
|
1343
|
+
const ingressRefs = relationshipRefs(relationships, "ingress");
|
|
1344
|
+
const routeRefs = relationshipRefs(relationships, "route");
|
|
1345
|
+
const serviceCount = serviceRefs.length;
|
|
1346
|
+
const ingressCount = ingressRefs.length;
|
|
1347
|
+
const routeCount = routeRefs.length;
|
|
1348
|
+
const hasExternal = ingressCount + routeCount > 0;
|
|
1349
|
+
if (serviceCount + ingressCount + routeCount === 0) return null;
|
|
1350
|
+
|
|
1351
|
+
return (
|
|
1352
|
+
<ApplicationPanel title="Entrypoints">
|
|
1353
|
+
<div className="space-y-3">
|
|
1354
|
+
<div>
|
|
1355
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1356
|
+
<h3 className="text-sm font-semibold text-theme-text-primary">
|
|
1357
|
+
{hasExternal
|
|
1358
|
+
? "External entrypoints configured"
|
|
1359
|
+
: "Internal services configured"}
|
|
1360
|
+
</h3>
|
|
1361
|
+
<span
|
|
1362
|
+
className={`${CHIP} ${hasExternal ? CHIP_TONE.blue : CHIP_TONE.muted}`}
|
|
1363
|
+
>
|
|
1364
|
+
{hasExternal ? "External" : "Internal"}
|
|
1365
|
+
</span>
|
|
1366
|
+
</div>
|
|
1367
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
1368
|
+
{hasExternal
|
|
1369
|
+
? "Traffic can enter this application through ingress or route resources."
|
|
1370
|
+
: "Services target this application, but no ingress or route was detected."}
|
|
1371
|
+
</p>
|
|
1372
|
+
</div>
|
|
1373
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
1374
|
+
<ApplicationFact
|
|
1375
|
+
variant="bare"
|
|
1376
|
+
label="Services"
|
|
1377
|
+
value={String(serviceCount)}
|
|
1378
|
+
/>
|
|
1379
|
+
<ApplicationFact
|
|
1380
|
+
variant="bare"
|
|
1381
|
+
label="Ingresses"
|
|
1382
|
+
value={String(ingressCount)}
|
|
1383
|
+
/>
|
|
1384
|
+
<ApplicationFact
|
|
1385
|
+
variant="bare"
|
|
1386
|
+
label="Routes"
|
|
1387
|
+
value={String(routeCount)}
|
|
1388
|
+
/>
|
|
1389
|
+
</div>
|
|
1390
|
+
<div className="space-y-3 border-t border-theme-border pt-3">
|
|
1391
|
+
<ApplicationRelatedNameGroup
|
|
1392
|
+
label="Services"
|
|
1393
|
+
refs={serviceRefs}
|
|
1394
|
+
onNavigateToResource={onNavigateToResource}
|
|
1395
|
+
/>
|
|
1396
|
+
<ApplicationRelatedNameGroup
|
|
1397
|
+
label="Ingresses"
|
|
1398
|
+
refs={ingressRefs}
|
|
1399
|
+
onNavigateToResource={onNavigateToResource}
|
|
1400
|
+
/>
|
|
1401
|
+
<ApplicationRelatedNameGroup
|
|
1402
|
+
label="Routes"
|
|
1403
|
+
refs={routeRefs}
|
|
1404
|
+
onNavigateToResource={onNavigateToResource}
|
|
1405
|
+
/>
|
|
1406
|
+
</div>
|
|
1407
|
+
</div>
|
|
1408
|
+
</ApplicationPanel>
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function ApplicationDependencies({
|
|
1413
|
+
relationships,
|
|
1414
|
+
onNavigateToResource,
|
|
1415
|
+
}: {
|
|
1416
|
+
relationships: AppRow["relationships"];
|
|
1417
|
+
onNavigateToResource?: (resource: {
|
|
1418
|
+
kind: string;
|
|
1419
|
+
namespace: string;
|
|
1420
|
+
name: string;
|
|
1421
|
+
group?: string;
|
|
1422
|
+
}) => void;
|
|
1423
|
+
}) {
|
|
1424
|
+
const configs = relationshipRefs(relationships, "config");
|
|
1425
|
+
const scalers = relationshipRefs(relationships, "scaler");
|
|
1426
|
+
const storage = relationshipRefs(relationships, "storage");
|
|
1427
|
+
const pdbs = relationshipRefs(relationships, "pdb");
|
|
1428
|
+
const networkPolicies = relationshipRefs(relationships, "networkPolicy");
|
|
1429
|
+
if (
|
|
1430
|
+
configs.length +
|
|
1431
|
+
scalers.length +
|
|
1432
|
+
storage.length +
|
|
1433
|
+
pdbs.length +
|
|
1434
|
+
networkPolicies.length ===
|
|
1435
|
+
0
|
|
1436
|
+
)
|
|
1437
|
+
return null;
|
|
1438
|
+
|
|
1439
|
+
return (
|
|
1440
|
+
<ApplicationPanel title="Dependencies">
|
|
1441
|
+
<div className="space-y-3">
|
|
1442
|
+
<ApplicationDependencyRow
|
|
1443
|
+
label="Configuration"
|
|
1444
|
+
refs={configs}
|
|
1445
|
+
totalCount={relationships?.configs}
|
|
1446
|
+
detail="ConfigMaps and Secrets referenced by app workloads."
|
|
1447
|
+
onNavigateToResource={onNavigateToResource}
|
|
1448
|
+
/>
|
|
1449
|
+
<ApplicationDependencyRow
|
|
1450
|
+
label="Autoscaling"
|
|
1451
|
+
refs={scalers}
|
|
1452
|
+
totalCount={relationships?.scalers}
|
|
1453
|
+
detail="Autoscalers controlling app workloads."
|
|
1454
|
+
onNavigateToResource={onNavigateToResource}
|
|
1455
|
+
/>
|
|
1456
|
+
<ApplicationDependencyRow
|
|
1457
|
+
label="Storage"
|
|
1458
|
+
refs={storage}
|
|
1459
|
+
totalCount={relationships?.storage}
|
|
1460
|
+
detail="PersistentVolumeClaims mounted by app workloads."
|
|
1461
|
+
onNavigateToResource={onNavigateToResource}
|
|
1462
|
+
/>
|
|
1463
|
+
<ApplicationDependencyRow
|
|
1464
|
+
label="Availability policy"
|
|
1465
|
+
refs={pdbs}
|
|
1466
|
+
totalCount={relationships?.pdbs}
|
|
1467
|
+
detail="PodDisruptionBudgets protecting app workloads."
|
|
1468
|
+
onNavigateToResource={onNavigateToResource}
|
|
1469
|
+
/>
|
|
1470
|
+
<ApplicationDependencyRow
|
|
1471
|
+
label="Network policy"
|
|
1472
|
+
refs={networkPolicies}
|
|
1473
|
+
totalCount={relationships?.networkPolicies}
|
|
1474
|
+
detail="Network policies selecting app workloads."
|
|
1475
|
+
onNavigateToResource={onNavigateToResource}
|
|
1476
|
+
/>
|
|
1477
|
+
</div>
|
|
1478
|
+
</ApplicationPanel>
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function ApplicationDependencyRow({
|
|
1483
|
+
label,
|
|
1484
|
+
refs,
|
|
1485
|
+
totalCount,
|
|
1486
|
+
detail,
|
|
1487
|
+
onNavigateToResource,
|
|
1488
|
+
}: {
|
|
1489
|
+
label: string;
|
|
1490
|
+
refs: ResourceRef[];
|
|
1491
|
+
totalCount?: number;
|
|
1492
|
+
detail: string;
|
|
1493
|
+
onNavigateToResource?: (resource: {
|
|
1494
|
+
kind: string;
|
|
1495
|
+
namespace: string;
|
|
1496
|
+
name: string;
|
|
1497
|
+
group?: string;
|
|
1498
|
+
}) => void;
|
|
1499
|
+
}) {
|
|
1500
|
+
if (refs.length === 0) return null;
|
|
1501
|
+
const count = Math.max(totalCount ?? refs.length, refs.length);
|
|
1502
|
+
return (
|
|
1503
|
+
<div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
|
|
1504
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
1505
|
+
<div className="text-sm font-semibold text-theme-text-primary">
|
|
1506
|
+
{label}
|
|
1507
|
+
</div>
|
|
1508
|
+
<span className={`${CHIP} ${CHIP_TONE.muted}`}>
|
|
1509
|
+
{pluralize(count, "resource")}
|
|
1510
|
+
</span>
|
|
1511
|
+
</div>
|
|
1512
|
+
<div className="mt-1 text-xs text-theme-text-tertiary">{detail}</div>
|
|
1513
|
+
<ApplicationRelatedNameGroup
|
|
1514
|
+
label={label}
|
|
1515
|
+
refs={refs}
|
|
1516
|
+
totalCount={count}
|
|
1517
|
+
onNavigateToResource={onNavigateToResource}
|
|
1518
|
+
hideLabel
|
|
1519
|
+
/>
|
|
1520
|
+
</div>
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function ApplicationRelatedNameGroup({
|
|
1525
|
+
label,
|
|
1526
|
+
refs,
|
|
1527
|
+
totalCount,
|
|
1528
|
+
onNavigateToResource,
|
|
1529
|
+
hideLabel,
|
|
1530
|
+
}: {
|
|
1531
|
+
label: string;
|
|
1532
|
+
refs?: ResourceRef[];
|
|
1533
|
+
totalCount?: number;
|
|
1534
|
+
onNavigateToResource?: (resource: {
|
|
1535
|
+
kind: string;
|
|
1536
|
+
namespace: string;
|
|
1537
|
+
name: string;
|
|
1538
|
+
group?: string;
|
|
1539
|
+
}) => void;
|
|
1540
|
+
hideLabel?: boolean;
|
|
1541
|
+
}) {
|
|
1542
|
+
if (!refs || refs.length === 0) return null;
|
|
1543
|
+
|
|
1544
|
+
const visible = refs.slice(0, 12);
|
|
1545
|
+
const count = Math.max(totalCount ?? refs.length, refs.length);
|
|
1546
|
+
const overflow = Math.max(0, count - visible.length);
|
|
1547
|
+
return (
|
|
1548
|
+
<div className={hideLabel ? "mt-2" : undefined}>
|
|
1549
|
+
{!hideLabel && (
|
|
1550
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">
|
|
1551
|
+
{label}
|
|
1552
|
+
{count > 1 ? ` (${count})` : ""}
|
|
1553
|
+
</div>
|
|
1554
|
+
)}
|
|
1555
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1556
|
+
{visible.map((ref) => (
|
|
1557
|
+
<ResourceRefBadge
|
|
1558
|
+
key={`${ref.group || ""}/${ref.kind}/${ref.namespace}/${ref.name}`}
|
|
1559
|
+
resourceRef={ref}
|
|
1560
|
+
onClick={
|
|
1561
|
+
onNavigateToResource
|
|
1562
|
+
? (clicked) =>
|
|
1563
|
+
onNavigateToResource(refToSelectedResource(clicked))
|
|
1564
|
+
: undefined
|
|
1565
|
+
}
|
|
1566
|
+
/>
|
|
1567
|
+
))}
|
|
1568
|
+
{overflow > 0 && (
|
|
1569
|
+
<span className={`${CHIP} ${CHIP_TONE.muted}`}>+{overflow} more</span>
|
|
1570
|
+
)}
|
|
1571
|
+
</div>
|
|
1572
|
+
</div>
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
type RelationshipGroup =
|
|
1577
|
+
| "service"
|
|
1578
|
+
| "ingress"
|
|
1579
|
+
| "route"
|
|
1580
|
+
| "config"
|
|
1581
|
+
| "scaler"
|
|
1582
|
+
| "storage"
|
|
1583
|
+
| "pdb"
|
|
1584
|
+
| "networkPolicy";
|
|
1585
|
+
|
|
1586
|
+
function relationshipRefs(
|
|
1587
|
+
relationships: AppRow["relationships"] | undefined,
|
|
1588
|
+
group: RelationshipGroup,
|
|
1589
|
+
): ResourceRef[] {
|
|
1590
|
+
if (!relationships) return [];
|
|
1591
|
+
switch (group) {
|
|
1592
|
+
case "service":
|
|
1593
|
+
return relationships.serviceRefs ?? [];
|
|
1594
|
+
case "ingress":
|
|
1595
|
+
return relationships.ingressRefs ?? [];
|
|
1596
|
+
case "route":
|
|
1597
|
+
return relationships.routeRefs ?? [];
|
|
1598
|
+
case "config":
|
|
1599
|
+
return relationships.configRefs ?? [];
|
|
1600
|
+
case "scaler":
|
|
1601
|
+
return relationships.scalerRefs ?? [];
|
|
1602
|
+
case "storage":
|
|
1603
|
+
return relationships.storageRefs ?? [];
|
|
1604
|
+
case "pdb":
|
|
1605
|
+
return relationships.pdbRefs ?? [];
|
|
1606
|
+
case "networkPolicy":
|
|
1607
|
+
return relationships.networkPolicyRefs ?? [];
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function ApplicationNow({
|
|
1612
|
+
issues,
|
|
1613
|
+
onSelectWorkload,
|
|
1614
|
+
}: {
|
|
1615
|
+
issues: AppIssue[];
|
|
1616
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
1617
|
+
}) {
|
|
1618
|
+
if (issues.length === 0) return null;
|
|
1619
|
+
|
|
1620
|
+
const top = issues[0];
|
|
1621
|
+
return (
|
|
1622
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
1623
|
+
<div className="flex flex-wrap items-start gap-3 border-b border-theme-border px-4 py-3">
|
|
1624
|
+
<span
|
|
1625
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${issueTone(top.severity)}`}
|
|
1626
|
+
>
|
|
1627
|
+
<AlertTriangle className="h-4 w-4" aria-hidden />
|
|
1628
|
+
</span>
|
|
1629
|
+
<div className="min-w-0 flex-1">
|
|
1630
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1631
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">
|
|
1632
|
+
{top.title}
|
|
1633
|
+
</h2>
|
|
1634
|
+
<span className={`${CHIP} ${issueTone(top.severity)}`}>
|
|
1635
|
+
{top.severity === "error" ? "Needs attention" : "Warning"}
|
|
1636
|
+
</span>
|
|
1637
|
+
</div>
|
|
1638
|
+
{top.detail && (
|
|
1639
|
+
<p className="mt-0.5 text-sm text-theme-text-secondary">
|
|
1640
|
+
{top.detail}
|
|
1641
|
+
</p>
|
|
1642
|
+
)}
|
|
1643
|
+
</div>
|
|
1644
|
+
{top.workload && (
|
|
1645
|
+
<button
|
|
1646
|
+
type="button"
|
|
1647
|
+
onClick={() => onSelectWorkload(top.workload!)}
|
|
1648
|
+
className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
|
|
1649
|
+
>
|
|
1650
|
+
Open workload
|
|
1651
|
+
</button>
|
|
1652
|
+
)}
|
|
1653
|
+
</div>
|
|
1654
|
+
{issues.length > 1 && (
|
|
1655
|
+
<div className="divide-y divide-theme-border px-4">
|
|
1656
|
+
{issues.slice(1, 5).map((issue) => (
|
|
1657
|
+
<div key={issue.key} className="flex items-start gap-3 py-3">
|
|
1658
|
+
<StatusDot
|
|
1659
|
+
tone={
|
|
1660
|
+
issue.severity === "error"
|
|
1661
|
+
? "unhealthy"
|
|
1662
|
+
: issue.severity === "warning"
|
|
1663
|
+
? "degraded"
|
|
1664
|
+
: "neutral"
|
|
1665
|
+
}
|
|
1666
|
+
className="mt-1"
|
|
1667
|
+
/>
|
|
1668
|
+
<div className="min-w-0 flex-1">
|
|
1669
|
+
<div className="truncate text-sm font-medium text-theme-text-primary">
|
|
1670
|
+
{issue.title}
|
|
1671
|
+
</div>
|
|
1672
|
+
{issue.detail && (
|
|
1673
|
+
<div className="truncate text-sm text-theme-text-tertiary">
|
|
1674
|
+
{issue.detail}
|
|
1675
|
+
</div>
|
|
1676
|
+
)}
|
|
1677
|
+
</div>
|
|
1678
|
+
{issue.workload && (
|
|
1679
|
+
<button
|
|
1680
|
+
type="button"
|
|
1681
|
+
onClick={() => onSelectWorkload(issue.workload!)}
|
|
1682
|
+
className="shrink-0 text-sm font-medium text-accent-text hover:underline"
|
|
1683
|
+
>
|
|
1684
|
+
Open
|
|
1685
|
+
</button>
|
|
1686
|
+
)}
|
|
1687
|
+
</div>
|
|
1688
|
+
))}
|
|
1689
|
+
</div>
|
|
1690
|
+
)}
|
|
1691
|
+
</section>
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function ApplicationBatchOverview({
|
|
1696
|
+
activity,
|
|
1697
|
+
}: {
|
|
1698
|
+
activity: AppBatchActivity[];
|
|
1699
|
+
}) {
|
|
1700
|
+
if (activity.length === 0) return null;
|
|
1701
|
+
|
|
1702
|
+
const stats = batchOverviewStats(activity);
|
|
1703
|
+
|
|
1704
|
+
return (
|
|
1705
|
+
<ApplicationPanel title="Batch activity">
|
|
1706
|
+
<div className="grid gap-4">
|
|
1707
|
+
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
1708
|
+
<ApplicationFact
|
|
1709
|
+
variant="bare"
|
|
1710
|
+
label="Latest run"
|
|
1711
|
+
value={stats.latestValue}
|
|
1712
|
+
detail={stats.latestDetail}
|
|
1713
|
+
/>
|
|
1714
|
+
<ApplicationFact
|
|
1715
|
+
variant="bare"
|
|
1716
|
+
label="Retained runs"
|
|
1717
|
+
value={stats.retainedValue}
|
|
1718
|
+
detail="Kubernetes-retained history"
|
|
1719
|
+
/>
|
|
1720
|
+
<ApplicationFact
|
|
1721
|
+
variant="bare"
|
|
1722
|
+
label="Active work"
|
|
1723
|
+
value={stats.activeValue}
|
|
1724
|
+
detail={stats.activeDetail}
|
|
1725
|
+
/>
|
|
1726
|
+
<ApplicationFact
|
|
1727
|
+
variant="bare"
|
|
1728
|
+
label="Schedules"
|
|
1729
|
+
value={stats.scheduleValue}
|
|
1730
|
+
detail={stats.scheduleDetail}
|
|
1731
|
+
monoValue={stats.scheduleMono}
|
|
1732
|
+
/>
|
|
1733
|
+
</div>
|
|
1734
|
+
</div>
|
|
1735
|
+
</ApplicationPanel>
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function latestBatchRuns(activity: AppBatchActivity[]): AppBatchActivity[] {
|
|
1740
|
+
return [...activity].sort(
|
|
1741
|
+
(a, b) =>
|
|
1742
|
+
latestRunTime(b) - latestRunTime(a) ||
|
|
1743
|
+
a.workload.name.localeCompare(b.workload.name),
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function latestRunTimestamp(item: AppBatchActivity): string | undefined {
|
|
1748
|
+
return (
|
|
1749
|
+
item.latestStartedAt ||
|
|
1750
|
+
item.latestFinishedAt ||
|
|
1751
|
+
item.lastScheduledAt ||
|
|
1752
|
+
item.lastSuccessfulAt
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function latestRunTime(item: AppBatchActivity): number {
|
|
1757
|
+
const value = latestRunTimestamp(item);
|
|
1758
|
+
if (!value) return 0;
|
|
1759
|
+
const t = Date.parse(value);
|
|
1760
|
+
return Number.isNaN(t) ? 0 : t;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function batchOverviewStats(activity: AppBatchActivity[]) {
|
|
1764
|
+
const latest = latestBatchRuns(activity)[0];
|
|
1765
|
+
const schedules = Array.from(
|
|
1766
|
+
new Set(
|
|
1767
|
+
activity
|
|
1768
|
+
.map((item) => item.schedule)
|
|
1769
|
+
.filter((s): s is string => Boolean(s)),
|
|
1770
|
+
),
|
|
1771
|
+
);
|
|
1772
|
+
const retained = activity.reduce((n, item) => n + item.retainedRuns, 0);
|
|
1773
|
+
const succeeded = activity.reduce(
|
|
1774
|
+
(n, item) => n + (item.workload.batch?.succeededRuns ?? 0),
|
|
1775
|
+
0,
|
|
1776
|
+
);
|
|
1777
|
+
const failed = activity.reduce((n, item) => n + item.failedRuns, 0);
|
|
1778
|
+
const active = activity.reduce((n, item) => n + item.activeRuns, 0);
|
|
1779
|
+
|
|
1780
|
+
return {
|
|
1781
|
+
latestValue: latest?.latestRunPhase || latest?.label || "None",
|
|
1782
|
+
latestDetail: latest
|
|
1783
|
+
? `${latest.latestRunName || latest.workload.name}${latestRunTimestamp(latest) ? ` · ${relativeTime(latestRunTimestamp(latest)!)} ` : ""}`.trim()
|
|
1784
|
+
: "No retained runs",
|
|
1785
|
+
retainedValue:
|
|
1786
|
+
retained > 0 ? `${succeeded} succeeded / ${failed} failed` : "0 retained",
|
|
1787
|
+
activeValue: active > 0 ? `${active} running` : "None",
|
|
1788
|
+
activeDetail:
|
|
1789
|
+
active > 0 ? activeWorkloadNames(activity) : "No active batch work",
|
|
1790
|
+
scheduleValue:
|
|
1791
|
+
schedules.length === 0
|
|
1792
|
+
? "None"
|
|
1793
|
+
: schedules.length === 1
|
|
1794
|
+
? schedules[0]
|
|
1795
|
+
: `${schedules.length} schedules`,
|
|
1796
|
+
scheduleDetail:
|
|
1797
|
+
schedules.length === 1 && latest?.lastSuccessfulAt
|
|
1798
|
+
? `last success ${relativeTime(latest.lastSuccessfulAt)}`
|
|
1799
|
+
: undefined,
|
|
1800
|
+
scheduleMono: schedules.length === 1,
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function activeWorkloadNames(activity: AppBatchActivity[]): string {
|
|
1805
|
+
const active = activity
|
|
1806
|
+
.filter((item) => item.activeRuns > 0)
|
|
1807
|
+
.map((item) => item.workload.name);
|
|
1808
|
+
if (active.length <= 2) return active.join(", ");
|
|
1809
|
+
return `${active.slice(0, 2).join(", ")} +${active.length - 2} more`;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function relativeTime(value: string): string {
|
|
1813
|
+
const t = Date.parse(value);
|
|
1814
|
+
if (Number.isNaN(t)) return value;
|
|
1815
|
+
const diff = Date.now() - t;
|
|
1816
|
+
if (diff < 60_000) return "just now";
|
|
1817
|
+
const minutes = Math.floor(diff / 60_000);
|
|
1818
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1819
|
+
const hours = Math.floor(minutes / 60);
|
|
1820
|
+
if (hours < 48) return `${hours}h ago`;
|
|
1821
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function ApplicationLatestHistory({
|
|
1825
|
+
history,
|
|
1826
|
+
sourceRef,
|
|
1827
|
+
showIncidentPreview,
|
|
1828
|
+
onSelectHistory,
|
|
1829
|
+
onOpenSource,
|
|
1830
|
+
}: {
|
|
1831
|
+
history?: AppHistory;
|
|
1832
|
+
sourceRef?: AppSourceRef;
|
|
1833
|
+
showIncidentPreview?: boolean;
|
|
1834
|
+
onSelectHistory: () => void;
|
|
1835
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
1836
|
+
}) {
|
|
1837
|
+
const summary = history?.summary;
|
|
1838
|
+
const showIncident = summary?.state === "incident" && showIncidentPreview;
|
|
1839
|
+
if (!summary || !showIncident) return null;
|
|
1840
|
+
const resolvedSource = sourceRef ?? history?.sourceRef;
|
|
1841
|
+
const tone = CHIP_TONE.amber;
|
|
1842
|
+
|
|
1843
|
+
return (
|
|
1844
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
1845
|
+
<div className="flex flex-wrap items-start gap-3">
|
|
1846
|
+
<span
|
|
1847
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${tone}`}
|
|
1848
|
+
>
|
|
1849
|
+
<AlertTriangle className="h-4 w-4" aria-hidden />
|
|
1850
|
+
</span>
|
|
1851
|
+
<div className="min-w-0 flex-1">
|
|
1852
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1853
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">
|
|
1854
|
+
{summary.title}
|
|
1855
|
+
</h2>
|
|
1856
|
+
<span className={`${CHIP} ${tone}`}>Latest incident</span>
|
|
1857
|
+
</div>
|
|
1858
|
+
{summary.detail && (
|
|
1859
|
+
<p className="mt-0.5 line-clamp-2 text-sm text-theme-text-secondary">
|
|
1860
|
+
{summary.detail}
|
|
1861
|
+
</p>
|
|
1862
|
+
)}
|
|
1863
|
+
{summary.timestamp && (
|
|
1864
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
1865
|
+
{formatAppEventTime(summary.timestamp)}
|
|
1866
|
+
</p>
|
|
1867
|
+
)}
|
|
1868
|
+
</div>
|
|
1869
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1870
|
+
{resolvedSource && !sourceRef && onOpenSource && (
|
|
1871
|
+
<button
|
|
1872
|
+
type="button"
|
|
1873
|
+
onClick={() => onOpenSource(resolvedSource)}
|
|
1874
|
+
className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
|
|
1875
|
+
>
|
|
1876
|
+
{sourceLinkLabel(resolvedSource)}
|
|
1877
|
+
</button>
|
|
1878
|
+
)}
|
|
1879
|
+
<button
|
|
1880
|
+
type="button"
|
|
1881
|
+
onClick={onSelectHistory}
|
|
1882
|
+
className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
|
|
1883
|
+
>
|
|
1884
|
+
View history
|
|
1885
|
+
</button>
|
|
1886
|
+
</div>
|
|
1887
|
+
</div>
|
|
1888
|
+
</section>
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function ApplicationLatestChangeFact({
|
|
1893
|
+
summary,
|
|
1894
|
+
sourceRef,
|
|
1895
|
+
onSelectHistory,
|
|
1896
|
+
onOpenSource,
|
|
1897
|
+
}: {
|
|
1898
|
+
summary: NonNullable<AppHistory["summary"]>;
|
|
1899
|
+
sourceRef?: AppSourceRef;
|
|
1900
|
+
onSelectHistory: () => void;
|
|
1901
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
1902
|
+
}) {
|
|
1903
|
+
return (
|
|
1904
|
+
<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">
|
|
1905
|
+
<div className="flex items-center justify-between gap-2">
|
|
1906
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1907
|
+
Latest change
|
|
1908
|
+
</div>
|
|
1909
|
+
<button
|
|
1910
|
+
type="button"
|
|
1911
|
+
onClick={onSelectHistory}
|
|
1912
|
+
className="shrink-0 text-xs font-medium text-accent-text hover:underline"
|
|
1913
|
+
>
|
|
1914
|
+
History
|
|
1915
|
+
</button>
|
|
1916
|
+
</div>
|
|
1917
|
+
<div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">
|
|
1918
|
+
{summary.title}
|
|
1919
|
+
</div>
|
|
1920
|
+
{summary.detail && (
|
|
1921
|
+
<div className="mt-0.5 truncate font-mono text-xs text-theme-text-tertiary">
|
|
1922
|
+
{summary.detail}
|
|
1923
|
+
</div>
|
|
1924
|
+
)}
|
|
1925
|
+
{sourceRef && onOpenSource && (
|
|
1926
|
+
<button
|
|
1927
|
+
type="button"
|
|
1928
|
+
onClick={() => onOpenSource(sourceRef)}
|
|
1929
|
+
className="mt-1 text-xs font-medium text-accent-text hover:underline"
|
|
1930
|
+
>
|
|
1931
|
+
{sourceLinkLabel(sourceRef)}
|
|
1932
|
+
</button>
|
|
1933
|
+
)}
|
|
1934
|
+
</div>
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function WorkloadsMatrix({
|
|
1939
|
+
workloads,
|
|
1940
|
+
onSelectWorkload,
|
|
1941
|
+
}: {
|
|
1942
|
+
workloads: AppWorkload[];
|
|
1943
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
1944
|
+
}) {
|
|
1945
|
+
if (workloads.length === 0) {
|
|
1946
|
+
return <EmptyState variant="inline" headline="No inspectable workloads." />;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
return (
|
|
1950
|
+
<div className="overflow-x-auto">
|
|
1951
|
+
<table className="w-full min-w-[760px] table-fixed text-sm">
|
|
1952
|
+
<thead>
|
|
1953
|
+
<tr className="border-b border-theme-border text-left text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1954
|
+
<th className="w-[34%] px-2 py-2 font-semibold">Workload</th>
|
|
1955
|
+
<th className="w-[13%] px-2 py-2 font-semibold">Kind</th>
|
|
1956
|
+
<th className="w-[12%] px-2 py-2 font-semibold">Class</th>
|
|
1957
|
+
<th className="w-[14%] px-2 py-2 font-semibold">Status</th>
|
|
1958
|
+
<th className="w-[18%] px-2 py-2 font-semibold">Runtime</th>
|
|
1959
|
+
<th className="w-[16%] px-2 py-2 font-semibold">Version</th>
|
|
1960
|
+
</tr>
|
|
1961
|
+
</thead>
|
|
1962
|
+
<tbody>
|
|
1963
|
+
{workloads.map((w) => {
|
|
1964
|
+
const status = workloadRuntimeStatus(w);
|
|
1965
|
+
const reason =
|
|
1966
|
+
w.reason === "Completed" && w.kind !== "Job"
|
|
1967
|
+
? undefined
|
|
1968
|
+
: w.reason;
|
|
1969
|
+
return (
|
|
1970
|
+
<tr
|
|
1971
|
+
key={workloadKey(w)}
|
|
1972
|
+
className="border-b border-theme-border last:border-b-0 hover:bg-theme-hover"
|
|
1973
|
+
>
|
|
1974
|
+
<td className="truncate px-2 py-2">
|
|
1975
|
+
<button
|
|
1976
|
+
type="button"
|
|
1977
|
+
onClick={() => onSelectWorkload(w)}
|
|
1978
|
+
className="truncate font-medium text-theme-text-primary hover:text-accent-text hover:underline"
|
|
1979
|
+
>
|
|
1980
|
+
{w.name}
|
|
1981
|
+
</button>
|
|
1982
|
+
{reason && (
|
|
1983
|
+
<div className="truncate text-xs text-theme-text-tertiary">
|
|
1984
|
+
{reason}
|
|
1985
|
+
</div>
|
|
1986
|
+
)}
|
|
1987
|
+
</td>
|
|
1988
|
+
<td className="px-2 py-2 text-theme-text-secondary">
|
|
1989
|
+
{w.kind}
|
|
1990
|
+
</td>
|
|
1991
|
+
<td className="px-2 py-2 text-theme-text-secondary">
|
|
1992
|
+
{workloadClassOf(w.workload_class)}
|
|
1993
|
+
</td>
|
|
1994
|
+
<td className="px-2 py-2">
|
|
1995
|
+
<span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
|
|
1996
|
+
<StatusDot tone={mapHealthToTone(status.health)} />
|
|
1997
|
+
{status.label}
|
|
1998
|
+
</span>
|
|
1999
|
+
</td>
|
|
2000
|
+
<td className="px-2 py-2 text-xs text-theme-text-secondary">
|
|
2001
|
+
{workloadRuntimeDetail(w)}
|
|
2002
|
+
</td>
|
|
2003
|
+
<td className="truncate px-2 py-2 font-mono text-xs text-theme-text-secondary">
|
|
2004
|
+
{w.version || w.appVersion || "-"}
|
|
2005
|
+
</td>
|
|
2006
|
+
</tr>
|
|
2007
|
+
);
|
|
2008
|
+
})}
|
|
2009
|
+
</tbody>
|
|
2010
|
+
</table>
|
|
2011
|
+
</div>
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function workloadRuntimeStatus(workload: AppWorkload): {
|
|
2016
|
+
label: string;
|
|
2017
|
+
health: AppHealth;
|
|
2018
|
+
} {
|
|
2019
|
+
const batch = workload.batch;
|
|
2020
|
+
if (!batch) {
|
|
2021
|
+
const health = healthOf(workload.health);
|
|
2022
|
+
return { label: HEALTH_META[health].label, health };
|
|
2023
|
+
}
|
|
2024
|
+
if ((batch.activeRuns ?? 0) > 0 || batch.latestRunPhase === "Running")
|
|
2025
|
+
return { label: "Running", health: "neutral" };
|
|
2026
|
+
if (batch.latestRunPhase === "Failed" || batch.latestRunPhase === "Error")
|
|
2027
|
+
return { label: "Failed", health: "unhealthy" };
|
|
2028
|
+
if (batch.latestRunPhase === "Succeeded")
|
|
2029
|
+
return { label: "Succeeded", health: "healthy" };
|
|
2030
|
+
if (batch.suspended) return { label: "Suspended", health: "neutral" };
|
|
2031
|
+
return { label: "Idle", health: "neutral" };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function workloadRuntimeDetail(workload: AppWorkload): string {
|
|
2035
|
+
const batch = workload.batch;
|
|
2036
|
+
if (!batch) return `${workload.ready}/${workload.desired} ready`;
|
|
2037
|
+
if ((batch.activeRuns ?? 0) > 0)
|
|
2038
|
+
return pluralize(batch.activeRuns ?? 0, "active run");
|
|
2039
|
+
const at =
|
|
2040
|
+
batch.latestStartedAt || batch.latestFinishedAt || batch.lastScheduledAt;
|
|
2041
|
+
if (batch.latestRunName)
|
|
2042
|
+
return `${midTruncate(batch.latestRunName, 28)}${at ? ` · ${relativeTime(at)}` : ""}`;
|
|
2043
|
+
if (batch.schedule) return `Schedule ${batch.schedule}`;
|
|
2044
|
+
return "No retained runs";
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function ApplicationTopology({
|
|
2048
|
+
topology,
|
|
2049
|
+
loading,
|
|
2050
|
+
available,
|
|
2051
|
+
workloads,
|
|
2052
|
+
colorByWorkload,
|
|
2053
|
+
focusNodeId,
|
|
2054
|
+
focusedOwnerId,
|
|
2055
|
+
onNodeClick,
|
|
2056
|
+
onNodeHover,
|
|
2057
|
+
onFocusWorkload,
|
|
2058
|
+
onSelectWorkload,
|
|
2059
|
+
deploymentLayer,
|
|
2060
|
+
deploymentSource,
|
|
2061
|
+
onOpenSource,
|
|
2062
|
+
onToggleReplicaSets,
|
|
2063
|
+
}: {
|
|
2064
|
+
topology: Topology | null;
|
|
2065
|
+
loading?: boolean;
|
|
2066
|
+
available: boolean;
|
|
2067
|
+
workloads: AppWorkload[];
|
|
2068
|
+
colorByWorkload: Map<string, number> | null;
|
|
2069
|
+
focusNodeId?: string;
|
|
2070
|
+
focusedOwnerId: WorkloadFocus;
|
|
2071
|
+
onNodeClick: (node: TopologyNode) => void;
|
|
2072
|
+
onNodeHover: (node: TopologyNode | null) => void;
|
|
2073
|
+
onFocusWorkload: (owner: WorkloadFocus) => void;
|
|
2074
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
2075
|
+
deploymentLayer: DeploymentTopologyLayer | null;
|
|
2076
|
+
deploymentSource?: AppSourceRef;
|
|
2077
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
2078
|
+
onToggleReplicaSets: (ownerID: string) => void;
|
|
2079
|
+
}) {
|
|
2080
|
+
const hasSharedOrUnscopedNodes = useMemo(
|
|
2081
|
+
() =>
|
|
2082
|
+
topology?.nodes.some((node) => {
|
|
2083
|
+
if (node.data?.deploymentMembership === "source-only") return false;
|
|
2084
|
+
const stamp = ownershipOf(node.data);
|
|
2085
|
+
return !stamp.ownerWorkloadId && stamp.focusWorkloadIds.length !== 1;
|
|
2086
|
+
}) ?? false,
|
|
2087
|
+
[topology],
|
|
2088
|
+
);
|
|
2089
|
+
|
|
2090
|
+
return (
|
|
2091
|
+
<div className="relative min-h-0 flex-1 bg-theme-surface">
|
|
2092
|
+
{available && topology ? (
|
|
2093
|
+
<>
|
|
2094
|
+
<TopologyGraph
|
|
2095
|
+
topology={topology}
|
|
2096
|
+
viewMode="resources"
|
|
2097
|
+
groupingMode="namespace"
|
|
2098
|
+
hideGroupHeader
|
|
2099
|
+
onNodeClick={onNodeClick}
|
|
2100
|
+
showExportButton={false}
|
|
2101
|
+
focusNodeId={focusNodeId}
|
|
2102
|
+
focusedOwnerId={focusedOwnerId}
|
|
2103
|
+
onNodeHover={onNodeHover}
|
|
2104
|
+
onToggleReplicaSets={onToggleReplicaSets}
|
|
2105
|
+
fitViewPadding={{
|
|
2106
|
+
top: 0.08,
|
|
2107
|
+
right: 0.05,
|
|
2108
|
+
bottom: 0.08,
|
|
2109
|
+
left: "360px",
|
|
2110
|
+
}}
|
|
2111
|
+
/>
|
|
2112
|
+
<TopologyWorkloadLegend
|
|
2113
|
+
workloads={workloads}
|
|
2114
|
+
colorByWorkload={colorByWorkload}
|
|
2115
|
+
focusedOwnerId={focusedOwnerId}
|
|
2116
|
+
showSharedOrUnscoped={hasSharedOrUnscopedNodes}
|
|
2117
|
+
onFocus={onFocusWorkload}
|
|
2118
|
+
onSelectWorkload={onSelectWorkload}
|
|
2119
|
+
deploymentLayer={deploymentLayer}
|
|
2120
|
+
deploymentSource={deploymentSource}
|
|
2121
|
+
onOpenSource={onOpenSource}
|
|
2122
|
+
/>
|
|
2123
|
+
</>
|
|
2124
|
+
) : loading ? (
|
|
2125
|
+
<PaneLoader label="Loading topology..." className="absolute inset-0" />
|
|
2126
|
+
) : (
|
|
2127
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
2128
|
+
<EmptyState
|
|
2129
|
+
headline="No topology available"
|
|
2130
|
+
body="Radar could not build an application topology for this app."
|
|
2131
|
+
/>
|
|
2132
|
+
</div>
|
|
2133
|
+
)}
|
|
2134
|
+
</div>
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function ApplicationHistoryView({
|
|
2139
|
+
history,
|
|
2140
|
+
sourceLoading,
|
|
2141
|
+
items: suppliedItems,
|
|
2142
|
+
runtimeLoading,
|
|
2143
|
+
runtimeError,
|
|
2144
|
+
mode,
|
|
2145
|
+
range,
|
|
2146
|
+
rangeOptions,
|
|
2147
|
+
coverageRecordCount,
|
|
2148
|
+
runtimeLimited,
|
|
2149
|
+
workloads,
|
|
2150
|
+
onSelectWorkload,
|
|
2151
|
+
onNavigateToResource,
|
|
2152
|
+
onRangeChange,
|
|
2153
|
+
onOpenTimeline,
|
|
2154
|
+
onOpenSource,
|
|
2155
|
+
}: {
|
|
2156
|
+
history?: AppHistory;
|
|
2157
|
+
sourceLoading?: boolean;
|
|
2158
|
+
items?: ApplicationHistoryItem[];
|
|
2159
|
+
runtimeLoading?: boolean;
|
|
2160
|
+
runtimeError?: boolean;
|
|
2161
|
+
mode?: "local" | "retained";
|
|
2162
|
+
range?: ApplicationHistoryRange;
|
|
2163
|
+
rangeOptions?: Array<{ value: ApplicationHistoryRange; label: string }>;
|
|
2164
|
+
coverageRecordCount?: number;
|
|
2165
|
+
runtimeLimited?: boolean;
|
|
2166
|
+
workloads: AppWorkload[];
|
|
2167
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
2168
|
+
onNavigateToResource?: (resource: ResourceRef) => void;
|
|
2169
|
+
onRangeChange?: (range: ApplicationHistoryRange) => void;
|
|
2170
|
+
onOpenTimeline?: (timestamp?: string) => void;
|
|
2171
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
2172
|
+
}) {
|
|
2173
|
+
const [category, setCategory] = useState<ApplicationHistoryCategory | "all">(
|
|
2174
|
+
"all",
|
|
2175
|
+
);
|
|
2176
|
+
const items = useMemo(
|
|
2177
|
+
() => suppliedItems ?? buildApplicationHistoryItems(history, []),
|
|
2178
|
+
[history, suppliedItems],
|
|
2179
|
+
);
|
|
2180
|
+
const availableCategories = useMemo(
|
|
2181
|
+
() => Array.from(new Set(items.map((item) => item.category))),
|
|
2182
|
+
[items],
|
|
2183
|
+
);
|
|
2184
|
+
useEffect(() => {
|
|
2185
|
+
if (category !== "all" && !availableCategories.includes(category))
|
|
2186
|
+
setCategory("all");
|
|
2187
|
+
}, [availableCategories, category]);
|
|
2188
|
+
const visibleItems = useMemo(
|
|
2189
|
+
() =>
|
|
2190
|
+
category === "all"
|
|
2191
|
+
? items
|
|
2192
|
+
: items.filter((item) => item.category === category),
|
|
2193
|
+
[category, items],
|
|
2194
|
+
);
|
|
2195
|
+
const groups = useMemo(() => {
|
|
2196
|
+
const byDate = new Map<string, ApplicationHistoryItem[]>();
|
|
2197
|
+
for (const item of visibleItems) {
|
|
2198
|
+
const label = applicationHistoryDateLabel(item.timestamp);
|
|
2199
|
+
byDate.set(label, [...(byDate.get(label) ?? []), item]);
|
|
2200
|
+
}
|
|
2201
|
+
return [...byDate.entries()];
|
|
2202
|
+
}, [visibleItems]);
|
|
2203
|
+
const loading = sourceLoading || runtimeLoading;
|
|
2204
|
+
const empty = !loading && !runtimeError && items.length === 0;
|
|
2205
|
+
const hasRuntimeItems = items.some((item) => item.category !== "deployment");
|
|
2206
|
+
|
|
2207
|
+
return (
|
|
2208
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
2209
|
+
<div className="w-full max-w-[2400px] p-4 sm:p-6">
|
|
2210
|
+
<section className="overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
2211
|
+
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-theme-border px-4 py-3">
|
|
2212
|
+
<div className="min-w-0">
|
|
2213
|
+
<div className="flex items-center gap-2">
|
|
2214
|
+
<Clock3 className="h-4 w-4 text-theme-text-secondary" />
|
|
2215
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">
|
|
2216
|
+
Application history
|
|
2217
|
+
</h2>
|
|
2218
|
+
{loading && items.length > 0 && (
|
|
2219
|
+
<RefreshCw
|
|
2220
|
+
className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary"
|
|
2221
|
+
aria-label="Refreshing history"
|
|
2222
|
+
/>
|
|
2223
|
+
)}
|
|
2224
|
+
</div>
|
|
2225
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
2226
|
+
Deployments, configuration changes, and problems attributed to
|
|
2227
|
+
this application.
|
|
2228
|
+
</p>
|
|
2229
|
+
</div>
|
|
2230
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
2231
|
+
{range &&
|
|
2232
|
+
rangeOptions &&
|
|
2233
|
+
rangeOptions.length > 1 &&
|
|
2234
|
+
onRangeChange && (
|
|
2235
|
+
<select
|
|
2236
|
+
aria-label="Application history range"
|
|
2237
|
+
value={range}
|
|
2238
|
+
onChange={(event) =>
|
|
2239
|
+
onRangeChange(
|
|
2240
|
+
event.target.value as ApplicationHistoryRange,
|
|
2241
|
+
)
|
|
2242
|
+
}
|
|
2243
|
+
className="h-8 rounded-md border border-theme-border bg-theme-base px-2 text-xs text-theme-text-primary"
|
|
2244
|
+
>
|
|
2245
|
+
{rangeOptions.map((option) => (
|
|
2246
|
+
<option key={option.value} value={option.value}>
|
|
2247
|
+
{option.label}
|
|
2248
|
+
</option>
|
|
2249
|
+
))}
|
|
2250
|
+
</select>
|
|
2251
|
+
)}
|
|
2252
|
+
{onOpenTimeline && (
|
|
2253
|
+
<button
|
|
2254
|
+
type="button"
|
|
2255
|
+
onClick={() => onOpenTimeline(visibleItems[0]?.timestamp)}
|
|
2256
|
+
className="inline-flex h-8 items-center gap-1.5 px-2 text-xs font-medium text-accent-text hover:underline"
|
|
2257
|
+
>
|
|
2258
|
+
Open full Timeline
|
|
2259
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
2260
|
+
</button>
|
|
2261
|
+
)}
|
|
2262
|
+
</div>
|
|
2263
|
+
</div>
|
|
2264
|
+
|
|
2265
|
+
{availableCategories.length > 1 && (
|
|
2266
|
+
<div
|
|
2267
|
+
className="flex flex-wrap gap-1 border-b border-theme-border px-4 py-2"
|
|
2268
|
+
role="group"
|
|
2269
|
+
aria-label="Application history categories"
|
|
2270
|
+
>
|
|
2271
|
+
{(["all", "deployment", "change", "problem"] as const).map(
|
|
2272
|
+
(value) => {
|
|
2273
|
+
if (value !== "all" && !availableCategories.includes(value))
|
|
2274
|
+
return null;
|
|
2275
|
+
return (
|
|
2276
|
+
<button
|
|
2277
|
+
key={value}
|
|
2278
|
+
type="button"
|
|
2279
|
+
aria-pressed={category === value}
|
|
2280
|
+
onClick={() => setCategory(value)}
|
|
2281
|
+
className={clsx(
|
|
2282
|
+
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
|
2283
|
+
category === value
|
|
2284
|
+
? "bg-accent-subtle text-accent-text"
|
|
2285
|
+
: "text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary",
|
|
2286
|
+
)}
|
|
2287
|
+
>
|
|
2288
|
+
{applicationHistoryCategoryLabel(value)}
|
|
2289
|
+
</button>
|
|
2290
|
+
);
|
|
2291
|
+
},
|
|
2292
|
+
)}
|
|
2293
|
+
</div>
|
|
2294
|
+
)}
|
|
2295
|
+
|
|
2296
|
+
{(runtimeError || (history?.partialSources?.length ?? 0) > 0) && (
|
|
2297
|
+
<div
|
|
2298
|
+
className={clsx(
|
|
2299
|
+
"border-b border-theme-border bg-theme-elevated px-4 py-2 text-xs",
|
|
2300
|
+
HEALTH_META.degraded.text,
|
|
2301
|
+
)}
|
|
2302
|
+
>
|
|
2303
|
+
{runtimeError && (
|
|
2304
|
+
<div>
|
|
2305
|
+
{hasRuntimeItems
|
|
2306
|
+
? "Application changes could not be refreshed. Previously loaded changes may be stale."
|
|
2307
|
+
: "Application changes could not be loaded."}
|
|
2308
|
+
{!hasRuntimeItems &&
|
|
2309
|
+
items.some((item) => item.category === "deployment") &&
|
|
2310
|
+
" Deployment-source history is still shown."}
|
|
2311
|
+
</div>
|
|
2312
|
+
)}
|
|
2313
|
+
{history?.partialSources?.map((message, index) => (
|
|
2314
|
+
<div key={`${message}-${index}`}>{message}</div>
|
|
2315
|
+
))}
|
|
2316
|
+
</div>
|
|
2317
|
+
)}
|
|
2318
|
+
|
|
2319
|
+
{loading && items.length === 0 && (
|
|
2320
|
+
<div className="flex items-center gap-2 px-4 py-8 text-sm text-theme-text-tertiary">
|
|
2321
|
+
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
2322
|
+
Loading application history...
|
|
2323
|
+
</div>
|
|
2324
|
+
)}
|
|
2325
|
+
|
|
2326
|
+
{groups.map(([label, dateItems]) => (
|
|
2327
|
+
<div key={label}>
|
|
2328
|
+
<div className="border-b border-theme-border bg-theme-base px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2329
|
+
{label}
|
|
2330
|
+
</div>
|
|
2331
|
+
<div className="divide-y divide-theme-border">
|
|
2332
|
+
{dateItems.map((item) => (
|
|
2333
|
+
<ApplicationHistoryLine
|
|
2334
|
+
key={item.id}
|
|
2335
|
+
item={item}
|
|
2336
|
+
workloads={workloads}
|
|
2337
|
+
onSelectWorkload={onSelectWorkload}
|
|
2338
|
+
onNavigateToResource={onNavigateToResource}
|
|
2339
|
+
onOpenSource={onOpenSource}
|
|
2340
|
+
/>
|
|
2341
|
+
))}
|
|
2342
|
+
</div>
|
|
2343
|
+
</div>
|
|
2344
|
+
))}
|
|
2345
|
+
|
|
2346
|
+
{empty && (
|
|
2347
|
+
<div className="px-4 py-8">
|
|
2348
|
+
<EmptyState
|
|
2349
|
+
variant="inline"
|
|
2350
|
+
headline="No application history in this range."
|
|
2351
|
+
body="Overview still shows the current application state."
|
|
2352
|
+
/>
|
|
2353
|
+
</div>
|
|
2354
|
+
)}
|
|
2355
|
+
|
|
2356
|
+
<div className="space-y-1 border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">
|
|
2357
|
+
{mode === "local" && (
|
|
2358
|
+
<div>
|
|
2359
|
+
Changes and problems reflect events retained by this Radar
|
|
2360
|
+
instance. Deployment-source history may cover a different
|
|
2361
|
+
period.
|
|
2362
|
+
</div>
|
|
2363
|
+
)}
|
|
2364
|
+
{mode === "retained" && (coverageRecordCount ?? 0) > 0 && (
|
|
2365
|
+
<div>
|
|
2366
|
+
Recording coverage is incomplete for part of this range.
|
|
2367
|
+
</div>
|
|
2368
|
+
)}
|
|
2369
|
+
{mode === "retained" && runtimeLimited && (
|
|
2370
|
+
<div>
|
|
2371
|
+
Showing the 10,000 most recent events in these namespaces. Older
|
|
2372
|
+
application activity in this range may not be included.
|
|
2373
|
+
</div>
|
|
2374
|
+
)}
|
|
2375
|
+
</div>
|
|
2376
|
+
</section>
|
|
2377
|
+
</div>
|
|
2378
|
+
</div>
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function ApplicationHistoryLine({
|
|
2383
|
+
item,
|
|
2384
|
+
workloads,
|
|
2385
|
+
onSelectWorkload,
|
|
2386
|
+
onNavigateToResource,
|
|
2387
|
+
onOpenSource,
|
|
2388
|
+
}: {
|
|
2389
|
+
item: ApplicationHistoryItem;
|
|
2390
|
+
workloads: AppWorkload[];
|
|
2391
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
2392
|
+
onNavigateToResource?: (resource: ResourceRef) => void;
|
|
2393
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
2394
|
+
}) {
|
|
2395
|
+
const workload = item.resource
|
|
2396
|
+
? workloads.find(
|
|
2397
|
+
(candidate) =>
|
|
2398
|
+
candidate.kind.toLowerCase() === item.resource!.kind.toLowerCase() &&
|
|
2399
|
+
candidate.namespace === item.resource!.namespace &&
|
|
2400
|
+
candidate.name === item.resource!.name,
|
|
2401
|
+
)
|
|
2402
|
+
: undefined;
|
|
2403
|
+
const Icon =
|
|
2404
|
+
item.category === "deployment"
|
|
2405
|
+
? GitCommit
|
|
2406
|
+
: item.category === "problem"
|
|
2407
|
+
? AlertTriangle
|
|
2408
|
+
: item.resource
|
|
2409
|
+
? getTopologyIcon(item.resource.kind)
|
|
2410
|
+
: Activity;
|
|
2411
|
+
const action =
|
|
2412
|
+
item.sourceRef && onOpenSource
|
|
2413
|
+
? {
|
|
2414
|
+
label: sourceLinkLabel(item.sourceRef),
|
|
2415
|
+
run: () => onOpenSource(item.sourceRef!),
|
|
2416
|
+
}
|
|
2417
|
+
: workload
|
|
2418
|
+
? { label: "Open workload", run: () => onSelectWorkload(workload) }
|
|
2419
|
+
: item.resource && onNavigateToResource
|
|
2420
|
+
? {
|
|
2421
|
+
label: "Open resource",
|
|
2422
|
+
run: () => onNavigateToResource(item.resource!),
|
|
2423
|
+
}
|
|
2424
|
+
: null;
|
|
2425
|
+
const revisionAlreadyInTitle = Boolean(
|
|
2426
|
+
item.revision &&
|
|
2427
|
+
item.title.toLowerCase().startsWith("helm revision ") &&
|
|
2428
|
+
item.title.endsWith(item.revision),
|
|
2429
|
+
);
|
|
2430
|
+
|
|
2431
|
+
return (
|
|
2432
|
+
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-3 px-4 py-3">
|
|
2433
|
+
<div
|
|
2434
|
+
className={clsx(
|
|
2435
|
+
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md border",
|
|
2436
|
+
item.category === "problem"
|
|
2437
|
+
? clsx(
|
|
2438
|
+
"border-theme-border bg-theme-elevated",
|
|
2439
|
+
HEALTH_META.degraded.text,
|
|
2440
|
+
)
|
|
2441
|
+
: "border-theme-border bg-theme-elevated text-theme-text-secondary",
|
|
2442
|
+
)}
|
|
2443
|
+
>
|
|
2444
|
+
<Icon className="h-4 w-4" />
|
|
2445
|
+
</div>
|
|
2446
|
+
<div className="min-w-0">
|
|
2447
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
2448
|
+
<span className="font-medium text-theme-text-primary">
|
|
2449
|
+
{item.title}
|
|
2450
|
+
</span>
|
|
2451
|
+
{item.status && (
|
|
2452
|
+
<span className={`${CHIP} ${historyStatusTone(item.status)}`}>
|
|
2453
|
+
{item.status}
|
|
2454
|
+
</span>
|
|
2455
|
+
)}
|
|
2456
|
+
{item.count && item.count > 1 && (
|
|
2457
|
+
<span className={`${CHIP} ${CHIP_TONE.muted}`}>{item.count}x</span>
|
|
2458
|
+
)}
|
|
2459
|
+
</div>
|
|
2460
|
+
{item.revision && !revisionAlreadyInTitle && (
|
|
2461
|
+
<div className="truncate font-mono text-sm text-theme-text-secondary">
|
|
2462
|
+
{item.revision}
|
|
2463
|
+
</div>
|
|
2464
|
+
)}
|
|
2465
|
+
{item.detail && (
|
|
2466
|
+
<div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
|
|
2467
|
+
{item.detail}
|
|
2468
|
+
</div>
|
|
2469
|
+
)}
|
|
2470
|
+
{item.resource && (
|
|
2471
|
+
<button
|
|
2472
|
+
type="button"
|
|
2473
|
+
onClick={action?.run}
|
|
2474
|
+
disabled={!action}
|
|
2475
|
+
className={clsx(
|
|
2476
|
+
"mt-1 truncate text-xs",
|
|
2477
|
+
action
|
|
2478
|
+
? "text-accent-text hover:underline"
|
|
2479
|
+
: "text-theme-text-tertiary",
|
|
2480
|
+
)}
|
|
2481
|
+
>
|
|
2482
|
+
{getKindLabel(item.resource.kind)} · {item.resource.namespace}/
|
|
2483
|
+
{item.resource.name}
|
|
2484
|
+
</button>
|
|
2485
|
+
)}
|
|
2486
|
+
</div>
|
|
2487
|
+
<div className="text-right text-xs text-theme-text-tertiary">
|
|
2488
|
+
<div>{applicationHistoryTimeLabel(item.timestamp)}</div>
|
|
2489
|
+
{item.initiatedBy && (
|
|
2490
|
+
<div className="mt-1 max-w-40 truncate">{item.initiatedBy}</div>
|
|
2491
|
+
)}
|
|
2492
|
+
{action && item.sourceRef && (
|
|
2493
|
+
<button
|
|
2494
|
+
type="button"
|
|
2495
|
+
onClick={action.run}
|
|
2496
|
+
className="mt-1 font-medium text-accent-text hover:underline"
|
|
2497
|
+
>
|
|
2498
|
+
{action.label}
|
|
2499
|
+
</button>
|
|
2500
|
+
)}
|
|
2501
|
+
</div>
|
|
2502
|
+
</div>
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
function applicationHistoryCategoryLabel(
|
|
2507
|
+
category: ApplicationHistoryCategory | "all",
|
|
2508
|
+
): string {
|
|
2509
|
+
if (category === "all") return "All";
|
|
2510
|
+
if (category === "deployment") return "Deployments";
|
|
2511
|
+
if (category === "problem") return "Problems";
|
|
2512
|
+
return "Changes";
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function applicationHistoryDateLabel(timestamp: string): string {
|
|
2516
|
+
const date = new Date(timestamp);
|
|
2517
|
+
const today = new Date();
|
|
2518
|
+
const yesterday = new Date(today);
|
|
2519
|
+
yesterday.setDate(today.getDate() - 1);
|
|
2520
|
+
if (date.toDateString() === today.toDateString()) return "Today";
|
|
2521
|
+
if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
|
|
2522
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
2523
|
+
month: "short",
|
|
2524
|
+
day: "numeric",
|
|
2525
|
+
year: date.getFullYear() === today.getFullYear() ? undefined : "numeric",
|
|
2526
|
+
}).format(date);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
function applicationHistoryTimeLabel(timestamp: string): string {
|
|
2530
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
2531
|
+
hour: "2-digit",
|
|
2532
|
+
minute: "2-digit",
|
|
2533
|
+
}).format(new Date(timestamp));
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
function buildAppIssues(
|
|
2537
|
+
workloads: AppWorkload[],
|
|
2538
|
+
events: NonNullable<AppRow["events"]>,
|
|
2539
|
+
): AppIssue[] {
|
|
2540
|
+
const issues: AppIssue[] = [];
|
|
2541
|
+
const issueByWorkload = new Map<string, AppIssue>();
|
|
2542
|
+
|
|
2543
|
+
for (const workload of workloads) {
|
|
2544
|
+
const health = healthOf(workload.health);
|
|
2545
|
+
const notReady = workload.desired > 0 && workload.ready < workload.desired;
|
|
2546
|
+
const hasRestarts = (workload.restarts ?? 0) > 0;
|
|
2547
|
+
const hasReason = Boolean(workload.reason);
|
|
2548
|
+
const hasHealthProblem = health === "degraded" || health === "unhealthy";
|
|
2549
|
+
if (!hasHealthProblem && !notReady && !hasRestarts && !hasReason) continue;
|
|
2550
|
+
|
|
2551
|
+
const severity: AppIssueSeverity =
|
|
2552
|
+
health === "unhealthy" || (workload.desired > 0 && workload.ready === 0)
|
|
2553
|
+
? "error"
|
|
2554
|
+
: "warning";
|
|
2555
|
+
const detail = [
|
|
2556
|
+
notReady ? `${workload.ready}/${workload.desired} ready` : undefined,
|
|
2557
|
+
hasRestarts ? pluralize(workload.restarts, "restart") : undefined,
|
|
2558
|
+
workload.reason,
|
|
2559
|
+
]
|
|
2560
|
+
.filter(Boolean)
|
|
2561
|
+
.join(" · ");
|
|
2562
|
+
const issue: AppIssue = {
|
|
2563
|
+
key: `workload:${workloadKey(workload)}`,
|
|
2564
|
+
severity,
|
|
2565
|
+
title: `${workload.name} ${health === "unhealthy" ? "is down" : health === "degraded" || notReady ? "is degraded" : "needs attention"}`,
|
|
2566
|
+
detail,
|
|
2567
|
+
workload,
|
|
2568
|
+
};
|
|
2569
|
+
issueByWorkload.set(workloadKey(workload), issue);
|
|
2570
|
+
issues.push(issue);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
for (const event of events) {
|
|
2574
|
+
const workload = directWorkloadForEvent(event, workloads);
|
|
2575
|
+
if (workload) {
|
|
2576
|
+
const existing = issueByWorkload.get(workloadKey(workload));
|
|
2577
|
+
if (existing) {
|
|
2578
|
+
existing.detail = [existing.detail, event.reason]
|
|
2579
|
+
.filter(Boolean)
|
|
2580
|
+
.join(" · ");
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
issues.push({
|
|
2584
|
+
key: `event:${event.object}:${event.reason}`,
|
|
2585
|
+
severity: "warning",
|
|
2586
|
+
title: `${event.reason} on ${workload.name}`,
|
|
2587
|
+
detail: event.message,
|
|
2588
|
+
workload,
|
|
2589
|
+
});
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
issues.push({
|
|
2593
|
+
key: `event:${event.object}:${event.reason}`,
|
|
2594
|
+
severity: "warning",
|
|
2595
|
+
title: `${event.reason} on ${event.object}`,
|
|
2596
|
+
detail: event.message,
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
const rank: Record<AppIssueSeverity, number> = {
|
|
2601
|
+
error: 0,
|
|
2602
|
+
warning: 1,
|
|
2603
|
+
info: 2,
|
|
2604
|
+
};
|
|
2605
|
+
return issues.sort(
|
|
2606
|
+
(a, b) =>
|
|
2607
|
+
rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title),
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function directWorkloadForEvent(
|
|
2612
|
+
event: NonNullable<AppRow["events"]>[number],
|
|
2613
|
+
workloads: AppWorkload[],
|
|
2614
|
+
): AppWorkload | undefined {
|
|
2615
|
+
const parsed = parseEventObject(event.object);
|
|
2616
|
+
if (!parsed) return undefined;
|
|
2617
|
+
const matches = workloads.filter(
|
|
2618
|
+
(w) =>
|
|
2619
|
+
w.kind.toLowerCase() === parsed.kind.toLowerCase() &&
|
|
2620
|
+
w.name === parsed.name,
|
|
2621
|
+
);
|
|
2622
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
function parseEventObject(
|
|
2626
|
+
object: string,
|
|
2627
|
+
): { kind: string; name: string } | null {
|
|
2628
|
+
const slash = object.indexOf("/");
|
|
2629
|
+
if (slash <= 0 || slash === object.length - 1) return null;
|
|
2630
|
+
return { kind: object.slice(0, slash), name: object.slice(slash + 1) };
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function issueTone(severity: AppIssueSeverity): string {
|
|
2634
|
+
if (severity === "error") return CHIP_TONE.rose;
|
|
2635
|
+
if (severity === "warning") return CHIP_TONE.amber;
|
|
2636
|
+
return CHIP_TONE.blue;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function historyStatusTone(status: string): string {
|
|
2640
|
+
const s = status.toLowerCase();
|
|
2641
|
+
if (s.includes("fail") || s.includes("error")) return CHIP_TONE.rose;
|
|
2642
|
+
if (s.includes("running") || s.includes("pending") || s.includes("progress"))
|
|
2643
|
+
return CHIP_TONE.amber;
|
|
2644
|
+
if (s.includes("succeed") || s.includes("deployed") || s.includes("ready"))
|
|
2645
|
+
return CHIP_TONE.emerald;
|
|
2646
|
+
return CHIP_TONE.muted;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function sourceLinkLabel(source: AppSourceRef): string {
|
|
2650
|
+
return `View ${sourceObjectLabel(source)}`;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
function sourceObjectLabel(source: AppSourceRef): string {
|
|
2654
|
+
if (source.type === "helm") return "Helm release";
|
|
2655
|
+
if (source.tool === "argocd" || source.kind.toLowerCase() === "application")
|
|
2656
|
+
return "Argo CD application";
|
|
2657
|
+
if (source.kind.toLowerCase() === "helmrelease") return "Flux HelmRelease";
|
|
2658
|
+
if (source.kind.toLowerCase() === "kustomization")
|
|
2659
|
+
return "Flux Kustomization";
|
|
2660
|
+
return "GitOps source";
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function sourceInventoryLabel(source: AppSourceRef): string {
|
|
2664
|
+
if (source.type === "helm") return "Helm";
|
|
2665
|
+
if (source.tool === "argocd" || source.kind.toLowerCase() === "application")
|
|
2666
|
+
return "Argo CD";
|
|
2667
|
+
if (source.tool === "fluxcd") return "Flux";
|
|
2668
|
+
return "GitOps";
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function SourceDeliveryStatus({
|
|
2672
|
+
status,
|
|
2673
|
+
}: {
|
|
2674
|
+
status: NonNullable<AppRow["sourceStatus"]>;
|
|
2675
|
+
}) {
|
|
2676
|
+
const values = [
|
|
2677
|
+
status.sync
|
|
2678
|
+
? { label: status.sync, tone: sourceSyncHealth(status.sync) }
|
|
2679
|
+
: null,
|
|
2680
|
+
status.health
|
|
2681
|
+
? { label: status.health, tone: sourceReportedHealth(status.health) }
|
|
2682
|
+
: null,
|
|
2683
|
+
].filter(
|
|
2684
|
+
(value): value is { label: string; tone: AppHealth } => value !== null,
|
|
2685
|
+
);
|
|
2686
|
+
|
|
2687
|
+
return (
|
|
2688
|
+
<span className="inline-flex min-w-0 items-center gap-2">
|
|
2689
|
+
{values.map((value, index) => (
|
|
2690
|
+
<span
|
|
2691
|
+
key={`${value.label}-${index}`}
|
|
2692
|
+
className="inline-flex min-w-0 items-center gap-1.5"
|
|
2693
|
+
>
|
|
2694
|
+
{index > 0 && (
|
|
2695
|
+
<span className="text-theme-text-tertiary" aria-hidden>
|
|
2696
|
+
·
|
|
2697
|
+
</span>
|
|
2698
|
+
)}
|
|
2699
|
+
<StatusDot tone={mapHealthToTone(value.tone)} />
|
|
2700
|
+
<span className="truncate">{value.label}</span>
|
|
2701
|
+
</span>
|
|
2702
|
+
))}
|
|
2703
|
+
</span>
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
function formatAppEventTime(value?: string): string {
|
|
2708
|
+
if (!value || value.startsWith("0001-01-01T00:00:00")) return "";
|
|
2709
|
+
return value;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function ApplicationPanel({
|
|
2713
|
+
title,
|
|
2714
|
+
children,
|
|
2715
|
+
}: {
|
|
2716
|
+
title: ReactNode;
|
|
2717
|
+
children: ReactNode;
|
|
2718
|
+
}) {
|
|
2719
|
+
return (
|
|
2720
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
|
|
2721
|
+
<h2 className="mb-3 text-sm font-semibold text-theme-text-primary">
|
|
2722
|
+
{title}
|
|
2723
|
+
</h2>
|
|
2724
|
+
{children}
|
|
2725
|
+
</section>
|
|
2726
|
+
);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
function ApplicationFact({
|
|
2730
|
+
label,
|
|
2731
|
+
value,
|
|
2732
|
+
detail,
|
|
2733
|
+
monoValue,
|
|
2734
|
+
monoDetail,
|
|
2735
|
+
variant = "card",
|
|
2736
|
+
}: {
|
|
2737
|
+
label: string;
|
|
2738
|
+
value: ReactNode;
|
|
2739
|
+
detail?: string;
|
|
2740
|
+
monoValue?: boolean;
|
|
2741
|
+
monoDetail?: boolean;
|
|
2742
|
+
variant?: "card" | "row" | "bare";
|
|
2743
|
+
}) {
|
|
2744
|
+
if (variant === "row") {
|
|
2745
|
+
return (
|
|
2746
|
+
<div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
|
|
2747
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2748
|
+
{label}
|
|
2749
|
+
</div>
|
|
2750
|
+
<div
|
|
2751
|
+
className={clsx(
|
|
2752
|
+
"mt-1 truncate text-sm font-semibold text-theme-text-primary",
|
|
2753
|
+
monoValue && "font-mono",
|
|
2754
|
+
)}
|
|
2755
|
+
>
|
|
2756
|
+
{value}
|
|
2757
|
+
</div>
|
|
2758
|
+
{detail && (
|
|
2759
|
+
<div
|
|
2760
|
+
className={clsx(
|
|
2761
|
+
"mt-0.5 truncate text-xs text-theme-text-tertiary",
|
|
2762
|
+
monoDetail && "font-mono",
|
|
2763
|
+
)}
|
|
2764
|
+
>
|
|
2765
|
+
{detail}
|
|
2766
|
+
</div>
|
|
2767
|
+
)}
|
|
2768
|
+
</div>
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
if (variant === "bare") {
|
|
2773
|
+
return (
|
|
2774
|
+
<div className="min-w-0">
|
|
2775
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2776
|
+
{label}
|
|
2777
|
+
</div>
|
|
2778
|
+
<div
|
|
2779
|
+
className={clsx(
|
|
2780
|
+
"mt-1 truncate text-sm font-semibold text-theme-text-primary",
|
|
2781
|
+
monoValue && "font-mono",
|
|
2782
|
+
)}
|
|
2783
|
+
>
|
|
2784
|
+
{value}
|
|
2785
|
+
</div>
|
|
2786
|
+
{detail && (
|
|
2787
|
+
<div
|
|
2788
|
+
className={clsx(
|
|
2789
|
+
"mt-0.5 truncate text-xs text-theme-text-tertiary",
|
|
2790
|
+
monoDetail && "font-mono",
|
|
2791
|
+
)}
|
|
2792
|
+
>
|
|
2793
|
+
{detail}
|
|
2794
|
+
</div>
|
|
2795
|
+
)}
|
|
2796
|
+
</div>
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
return (
|
|
2801
|
+
<div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
2802
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2803
|
+
{label}
|
|
2804
|
+
</div>
|
|
2805
|
+
<div
|
|
2806
|
+
className={clsx(
|
|
2807
|
+
"mt-1 truncate text-sm font-semibold text-theme-text-primary",
|
|
2808
|
+
monoValue && "font-mono",
|
|
2809
|
+
)}
|
|
2810
|
+
>
|
|
2811
|
+
{value}
|
|
2812
|
+
</div>
|
|
2813
|
+
{detail && (
|
|
2814
|
+
<div
|
|
2815
|
+
className={clsx(
|
|
2816
|
+
"mt-0.5 truncate text-xs text-theme-text-tertiary",
|
|
2817
|
+
monoDetail && "font-mono",
|
|
2818
|
+
)}
|
|
2819
|
+
>
|
|
2820
|
+
{detail}
|
|
2821
|
+
</div>
|
|
2822
|
+
)}
|
|
2823
|
+
</div>
|
|
2824
|
+
);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function ApplicationScopeSelector({
|
|
2828
|
+
workloads,
|
|
2829
|
+
selectedWorkload,
|
|
2830
|
+
onSelect,
|
|
2831
|
+
onFocus,
|
|
2832
|
+
}: {
|
|
2833
|
+
workloads: AppWorkload[];
|
|
2834
|
+
selectedWorkload: AppWorkload | null;
|
|
2835
|
+
onSelect: (key: string | null) => void;
|
|
2836
|
+
onFocus: (owner: WorkloadFocus) => void;
|
|
2837
|
+
}) {
|
|
2838
|
+
const [open, setOpen] = useState(false);
|
|
2839
|
+
const [query, setQuery] = useState("");
|
|
2840
|
+
const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
|
|
2841
|
+
const selectedKey = selectedWorkload ? workloadKey(selectedWorkload) : null;
|
|
2842
|
+
const filteredWorkloads = useMemo(() => {
|
|
2843
|
+
const q = query.trim().toLowerCase();
|
|
2844
|
+
if (!q) return workloads;
|
|
2845
|
+
return workloads.filter((w) =>
|
|
2846
|
+
`${w.kind} ${w.namespace} ${w.name}`.toLowerCase().includes(q),
|
|
2847
|
+
);
|
|
2848
|
+
}, [query, workloads]);
|
|
2849
|
+
|
|
2850
|
+
if (workloads.length <= 1) {
|
|
2851
|
+
return selectedWorkload ? (
|
|
2852
|
+
<StaticWorkloadScope workload={selectedWorkload} />
|
|
2853
|
+
) : (
|
|
2854
|
+
<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">
|
|
2855
|
+
<Boxes
|
|
2856
|
+
className="h-4 w-4 shrink-0 text-theme-text-secondary"
|
|
2857
|
+
aria-hidden
|
|
2858
|
+
/>
|
|
2859
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
2860
|
+
Application
|
|
2861
|
+
</span>
|
|
2862
|
+
</span>
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
return (
|
|
2867
|
+
<div ref={rootRef} className="relative min-w-0">
|
|
2868
|
+
<button
|
|
2869
|
+
type="button"
|
|
2870
|
+
onClick={() => setOpen((o) => !o)}
|
|
2871
|
+
aria-haspopup="listbox"
|
|
2872
|
+
aria-expanded={open}
|
|
2873
|
+
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"
|
|
2874
|
+
>
|
|
2875
|
+
{selectedWorkload ? (
|
|
2876
|
+
<WorkloadKindIcon workload={selectedWorkload} />
|
|
2877
|
+
) : (
|
|
2878
|
+
<Boxes
|
|
2879
|
+
className="h-4 w-4 shrink-0 text-theme-text-secondary"
|
|
2880
|
+
aria-hidden
|
|
2881
|
+
/>
|
|
2882
|
+
)}
|
|
2883
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
2884
|
+
{selectedWorkload ? selectedWorkload.name : "Application"}
|
|
2885
|
+
</span>
|
|
2886
|
+
{selectedWorkload && (
|
|
2887
|
+
<span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
|
|
2888
|
+
{selectedWorkload.kind}
|
|
2889
|
+
</span>
|
|
2890
|
+
)}
|
|
2891
|
+
<ChevronDown
|
|
2892
|
+
className={clsx(
|
|
2893
|
+
"h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform",
|
|
2894
|
+
open && "rotate-180",
|
|
2895
|
+
)}
|
|
2896
|
+
aria-hidden
|
|
2897
|
+
/>
|
|
2898
|
+
</button>
|
|
2899
|
+
{open && (
|
|
2900
|
+
<div
|
|
2901
|
+
role="listbox"
|
|
2902
|
+
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"
|
|
2903
|
+
onMouseLeave={() => onFocus(null)}
|
|
2904
|
+
>
|
|
2905
|
+
<div className="border-b border-theme-border p-1">
|
|
2906
|
+
<ScopeOption
|
|
2907
|
+
active={selectedKey === null}
|
|
2908
|
+
icon={
|
|
2909
|
+
<Boxes
|
|
2910
|
+
className="h-4 w-4 text-theme-text-secondary"
|
|
2911
|
+
aria-hidden
|
|
2912
|
+
/>
|
|
2913
|
+
}
|
|
2914
|
+
title="Application"
|
|
2915
|
+
subtitle="App scope"
|
|
2916
|
+
onClick={() => {
|
|
2917
|
+
setOpen(false);
|
|
2918
|
+
onSelect(null);
|
|
2919
|
+
}}
|
|
2920
|
+
onMouseEnter={() => onFocus(null)}
|
|
2921
|
+
/>
|
|
2922
|
+
</div>
|
|
2923
|
+
<div className="border-b border-theme-border px-2 py-1.5">
|
|
2924
|
+
<div className="flex items-center justify-between gap-3">
|
|
2925
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
2926
|
+
Workloads
|
|
2927
|
+
</span>
|
|
2928
|
+
<span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
|
|
2929
|
+
{workloads.length}
|
|
2930
|
+
</span>
|
|
2931
|
+
</div>
|
|
2932
|
+
{workloads.length > 8 && (
|
|
2933
|
+
<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">
|
|
2934
|
+
<Search
|
|
2935
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
2936
|
+
aria-hidden
|
|
2937
|
+
/>
|
|
2938
|
+
<input
|
|
2939
|
+
value={query}
|
|
2940
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2941
|
+
placeholder="Filter workloads..."
|
|
2942
|
+
className="min-w-0 flex-1 bg-transparent text-sm text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
|
|
2943
|
+
/>
|
|
2944
|
+
</label>
|
|
2945
|
+
)}
|
|
2946
|
+
</div>
|
|
2947
|
+
<div className="max-h-80 overflow-y-auto p-1">
|
|
2948
|
+
{filteredWorkloads.length === 0 ? (
|
|
2949
|
+
<div className="px-2 py-4 text-center text-sm text-theme-text-tertiary">
|
|
2950
|
+
No workloads match.
|
|
2951
|
+
</div>
|
|
2952
|
+
) : (
|
|
2953
|
+
filteredWorkloads.map((w) => {
|
|
2954
|
+
const key = workloadKey(w);
|
|
2955
|
+
return (
|
|
2956
|
+
<ScopeOption
|
|
2957
|
+
key={key}
|
|
2958
|
+
active={selectedKey === key}
|
|
2959
|
+
icon={<WorkloadKindIcon workload={w} />}
|
|
2960
|
+
title={w.name}
|
|
2961
|
+
subtitle={`${w.kind} · ${w.ready}/${w.desired} ready${w.reason ? ` · ${w.reason}` : ""}`}
|
|
2962
|
+
onClick={() => {
|
|
2963
|
+
setOpen(false);
|
|
2964
|
+
onSelect(key);
|
|
2965
|
+
}}
|
|
2966
|
+
onMouseEnter={() => onFocus(key)}
|
|
2967
|
+
/>
|
|
2968
|
+
);
|
|
2969
|
+
})
|
|
2970
|
+
)}
|
|
2971
|
+
</div>
|
|
2972
|
+
</div>
|
|
2973
|
+
)}
|
|
2974
|
+
</div>
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
function TopologyWorkloadLegend({
|
|
2979
|
+
workloads,
|
|
2980
|
+
colorByWorkload,
|
|
2981
|
+
focusedOwnerId,
|
|
2982
|
+
showSharedOrUnscoped,
|
|
2983
|
+
onFocus,
|
|
2984
|
+
onSelectWorkload,
|
|
2985
|
+
deploymentLayer,
|
|
2986
|
+
deploymentSource,
|
|
2987
|
+
onOpenSource,
|
|
2988
|
+
}: {
|
|
2989
|
+
workloads: AppWorkload[];
|
|
2990
|
+
colorByWorkload: Map<string, number> | null;
|
|
2991
|
+
focusedOwnerId: WorkloadFocus;
|
|
2992
|
+
showSharedOrUnscoped: boolean;
|
|
2993
|
+
onFocus: (owner: WorkloadFocus) => void;
|
|
2994
|
+
onSelectWorkload: (workload: AppWorkload) => void;
|
|
2995
|
+
deploymentLayer: DeploymentTopologyLayer | null;
|
|
2996
|
+
deploymentSource?: AppSourceRef;
|
|
2997
|
+
onOpenSource?: (source: AppSourceRef) => void;
|
|
2998
|
+
}) {
|
|
2999
|
+
const managedOnlyCount = deploymentLayer?.managedOnly.length ?? 0;
|
|
3000
|
+
const runtimeOnlyCount = deploymentLayer?.runtimeOnlyCount ?? 0;
|
|
3001
|
+
const showSourceDifferences =
|
|
3002
|
+
!!deploymentSource && managedOnlyCount + runtimeOnlyCount > 0;
|
|
3003
|
+
const sourceLabel = deploymentSource
|
|
3004
|
+
? sourceInventoryLabel(deploymentSource)
|
|
3005
|
+
: "";
|
|
3006
|
+
|
|
3007
|
+
return (
|
|
3008
|
+
<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">
|
|
3009
|
+
<div className="flex items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-3 py-2">
|
|
3010
|
+
<span className="text-xs font-semibold text-theme-text-primary">
|
|
3011
|
+
Workloads
|
|
3012
|
+
</span>
|
|
3013
|
+
<span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
|
|
3014
|
+
{workloads.length}
|
|
3015
|
+
</span>
|
|
3016
|
+
</div>
|
|
3017
|
+
<div
|
|
3018
|
+
className="max-h-72 overflow-y-auto p-1"
|
|
3019
|
+
onMouseLeave={() => onFocus(null)}
|
|
3020
|
+
>
|
|
3021
|
+
{workloads.map((w) => {
|
|
3022
|
+
const key = workloadKey(w);
|
|
3023
|
+
const idx = colorByWorkload?.get(key);
|
|
3024
|
+
const hue = idx != null ? workloadHue(idx) : null;
|
|
3025
|
+
return (
|
|
3026
|
+
<ScopeOption
|
|
3027
|
+
key={key}
|
|
3028
|
+
compact
|
|
3029
|
+
active={focusedOwnerId === key}
|
|
3030
|
+
accentColor={hue?.swatch}
|
|
3031
|
+
icon={<WorkloadKindIcon workload={w} compact />}
|
|
3032
|
+
title={w.name}
|
|
3033
|
+
subtitle={`${w.kind} · ${w.ready}/${w.desired}`}
|
|
3034
|
+
onClick={() => onSelectWorkload(w)}
|
|
3035
|
+
onMouseEnter={() => onFocus(key)}
|
|
3036
|
+
/>
|
|
3037
|
+
);
|
|
3038
|
+
})}
|
|
3039
|
+
{showSharedOrUnscoped && (
|
|
3040
|
+
<ScopeOption
|
|
3041
|
+
compact
|
|
3042
|
+
muted
|
|
3043
|
+
active={focusedOwnerId === NEUTRAL_OWNER}
|
|
3044
|
+
icon={<SharedScopeMarker />}
|
|
3045
|
+
title="Shared / unscoped"
|
|
3046
|
+
onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
|
|
3047
|
+
/>
|
|
3048
|
+
)}
|
|
3049
|
+
</div>
|
|
3050
|
+
{showSourceDifferences && deploymentSource && (
|
|
3051
|
+
<div className="border-t border-theme-border px-3 py-2.5">
|
|
3052
|
+
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
3053
|
+
Source differences
|
|
3054
|
+
</div>
|
|
3055
|
+
<div className="space-y-1">
|
|
3056
|
+
{managedOnlyCount > 0 && (
|
|
3057
|
+
<div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
|
|
3058
|
+
<DeploymentSourceLogo source={deploymentSource} />
|
|
3059
|
+
<span className="min-w-0 flex-1">
|
|
3060
|
+
<span className="block">
|
|
3061
|
+
{managedOnlyCount} only in {sourceLabel}
|
|
3062
|
+
</span>
|
|
3063
|
+
{deploymentLayer?.managedOnlySummary && (
|
|
3064
|
+
<span className="mt-0.5 block text-[10px] text-theme-text-tertiary">
|
|
3065
|
+
{deploymentLayer.managedOnlySummary}
|
|
3066
|
+
</span>
|
|
3067
|
+
)}
|
|
3068
|
+
</span>
|
|
3069
|
+
{onOpenSource && (
|
|
3070
|
+
<Tooltip content={`Open ${sourceLabel} view`} delay={150}>
|
|
3071
|
+
<button
|
|
3072
|
+
type="button"
|
|
3073
|
+
onClick={() => onOpenSource(deploymentSource)}
|
|
3074
|
+
className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
3075
|
+
>
|
|
3076
|
+
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
3077
|
+
</button>
|
|
3078
|
+
</Tooltip>
|
|
3079
|
+
)}
|
|
3080
|
+
</div>
|
|
3081
|
+
)}
|
|
3082
|
+
{runtimeOnlyCount > 0 && (
|
|
3083
|
+
<div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
|
|
3084
|
+
<Radio
|
|
3085
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
3086
|
+
aria-hidden
|
|
3087
|
+
/>
|
|
3088
|
+
<span>
|
|
3089
|
+
{pluralize(runtimeOnlyCount, "runtime-only resource")}
|
|
3090
|
+
</span>
|
|
3091
|
+
</div>
|
|
3092
|
+
)}
|
|
3093
|
+
</div>
|
|
3094
|
+
</div>
|
|
3095
|
+
)}
|
|
3096
|
+
</div>
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
function DeploymentSourceLogo({ source }: { source: AppSourceRef }) {
|
|
3101
|
+
const sourceLabel = sourceInventoryLabel(source);
|
|
3102
|
+
const logo =
|
|
3103
|
+
sourceLabel === "Argo CD"
|
|
3104
|
+
? argoCdLogo
|
|
3105
|
+
: sourceLabel === "Flux"
|
|
3106
|
+
? fluxLogo
|
|
3107
|
+
: null;
|
|
3108
|
+
if (!logo)
|
|
3109
|
+
return (
|
|
3110
|
+
<Layers
|
|
3111
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
3112
|
+
aria-hidden
|
|
3113
|
+
/>
|
|
3114
|
+
);
|
|
3115
|
+
return <img src={logo} alt="" className="h-4 w-4 shrink-0 object-contain" />;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
function StaticWorkloadScope({ workload }: { workload: AppWorkload }) {
|
|
3119
|
+
return (
|
|
3120
|
+
<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">
|
|
3121
|
+
<WorkloadKindIcon workload={workload} />
|
|
3122
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
3123
|
+
{workload.name}
|
|
3124
|
+
</span>
|
|
3125
|
+
<span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
|
|
3126
|
+
{workload.kind}
|
|
3127
|
+
</span>
|
|
3128
|
+
</span>
|
|
3129
|
+
);
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function WorkloadKindIcon({
|
|
3133
|
+
workload,
|
|
3134
|
+
compact,
|
|
3135
|
+
}: {
|
|
3136
|
+
workload: AppWorkload;
|
|
3137
|
+
compact?: boolean;
|
|
3138
|
+
}) {
|
|
3139
|
+
const KindIcon = getTopologyIcon(workload.kind);
|
|
3140
|
+
return (
|
|
3141
|
+
<span
|
|
3142
|
+
className={clsx(
|
|
3143
|
+
"relative flex shrink-0 items-center justify-center",
|
|
3144
|
+
compact ? "h-4 w-4" : "h-5 w-5",
|
|
3145
|
+
)}
|
|
3146
|
+
aria-hidden
|
|
3147
|
+
>
|
|
3148
|
+
<KindIcon
|
|
3149
|
+
className={clsx(
|
|
3150
|
+
"text-theme-text-secondary",
|
|
3151
|
+
compact ? "h-3.5 w-3.5" : "h-4 w-4",
|
|
3152
|
+
)}
|
|
3153
|
+
/>
|
|
3154
|
+
<StatusDot
|
|
3155
|
+
tone={mapHealthToTone(healthOf(workload.health))}
|
|
3156
|
+
size="xs"
|
|
3157
|
+
className="absolute -bottom-0.5 -right-0.5 ring-2 ring-theme-base"
|
|
3158
|
+
/>
|
|
3159
|
+
</span>
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function SharedScopeMarker() {
|
|
3164
|
+
return (
|
|
3165
|
+
<span className="flex w-5 shrink-0 items-center gap-1" aria-hidden>
|
|
3166
|
+
<span className="block h-4 w-1 rounded-full bg-theme-border" />
|
|
3167
|
+
<span className="h-1.5 w-1.5 rounded-full bg-theme-border" />
|
|
3168
|
+
</span>
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
function ScopeOption({
|
|
3173
|
+
active,
|
|
3174
|
+
muted,
|
|
3175
|
+
compact,
|
|
3176
|
+
onClick,
|
|
3177
|
+
onMouseEnter,
|
|
3178
|
+
icon,
|
|
3179
|
+
title,
|
|
3180
|
+
subtitle,
|
|
3181
|
+
accentColor,
|
|
3182
|
+
}: {
|
|
3183
|
+
active?: boolean;
|
|
3184
|
+
muted?: boolean;
|
|
3185
|
+
compact?: boolean;
|
|
3186
|
+
onClick?: () => void;
|
|
3187
|
+
onMouseEnter?: () => void;
|
|
3188
|
+
icon: ReactNode;
|
|
3189
|
+
title: string;
|
|
3190
|
+
subtitle?: string;
|
|
3191
|
+
accentColor?: string;
|
|
3192
|
+
}) {
|
|
3193
|
+
const className = clsx(
|
|
3194
|
+
"relative flex w-full items-center gap-2 rounded-md text-left transition-colors",
|
|
3195
|
+
compact ? "px-2 py-1.5" : "px-2 py-2",
|
|
3196
|
+
active ? "selection selection-ring" : onClick && "hover:bg-theme-hover",
|
|
3197
|
+
!onClick && "cursor-default",
|
|
3198
|
+
);
|
|
3199
|
+
const inner = (
|
|
3200
|
+
<>
|
|
3201
|
+
{accentColor && (
|
|
3202
|
+
<span
|
|
3203
|
+
className="absolute inset-y-2 left-0 w-1 rounded-full"
|
|
3204
|
+
style={{ background: accentColor }}
|
|
3205
|
+
aria-hidden
|
|
3206
|
+
/>
|
|
3207
|
+
)}
|
|
3208
|
+
{icon}
|
|
3209
|
+
<span className="min-w-0 flex-1">
|
|
3210
|
+
<span
|
|
3211
|
+
className={clsx(
|
|
3212
|
+
"block truncate text-sm",
|
|
3213
|
+
muted
|
|
3214
|
+
? "text-theme-text-tertiary"
|
|
3215
|
+
: "font-medium text-theme-text-primary",
|
|
3216
|
+
)}
|
|
3217
|
+
>
|
|
3218
|
+
{title}
|
|
3219
|
+
</span>
|
|
3220
|
+
{subtitle && (
|
|
3221
|
+
<span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
3222
|
+
{subtitle}
|
|
3223
|
+
</span>
|
|
3224
|
+
)}
|
|
3225
|
+
</span>
|
|
3226
|
+
</>
|
|
3227
|
+
);
|
|
3228
|
+
return onClick ? (
|
|
3229
|
+
<button
|
|
3230
|
+
type="button"
|
|
3231
|
+
onClick={onClick}
|
|
3232
|
+
onMouseEnter={onMouseEnter}
|
|
3233
|
+
className={className}
|
|
3234
|
+
>
|
|
3235
|
+
{inner}
|
|
3236
|
+
</button>
|
|
3237
|
+
) : (
|
|
3238
|
+
<div onMouseEnter={onMouseEnter} className={className}>
|
|
3239
|
+
{inner}
|
|
3240
|
+
</div>
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
function useDismissablePopover<T extends HTMLElement>(
|
|
3245
|
+
open: boolean,
|
|
3246
|
+
setOpen: (open: boolean) => void,
|
|
3247
|
+
) {
|
|
3248
|
+
const rootRef = useRef<T>(null);
|
|
3249
|
+
useEffect(() => {
|
|
3250
|
+
if (!open) return;
|
|
3251
|
+
const onDown = (e: MouseEvent) => {
|
|
3252
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node))
|
|
3253
|
+
setOpen(false);
|
|
3254
|
+
};
|
|
3255
|
+
const onKey = (e: KeyboardEvent) => {
|
|
3256
|
+
if (e.key === "Escape") setOpen(false);
|
|
3257
|
+
};
|
|
3258
|
+
document.addEventListener("mousedown", onDown);
|
|
3259
|
+
document.addEventListener("keydown", onKey);
|
|
3260
|
+
return () => {
|
|
3261
|
+
document.removeEventListener("mousedown", onDown);
|
|
3262
|
+
document.removeEventListener("keydown", onKey);
|
|
3263
|
+
};
|
|
3264
|
+
}, [open, setOpen]);
|
|
3265
|
+
return rootRef;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
function renderRuntime(
|
|
3269
|
+
workload: SelectedAppWorkload | undefined,
|
|
3270
|
+
renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
|
|
3271
|
+
): ReactNode {
|
|
3272
|
+
if (!workload) {
|
|
3273
|
+
return (
|
|
3274
|
+
<div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
|
|
3275
|
+
This application has no inspectable workloads.
|
|
3276
|
+
</div>
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
return (
|
|
3280
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
3281
|
+
<div key={workloadKey(workload)} className="min-h-0 flex-1">
|
|
3282
|
+
{renderWorkload(workload)}
|
|
3283
|
+
</div>
|
|
3284
|
+
</div>
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
function restartWarning(
|
|
3289
|
+
workloads: AppWorkload[],
|
|
3290
|
+
): { restarts: number; reason?: string; workload: string } | null {
|
|
3291
|
+
let worst: { restarts: number; reason?: string; workload: string } | null =
|
|
3292
|
+
null;
|
|
3293
|
+
for (const w of workloads) {
|
|
3294
|
+
const r = w.restarts ?? 0;
|
|
3295
|
+
if (r > 0 && (!worst || r > worst.restarts)) {
|
|
3296
|
+
worst = {
|
|
3297
|
+
restarts: r,
|
|
3298
|
+
reason: w.reason,
|
|
3299
|
+
workload: `${w.kind}/${w.name}`,
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return worst;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// EnvSwitcher — the Environment fact's interactive form when one app runs in
|
|
3307
|
+
// several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
|
|
3308
|
+
// ladder). More: a picker popover with the full ladder-ordered list — same
|
|
3309
|
+
// affordance at 5 envs or 100. Always ends with the evidence chip
|
|
3310
|
+
// (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
|
|
3311
|
+
// the amber lag chip.
|
|
3312
|
+
const MAX_INLINE_ENVS = 4;
|
|
3313
|
+
|
|
3314
|
+
function EnvSwitcher({
|
|
3315
|
+
identityKey,
|
|
3316
|
+
instances,
|
|
3317
|
+
activeKey,
|
|
3318
|
+
onSwitch,
|
|
3319
|
+
}: {
|
|
3320
|
+
identityKey: string;
|
|
3321
|
+
instances: AppIdentityInstance[];
|
|
3322
|
+
activeKey: string;
|
|
3323
|
+
onSwitch?: (appKey: string) => void;
|
|
3324
|
+
}) {
|
|
3325
|
+
const [open, setOpen] = useState(false);
|
|
3326
|
+
const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
|
|
3327
|
+
|
|
3328
|
+
const envInstances = useMemo(
|
|
3329
|
+
() => collapseIdentityInstances(instances, activeKey),
|
|
3330
|
+
[instances, activeKey],
|
|
3331
|
+
);
|
|
3332
|
+
const lag = appGroupLagMessage(envInstances);
|
|
3333
|
+
const active =
|
|
3334
|
+
envInstances.find((i) => i.appKey === activeKey) ??
|
|
3335
|
+
instances.find((i) => i.appKey === activeKey);
|
|
3336
|
+
const envCounts = useMemo(() => {
|
|
3337
|
+
const counts = new Map<string, number>();
|
|
3338
|
+
for (const inst of instances)
|
|
3339
|
+
counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1);
|
|
3340
|
+
return counts;
|
|
3341
|
+
}, [instances]);
|
|
3342
|
+
const evidenceChip = (
|
|
3343
|
+
<Tooltip
|
|
3344
|
+
content={
|
|
3345
|
+
<AppIdentityTooltip
|
|
3346
|
+
identityKey={identityKey}
|
|
3347
|
+
members={instances.map((i) => ({
|
|
3348
|
+
name: i.name,
|
|
3349
|
+
env: i.env,
|
|
3350
|
+
confidence: i.confidence,
|
|
3351
|
+
evidence: i.evidence,
|
|
3352
|
+
}))}
|
|
3353
|
+
/>
|
|
3354
|
+
}
|
|
3355
|
+
delay={150}
|
|
3356
|
+
>
|
|
3357
|
+
<span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
|
|
3358
|
+
<Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
|
|
3359
|
+
</span>
|
|
3360
|
+
</Tooltip>
|
|
3361
|
+
);
|
|
3362
|
+
const lagChip = lag && (
|
|
3363
|
+
<span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
|
|
3364
|
+
);
|
|
3365
|
+
|
|
3366
|
+
if (envInstances.length <= MAX_INLINE_ENVS) {
|
|
3367
|
+
return (
|
|
3368
|
+
<span className="flex flex-wrap items-center gap-1">
|
|
3369
|
+
{instances.map((inst) => {
|
|
3370
|
+
const isActive = inst.appKey === activeKey;
|
|
3371
|
+
const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1;
|
|
3372
|
+
return (
|
|
3373
|
+
<Tooltip
|
|
3374
|
+
key={inst.appKey}
|
|
3375
|
+
content={`${inst.name}${inst.version ? ` · ${inst.version}` : ""}`}
|
|
3376
|
+
delay={150}
|
|
3377
|
+
>
|
|
3378
|
+
<button
|
|
3379
|
+
type="button"
|
|
3380
|
+
disabled={isActive}
|
|
3381
|
+
onClick={() => !isActive && onSwitch?.(inst.appKey)}
|
|
3382
|
+
className={clsx(
|
|
3383
|
+
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors",
|
|
3384
|
+
isActive
|
|
3385
|
+
? "selection selection-ring font-medium"
|
|
3386
|
+
: "bg-theme-surface ring-theme-border hover:bg-theme-hover",
|
|
3387
|
+
)}
|
|
3388
|
+
>
|
|
3389
|
+
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
3390
|
+
{inst.env}
|
|
3391
|
+
{duplicateEnv ? (
|
|
3392
|
+
<span className="max-w-24 truncate text-theme-text-tertiary">
|
|
3393
|
+
{inst.name}
|
|
3394
|
+
</span>
|
|
3395
|
+
) : null}
|
|
3396
|
+
</button>
|
|
3397
|
+
</Tooltip>
|
|
3398
|
+
);
|
|
3399
|
+
})}
|
|
3400
|
+
{evidenceChip}
|
|
3401
|
+
{lagChip}
|
|
3402
|
+
</span>
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
return (
|
|
3407
|
+
<div ref={rootRef} className="relative flex items-center gap-1">
|
|
3408
|
+
<button
|
|
3409
|
+
type="button"
|
|
3410
|
+
onClick={() => setOpen((o) => !o)}
|
|
3411
|
+
aria-haspopup="listbox"
|
|
3412
|
+
aria-expanded={open}
|
|
3413
|
+
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"
|
|
3414
|
+
>
|
|
3415
|
+
{active && <StatusDot tone={mapHealthToTone(active.health)} />}
|
|
3416
|
+
<span className="font-medium">{active?.env ?? "—"}</span>
|
|
3417
|
+
<span className="text-theme-text-tertiary">
|
|
3418
|
+
· {envInstances.length} environments
|
|
3419
|
+
</span>
|
|
3420
|
+
<ChevronDown
|
|
3421
|
+
className={clsx(
|
|
3422
|
+
"h-3 w-3 text-theme-text-tertiary transition-transform",
|
|
3423
|
+
open && "rotate-180",
|
|
3424
|
+
)}
|
|
3425
|
+
aria-hidden
|
|
3426
|
+
/>
|
|
3427
|
+
</button>
|
|
3428
|
+
{evidenceChip}
|
|
3429
|
+
{lagChip}
|
|
3430
|
+
{open && (
|
|
3431
|
+
<div
|
|
3432
|
+
role="listbox"
|
|
3433
|
+
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"
|
|
3434
|
+
>
|
|
3435
|
+
{instances.map((inst) => {
|
|
3436
|
+
const isActive = inst.appKey === activeKey;
|
|
3437
|
+
return (
|
|
3438
|
+
<button
|
|
3439
|
+
key={inst.appKey}
|
|
3440
|
+
type="button"
|
|
3441
|
+
role="option"
|
|
3442
|
+
aria-selected={isActive}
|
|
3443
|
+
onClick={() => {
|
|
3444
|
+
setOpen(false);
|
|
3445
|
+
if (!isActive) onSwitch?.(inst.appKey);
|
|
3446
|
+
}}
|
|
3447
|
+
className={clsx(
|
|
3448
|
+
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs",
|
|
3449
|
+
isActive
|
|
3450
|
+
? "selection selection-ring"
|
|
3451
|
+
: "hover:bg-theme-hover",
|
|
3452
|
+
)}
|
|
3453
|
+
>
|
|
3454
|
+
<StatusDot tone={mapHealthToTone(inst.health)} />
|
|
3455
|
+
<span className="w-20 shrink-0 font-medium text-theme-text-primary">
|
|
3456
|
+
{inst.env}
|
|
3457
|
+
</span>
|
|
3458
|
+
<span className="min-w-0 flex-1 truncate text-theme-text-secondary">
|
|
3459
|
+
{inst.name}
|
|
3460
|
+
</span>
|
|
3461
|
+
{inst.version && (
|
|
3462
|
+
<span className="font-mono text-[10px] text-theme-text-tertiary">
|
|
3463
|
+
{midTruncate(inst.version, 18)}
|
|
3464
|
+
</span>
|
|
3465
|
+
)}
|
|
3466
|
+
</button>
|
|
3467
|
+
);
|
|
3468
|
+
})}
|
|
3469
|
+
</div>
|
|
3470
|
+
)}
|
|
3471
|
+
</div>
|
|
3472
|
+
);
|
|
678
3473
|
}
|