@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,3 +1,5 @@
|
|
|
1
|
+
import type { ResourceRef } from '../types/core'
|
|
2
|
+
|
|
1
3
|
// Shared model for the Applications surface — host-agnostic. The OSS single-
|
|
2
4
|
// cluster view and (eventually) the Cloud fleet view both build on these types
|
|
3
5
|
// and helpers. No React, no fetching.
|
|
@@ -5,145 +7,251 @@
|
|
|
5
7
|
// The wire shape mirrors radar OSS's GET /api/applications response
|
|
6
8
|
// (internal/server/applications.go). Field names match the Go json tags.
|
|
7
9
|
|
|
8
|
-
export type AppWorkloadClass =
|
|
9
|
-
|
|
10
|
+
export type AppWorkloadClass =
|
|
11
|
+
"service" | "worker" | "job" | "mixed" | "unknown";
|
|
12
|
+
export type AppHealth =
|
|
13
|
+
"healthy" | "degraded" | "unhealthy" | "neutral" | "unknown";
|
|
10
14
|
|
|
11
15
|
export interface AppWorkload {
|
|
12
|
-
kind: string
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
kind: string;
|
|
17
|
+
group?: string;
|
|
18
|
+
namespace: string;
|
|
19
|
+
name: string;
|
|
20
|
+
workload_class?: AppWorkloadClass;
|
|
21
|
+
image?: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
appVersion?: string;
|
|
24
|
+
health: string;
|
|
25
|
+
ready: number;
|
|
26
|
+
desired: number;
|
|
27
|
+
restarts: number;
|
|
28
|
+
reason?: string;
|
|
29
|
+
batch?: AppBatchSummary;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AppBatchSummary {
|
|
33
|
+
schedule?: string;
|
|
34
|
+
suspended?: boolean;
|
|
35
|
+
activeRuns?: number;
|
|
36
|
+
retainedRuns?: number;
|
|
37
|
+
failedRuns?: number;
|
|
38
|
+
succeededRuns?: number;
|
|
39
|
+
latestRunName?: string;
|
|
40
|
+
latestRunPhase?: string;
|
|
41
|
+
latestStartedAt?: string;
|
|
42
|
+
latestFinishedAt?: string;
|
|
43
|
+
lastScheduledAt?: string;
|
|
44
|
+
lastSuccessfulAt?: string;
|
|
45
|
+
message?: string;
|
|
24
46
|
}
|
|
25
47
|
|
|
26
48
|
export interface AppRelationships {
|
|
27
|
-
services?: string[]
|
|
28
|
-
ingresses?: string[]
|
|
49
|
+
services?: string[];
|
|
50
|
+
ingresses?: string[];
|
|
51
|
+
/** "Kind/name" — routes are polymorphic (HTTPRoute/GRPCRoute/…). */
|
|
29
52
|
routes?: string[]
|
|
30
53
|
configs?: number
|
|
31
54
|
scalers?: number
|
|
55
|
+
storage?: number
|
|
32
56
|
pdbs?: number
|
|
57
|
+
networkPolicies?: number
|
|
58
|
+
serviceRefs?: ResourceRef[]
|
|
59
|
+
ingressRefs?: ResourceRef[]
|
|
60
|
+
routeRefs?: ResourceRef[]
|
|
61
|
+
configRefs?: ResourceRef[]
|
|
62
|
+
scalerRefs?: ResourceRef[]
|
|
63
|
+
storageRefs?: ResourceRef[]
|
|
64
|
+
pdbRefs?: ResourceRef[]
|
|
65
|
+
networkPolicyRefs?: ResourceRef[]
|
|
33
66
|
}
|
|
34
67
|
|
|
35
68
|
export interface AppEvent {
|
|
36
|
-
type: string
|
|
37
|
-
reason: string
|
|
38
|
-
message?: string
|
|
39
|
-
count: number
|
|
40
|
-
object: string
|
|
41
|
-
|
|
69
|
+
type: string;
|
|
70
|
+
reason: string;
|
|
71
|
+
message?: string;
|
|
72
|
+
count: number;
|
|
73
|
+
object: string;
|
|
74
|
+
firstSeen?: string;
|
|
75
|
+
lastSeen?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AppSourceRef {
|
|
79
|
+
type: "gitops" | "helm";
|
|
80
|
+
tool?: "argocd" | "fluxcd" | "helm" | string;
|
|
81
|
+
group?: string;
|
|
82
|
+
kind: string;
|
|
83
|
+
namespace: string;
|
|
84
|
+
name: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AppSourceStatus {
|
|
88
|
+
sync?: string
|
|
89
|
+
health?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AppHistorySummary {
|
|
93
|
+
state: "change" | "incident" | "none";
|
|
94
|
+
title: string;
|
|
95
|
+
detail?: string;
|
|
96
|
+
timestamp?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AppHistoryAnchor {
|
|
100
|
+
type: "gitops" | "helm" | string;
|
|
101
|
+
title: string;
|
|
102
|
+
status?: string;
|
|
103
|
+
revision?: string;
|
|
104
|
+
message?: string;
|
|
105
|
+
source?: string;
|
|
106
|
+
initiatedBy?: string;
|
|
107
|
+
timestamp?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AppHistoryIncident {
|
|
111
|
+
severity: "warning" | "error" | "info" | string;
|
|
112
|
+
title: string;
|
|
113
|
+
object: string;
|
|
114
|
+
message?: string;
|
|
115
|
+
count?: number;
|
|
116
|
+
firstSeen?: string;
|
|
117
|
+
lastSeen?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface AppHistory {
|
|
121
|
+
appKey: string;
|
|
122
|
+
sourceRef?: AppSourceRef;
|
|
123
|
+
summary?: AppHistorySummary;
|
|
124
|
+
anchors?: AppHistoryAnchor[];
|
|
125
|
+
incidents?: AppHistoryIncident[];
|
|
126
|
+
partialSources?: string[];
|
|
42
127
|
}
|
|
43
128
|
|
|
44
129
|
export interface AppIdentity {
|
|
45
130
|
/** Shared display identity (the name stem). Derived classification — never
|
|
46
131
|
* an address; instance keys remain the only URLs. */
|
|
47
|
-
key: string
|
|
132
|
+
key: string;
|
|
48
133
|
/** This instance's canonical env token (dev | staging | prod | …). */
|
|
49
|
-
env: string
|
|
134
|
+
env: string;
|
|
50
135
|
/** high (declared source path) | medium (name stem + shared image repo). */
|
|
51
|
-
confidence: string
|
|
136
|
+
confidence: string;
|
|
52
137
|
/** Human-readable why, for the app group chip tooltip. */
|
|
53
|
-
evidence: string
|
|
138
|
+
evidence: string;
|
|
54
139
|
/** True when the key is backed by declared upstream identity and can group across clusters. */
|
|
55
|
-
portable?: boolean
|
|
140
|
+
portable?: boolean;
|
|
56
141
|
/** Machine-readable provenance tier (radar applications_identity.go):
|
|
57
142
|
* explicit | argo-path | argo-appset | flux-source (declared origins, portable)
|
|
58
143
|
* · label | name-stem | namespace (NAMEs, per-cluster). */
|
|
59
|
-
source?: string
|
|
144
|
+
source?: string;
|
|
60
145
|
/** Declared source-path stem (argo-path / flux-source only). The display `key`
|
|
61
146
|
* is the name stem; this disambiguates same-name/different-path apps in the
|
|
62
147
|
* portable cross-cluster fold. */
|
|
63
|
-
pathKey?: string
|
|
148
|
+
pathKey?: string;
|
|
64
149
|
}
|
|
65
150
|
|
|
66
151
|
/** The user-set annotation that forces cross-cluster grouping — the canonical
|
|
67
152
|
* answer to "how do I make this fold?" for non-GitOps apps. */
|
|
68
|
-
export const APP_IDENTITY_ANNOTATION =
|
|
153
|
+
export const APP_IDENTITY_ANNOTATION = "app.skyhook.io/app";
|
|
69
154
|
|
|
70
155
|
/** Whether a Source is a declared cross-cluster origin (mirrors the hub's
|
|
71
156
|
* isDeclaredPortableSource). NAMEs collide across clusters and stay per-cluster. */
|
|
72
157
|
export function isDeclaredAppSource(source?: string): boolean {
|
|
73
|
-
return
|
|
158
|
+
return (
|
|
159
|
+
source === "explicit" ||
|
|
160
|
+
source === "argo-path" ||
|
|
161
|
+
source === "argo-appset" ||
|
|
162
|
+
source === "flux-source"
|
|
163
|
+
);
|
|
74
164
|
}
|
|
75
165
|
|
|
76
166
|
/** A short label for how an app's identity was determined, by Source — the
|
|
77
167
|
* plain-language "grouped by …" phrase shown in the identity tooltip. */
|
|
78
168
|
export function appSourceLabel(source?: string): string {
|
|
79
169
|
switch (source) {
|
|
80
|
-
case
|
|
81
|
-
return `the ${APP_IDENTITY_ANNOTATION} annotation
|
|
82
|
-
case
|
|
83
|
-
return
|
|
84
|
-
case
|
|
85
|
-
return
|
|
86
|
-
case
|
|
87
|
-
return
|
|
88
|
-
case
|
|
89
|
-
return
|
|
90
|
-
case
|
|
91
|
-
return
|
|
92
|
-
case
|
|
93
|
-
return
|
|
94
|
-
case
|
|
95
|
-
return
|
|
170
|
+
case "explicit":
|
|
171
|
+
return `the ${APP_IDENTITY_ANNOTATION} annotation`;
|
|
172
|
+
case "argo-path":
|
|
173
|
+
return "its Argo CD source path";
|
|
174
|
+
case "argo-appset":
|
|
175
|
+
return "its ApplicationSet (env fan-out)";
|
|
176
|
+
case "flux-source":
|
|
177
|
+
return "its Flux source";
|
|
178
|
+
case "addon":
|
|
179
|
+
return "a shared add-on name + chart/image";
|
|
180
|
+
case "label":
|
|
181
|
+
return "the app.kubernetes.io/name label";
|
|
182
|
+
case "name-stem":
|
|
183
|
+
return "a shared name + image";
|
|
184
|
+
case "namespace":
|
|
185
|
+
return "a shared namespace + image";
|
|
96
186
|
default:
|
|
97
|
-
return
|
|
187
|
+
return "its workload grouping";
|
|
98
188
|
}
|
|
99
189
|
}
|
|
100
190
|
|
|
101
191
|
/** The explainer for "why isn't this grouped across clusters, and how do I fix
|
|
102
192
|
* it?" — drives the per-cluster row hint. `folds` is true when the identity is a
|
|
103
193
|
* declared origin (folds cross-cluster); otherwise `fix` says what to add. */
|
|
104
|
-
export function appGroupingExplainer(identity?: AppIdentity): {
|
|
105
|
-
|
|
194
|
+
export function appGroupingExplainer(identity?: AppIdentity): {
|
|
195
|
+
folds: boolean;
|
|
196
|
+
how: string;
|
|
197
|
+
fix?: string;
|
|
198
|
+
} {
|
|
199
|
+
const folds = isDeclaredAppSource(identity?.source);
|
|
106
200
|
return {
|
|
107
201
|
folds,
|
|
108
202
|
how: `Grouped by ${appSourceLabel(identity?.source)}.`,
|
|
109
203
|
fix: folds
|
|
110
204
|
? undefined
|
|
111
205
|
: `This app only has a per-cluster name, which can't prove two clusters run the same app. To fold it across clusters, set ${APP_IDENTITY_ANNOTATION} on its workloads in each cluster (same value), or deploy it via Argo CD / Flux with the environment in the source path.`,
|
|
112
|
-
}
|
|
206
|
+
};
|
|
113
207
|
}
|
|
114
208
|
|
|
115
209
|
export interface AppRow {
|
|
116
|
-
key: string
|
|
117
|
-
name: string
|
|
210
|
+
key: string;
|
|
211
|
+
name: string;
|
|
118
212
|
/** The single namespace the app's WORKLOADS run in; absent/empty when they
|
|
119
213
|
* span several (use `namespaces`). Residence, not the GitOps manager's home. */
|
|
120
|
-
namespace?: string
|
|
214
|
+
namespace?: string;
|
|
121
215
|
/** All distinct workload namespaces, sorted. */
|
|
122
|
-
namespaces?: string[]
|
|
216
|
+
namespaces?: string[];
|
|
123
217
|
/** App identity grouping evidence — instances of one logical app across
|
|
124
218
|
* environments share identity.key. See applications_identity.go. */
|
|
125
|
-
identity?: AppIdentity
|
|
219
|
+
identity?: AppIdentity;
|
|
126
220
|
/** pkg/subject overlay tier (0 = raw, no signal); 1-9. */
|
|
127
|
-
tier?: number
|
|
221
|
+
tier?: number;
|
|
128
222
|
/** high | medium | low */
|
|
129
|
-
confidence?: string
|
|
223
|
+
confidence?: string;
|
|
130
224
|
/** app | addon | mixed — classification hint, never identity. */
|
|
131
225
|
category?: string
|
|
132
226
|
addonReason?: string
|
|
133
227
|
workload_class?: AppWorkloadClass
|
|
134
|
-
/**
|
|
228
|
+
/** Worst-of runtime health and exact deployment-source status. */
|
|
135
229
|
health: string
|
|
230
|
+
/** Worst-of across workloads, independent of the deployment source. */
|
|
231
|
+
runtimeHealth?: string
|
|
136
232
|
/** distinct image tags. */
|
|
137
|
-
versions?: string[]
|
|
233
|
+
versions?: string[];
|
|
138
234
|
/** True when the SAME image runs different tags across workloads — real
|
|
139
235
|
* drift. Multiple components on different images is normal, not skew. */
|
|
140
|
-
versionSkew?: boolean
|
|
236
|
+
versionSkew?: boolean;
|
|
141
237
|
/** Single upstream version (app.kubernetes.io/version) when all workloads
|
|
142
238
|
* agree — the app's "main version". Empty for multi-chart umbrellas. */
|
|
143
|
-
appVersion?: string
|
|
239
|
+
appVersion?: string;
|
|
240
|
+
/** Exact source-system object when the grouping signal names one. Label/name
|
|
241
|
+
* inferred apps intentionally omit this instead of guessing. */
|
|
242
|
+
sourceRef?: AppSourceRef
|
|
243
|
+
/** Controller-reported delivery state for the exact source. */
|
|
244
|
+
sourceStatus?: AppSourceStatus
|
|
245
|
+
/** Workloads resolve to different Helm/GitOps source objects. */
|
|
246
|
+
sourceConflict?: boolean
|
|
144
247
|
workloads: AppWorkload[]
|
|
145
248
|
events?: AppEvent[]
|
|
146
249
|
relationships?: AppRelationships
|
|
250
|
+
/** Exact grouping-signal evidence keys the server grouped by
|
|
251
|
+
* ("instance:billing-prod", "part-of:x", "name:x", "app:x", "helm:release",
|
|
252
|
+
* "argo:appname") plus informational "name-stem:<stem>". The timeline joins
|
|
253
|
+
* events to this app by these keys — see buildAppMembershipIndex. */
|
|
254
|
+
matchKeys?: string[];
|
|
147
255
|
}
|
|
148
256
|
|
|
149
257
|
// -----------------------------------------------------------------------------
|
|
@@ -151,13 +259,13 @@ export interface AppRow {
|
|
|
151
259
|
// envs sort trailing.
|
|
152
260
|
// -----------------------------------------------------------------------------
|
|
153
261
|
|
|
154
|
-
export const ENV_RANK: Record<string, number> = { dev: 0, staging: 1, prod: 2 }
|
|
262
|
+
export const ENV_RANK: Record<string, number> = { dev: 0, staging: 1, prod: 2 };
|
|
155
263
|
|
|
156
264
|
/** Rank for an environment label, or null when it isn't on the ladder. */
|
|
157
265
|
export function envRank(env: string | undefined): number | null {
|
|
158
|
-
if (!env) return null
|
|
159
|
-
const r = ENV_RANK[env.toLowerCase()]
|
|
160
|
-
return r === undefined ? null : r
|
|
266
|
+
if (!env) return null;
|
|
267
|
+
const r = ENV_RANK[env.toLowerCase()];
|
|
268
|
+
return r === undefined ? null : r;
|
|
161
269
|
}
|
|
162
270
|
|
|
163
271
|
// Namespace-name token → canonical env. Matched on the whole name first, then
|
|
@@ -170,81 +278,123 @@ export function envRank(env: string | undefined): number | null {
|
|
|
170
278
|
// so a "loadtest" namespace labels once the cluster itself proves the token is
|
|
171
279
|
// an env. Zero local vocabulary beyond the trio.
|
|
172
280
|
const ENV_NS_TOKENS: Record<string, string> = {
|
|
173
|
-
dev:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
281
|
+
dev: "dev",
|
|
282
|
+
devel: "dev",
|
|
283
|
+
develop: "dev",
|
|
284
|
+
development: "dev",
|
|
285
|
+
stg: "staging",
|
|
286
|
+
stage: "staging",
|
|
287
|
+
staging: "staging",
|
|
288
|
+
prd: "prod",
|
|
289
|
+
prod: "prod",
|
|
290
|
+
production: "prod",
|
|
291
|
+
live: "prod",
|
|
292
|
+
};
|
|
177
293
|
|
|
178
294
|
/** Infer a canonical environment from a namespace name, or null when nothing
|
|
179
295
|
* recognizable is present (conservative — `kube-system`, `billing` → null). */
|
|
180
|
-
export function envFromNamespace(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
296
|
+
export function envFromNamespace(
|
|
297
|
+
namespace: string | undefined,
|
|
298
|
+
extraTokens?: ReadonlySet<string>,
|
|
299
|
+
): string | null {
|
|
300
|
+
if (!namespace) return null;
|
|
301
|
+
const lower = namespace.toLowerCase();
|
|
302
|
+
const hit = (tok: string): string | null =>
|
|
303
|
+
ENV_NS_TOKENS[tok] ?? (extraTokens?.has(tok) ? tok : null);
|
|
304
|
+
const whole = hit(lower);
|
|
305
|
+
if (whole) return whole;
|
|
186
306
|
for (const seg of lower.split(/[-_]/).filter(Boolean)) {
|
|
187
|
-
const h = hit(seg)
|
|
188
|
-
if (h) return h
|
|
307
|
+
const h = hit(seg);
|
|
308
|
+
if (h) return h;
|
|
189
309
|
}
|
|
190
|
-
return null
|
|
310
|
+
return null;
|
|
191
311
|
}
|
|
192
312
|
|
|
193
313
|
export interface ResolvedAppEnv {
|
|
194
314
|
/** Canonical env token (lowercased), or '' when unlabeled. */
|
|
195
|
-
env: string
|
|
315
|
+
env: string;
|
|
196
316
|
/** True when derived from the namespace heuristic (not an explicit label). */
|
|
197
|
-
inferred: boolean
|
|
317
|
+
inferred: boolean;
|
|
198
318
|
}
|
|
199
319
|
|
|
200
320
|
/** Resolve an environment via the precedence cascade: an explicit env wins;
|
|
201
321
|
* otherwise the namespace heuristic (tagged inferred); otherwise unlabeled. */
|
|
202
|
-
export function resolveEnv(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
322
|
+
export function resolveEnv(
|
|
323
|
+
explicitEnv: string | undefined,
|
|
324
|
+
namespace: string | undefined,
|
|
325
|
+
extraTokens?: ReadonlySet<string>,
|
|
326
|
+
): ResolvedAppEnv {
|
|
327
|
+
const explicit = (explicitEnv || "").trim();
|
|
328
|
+
if (explicit) return { env: explicit.toLowerCase(), inferred: false };
|
|
329
|
+
const inferred = envFromNamespace(namespace, extraTokens);
|
|
330
|
+
if (inferred) return { env: inferred, inferred: true };
|
|
331
|
+
return { env: "", inferred: false };
|
|
208
332
|
}
|
|
209
333
|
|
|
210
|
-
export function identityEnvInferred(
|
|
211
|
-
|
|
212
|
-
|
|
334
|
+
export function identityEnvInferred(
|
|
335
|
+
identity: AppIdentity | undefined,
|
|
336
|
+
): boolean {
|
|
337
|
+
if (!identity) return false;
|
|
338
|
+
return identity.evidence.startsWith("namespace stem ");
|
|
213
339
|
}
|
|
214
340
|
|
|
215
341
|
// -----------------------------------------------------------------------------
|
|
216
342
|
// System namespaces — cluster plumbing hidden by default on the app surface.
|
|
217
343
|
// -----------------------------------------------------------------------------
|
|
218
344
|
|
|
219
|
-
const SYSTEM_NAMESPACES = new Set([
|
|
345
|
+
const SYSTEM_NAMESPACES = new Set([
|
|
346
|
+
"kube-system",
|
|
347
|
+
"kube-public",
|
|
348
|
+
"kube-node-lease",
|
|
349
|
+
"kube-flannel",
|
|
350
|
+
"local-path-storage",
|
|
351
|
+
]);
|
|
220
352
|
|
|
221
353
|
/** True for cluster-plumbing namespaces (kube-*, *-system operators) the app
|
|
222
354
|
* list hides by default. The `-system` suffix catches operator namespaces like
|
|
223
355
|
* `cert-manager`'s `gatekeeper-system`, `kourier-system`, etc.; `gke-managed-`
|
|
224
356
|
* is Google's documented prefix for GKE-managed component namespaces. */
|
|
225
357
|
export function isSystemNamespace(ns: string | undefined): boolean {
|
|
226
|
-
if (!ns) return false
|
|
227
|
-
const lower = ns.toLowerCase()
|
|
228
|
-
return
|
|
358
|
+
if (!ns) return false;
|
|
359
|
+
const lower = ns.toLowerCase();
|
|
360
|
+
return (
|
|
361
|
+
SYSTEM_NAMESPACES.has(lower) ||
|
|
362
|
+
lower.endsWith("-system") ||
|
|
363
|
+
lower.startsWith("gke-managed-")
|
|
364
|
+
);
|
|
229
365
|
}
|
|
230
366
|
|
|
231
367
|
// -----------------------------------------------------------------------------
|
|
232
368
|
// Category — the app/add-on/mixed classification hint (never identity).
|
|
233
369
|
// -----------------------------------------------------------------------------
|
|
234
370
|
|
|
235
|
-
export type AppCategory =
|
|
236
|
-
|
|
237
|
-
export const CATEGORY_ORDER: AppCategory[] = [
|
|
238
|
-
|
|
239
|
-
export const CATEGORY_META: Record<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
371
|
+
export type AppCategory = "app" | "addon" | "mixed";
|
|
372
|
+
|
|
373
|
+
export const CATEGORY_ORDER: AppCategory[] = ["app", "addon", "mixed"];
|
|
374
|
+
|
|
375
|
+
export const CATEGORY_META: Record<
|
|
376
|
+
AppCategory,
|
|
377
|
+
{ label: string; tooltip: string }
|
|
378
|
+
> = {
|
|
379
|
+
app: {
|
|
380
|
+
label: "App",
|
|
381
|
+
tooltip: "Software you deploy and run — services, workers, jobs.",
|
|
382
|
+
},
|
|
383
|
+
addon: {
|
|
384
|
+
label: "Add-on",
|
|
385
|
+
tooltip:
|
|
386
|
+
"Platform machinery (controllers, operators, system charts), classified by chart/label evidence. Shown for completeness.",
|
|
387
|
+
},
|
|
388
|
+
mixed: {
|
|
389
|
+
label: "Mixed",
|
|
390
|
+
tooltip:
|
|
391
|
+
"Has both app and add-on evidence. Kept visible — classification is informational, not identity.",
|
|
392
|
+
},
|
|
393
|
+
};
|
|
244
394
|
|
|
245
395
|
/** The category bucket for a row — apps with no category default to 'app'. */
|
|
246
396
|
export function categoryOf(category: string | undefined): AppCategory {
|
|
247
|
-
return category ===
|
|
397
|
+
return category === "addon" || category === "mixed" ? category : "app";
|
|
248
398
|
}
|
|
249
399
|
|
|
250
400
|
// -----------------------------------------------------------------------------
|
|
@@ -254,47 +404,51 @@ export function categoryOf(category: string | undefined): AppCategory {
|
|
|
254
404
|
// -----------------------------------------------------------------------------
|
|
255
405
|
|
|
256
406
|
function parseVersion(v: string | undefined): number[] | null {
|
|
257
|
-
if (!v) return null
|
|
258
|
-
const t = v.trim().replace(/^v/i,
|
|
259
|
-
if (!/^\d+(\.\d+)*$/.test(t)) return null
|
|
260
|
-
return t.split(
|
|
407
|
+
if (!v) return null;
|
|
408
|
+
const t = v.trim().replace(/^v/i, "");
|
|
409
|
+
if (!/^\d+(\.\d+)*$/.test(t)) return null;
|
|
410
|
+
return t.split(".").map((n) => parseInt(n, 10));
|
|
261
411
|
}
|
|
262
412
|
|
|
263
413
|
/** Date-stamped CI tags ("main_2026-03-26_05", "billing_main_2026-05-18_00"):
|
|
264
414
|
* same prefix + extractable date (+ optional sequence) gives a total order.
|
|
265
415
|
* Different prefixes or no date → not comparable; never guess. */
|
|
266
|
-
const DATE_TAG = /^(.*?)[-_](\d{4})[-_.](\d{2})[-_.](\d{2})(?:[-_.](\d+))
|
|
416
|
+
const DATE_TAG = /^(.*?)[-_](\d{4})[-_.](\d{2})[-_.](\d{2})(?:[-_.](\d+))?$/;
|
|
267
417
|
|
|
268
418
|
function parseDateTag(v: string): { prefix: string; ord: number } | null {
|
|
269
|
-
const m = DATE_TAG.exec(v)
|
|
270
|
-
if (!m) return null
|
|
271
|
-
const [, prefix, y, mo, d, seq] = m
|
|
272
|
-
const ord =
|
|
273
|
-
|
|
419
|
+
const m = DATE_TAG.exec(v);
|
|
420
|
+
if (!m) return null;
|
|
421
|
+
const [, prefix, y, mo, d, seq] = m;
|
|
422
|
+
const ord =
|
|
423
|
+
Number(y) * 1e8 + Number(mo) * 1e6 + Number(d) * 1e4 + Number(seq ?? 0);
|
|
424
|
+
return { prefix, ord };
|
|
274
425
|
}
|
|
275
426
|
|
|
276
427
|
/** -1 if a<b, 1 if a>b, 0 if equal, null if either isn't a comparable version. */
|
|
277
|
-
export function compareVersions(
|
|
428
|
+
export function compareVersions(
|
|
429
|
+
a: string | undefined,
|
|
430
|
+
b: string | undefined,
|
|
431
|
+
): number | null {
|
|
278
432
|
// Date-stamped pipeline tags first — semver parsing would misread them.
|
|
279
433
|
if (a && b) {
|
|
280
|
-
const da = parseDateTag(a)
|
|
281
|
-
const db = parseDateTag(b)
|
|
434
|
+
const da = parseDateTag(a);
|
|
435
|
+
const db = parseDateTag(b);
|
|
282
436
|
if (da && db) {
|
|
283
|
-
if (da.prefix !== db.prefix) return null
|
|
284
|
-
return da.ord === db.ord ? 0 : da.ord < db.ord ? -1 : 1
|
|
437
|
+
if (da.prefix !== db.prefix) return null;
|
|
438
|
+
return da.ord === db.ord ? 0 : da.ord < db.ord ? -1 : 1;
|
|
285
439
|
}
|
|
286
|
-
if (da || db) return null
|
|
440
|
+
if (da || db) return null;
|
|
287
441
|
}
|
|
288
|
-
const pa = parseVersion(a)
|
|
289
|
-
const pb = parseVersion(b)
|
|
290
|
-
if (!pa || !pb) return null
|
|
291
|
-
const len = Math.max(pa.length, pb.length)
|
|
442
|
+
const pa = parseVersion(a);
|
|
443
|
+
const pb = parseVersion(b);
|
|
444
|
+
if (!pa || !pb) return null;
|
|
445
|
+
const len = Math.max(pa.length, pb.length);
|
|
292
446
|
for (let i = 0; i < len; i++) {
|
|
293
|
-
const x = pa[i] ?? 0
|
|
294
|
-
const y = pb[i] ?? 0
|
|
295
|
-
if (x !== y) return x < y ? -1 : 1
|
|
447
|
+
const x = pa[i] ?? 0;
|
|
448
|
+
const y = pb[i] ?? 0;
|
|
449
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
296
450
|
}
|
|
297
|
-
return 0
|
|
451
|
+
return 0;
|
|
298
452
|
}
|
|
299
453
|
|
|
300
454
|
// -----------------------------------------------------------------------------
|
|
@@ -307,51 +461,72 @@ export function compareVersions(a: string | undefined, b: string | undefined): n
|
|
|
307
461
|
/** Coarse provenance bucket for the Source facet. Stable ids — display labels
|
|
308
462
|
* live in SOURCE_META (the house meta-map pattern), so they can be re-worded
|
|
309
463
|
* without breaking facet state or future URL serialization. */
|
|
310
|
-
export type AppSource =
|
|
464
|
+
export type AppSource = "argocd" | "flux" | "helm" | "label" | "ungrouped";
|
|
311
465
|
|
|
312
|
-
export const SOURCE_ORDER: AppSource[] = [
|
|
466
|
+
export const SOURCE_ORDER: AppSource[] = [
|
|
467
|
+
"argocd",
|
|
468
|
+
"flux",
|
|
469
|
+
"helm",
|
|
470
|
+
"label",
|
|
471
|
+
"ungrouped",
|
|
472
|
+
];
|
|
313
473
|
|
|
314
474
|
export const SOURCE_META: Record<AppSource, { label: string }> = {
|
|
315
|
-
argocd: { label:
|
|
316
|
-
flux: { label:
|
|
317
|
-
helm: { label:
|
|
318
|
-
label: { label:
|
|
319
|
-
ungrouped: { label:
|
|
320
|
-
}
|
|
475
|
+
argocd: { label: "Argo CD" },
|
|
476
|
+
flux: { label: "Flux" },
|
|
477
|
+
helm: { label: "Helm" },
|
|
478
|
+
label: { label: "Label" },
|
|
479
|
+
ungrouped: { label: "Ungrouped" },
|
|
480
|
+
};
|
|
321
481
|
|
|
322
482
|
interface TierMeta {
|
|
323
|
-
source: AppSource
|
|
483
|
+
source: AppSource;
|
|
324
484
|
/** Tooltip phrase pieces: "Grouped by {lead} `{code(name)}` {trail}". */
|
|
325
|
-
lead: string
|
|
326
|
-
code: (name: string) => string
|
|
327
|
-
trail?: string
|
|
485
|
+
lead: string;
|
|
486
|
+
code: (name: string) => string;
|
|
487
|
+
trail?: string;
|
|
328
488
|
}
|
|
329
489
|
|
|
330
490
|
const TIER_META: Record<number, TierMeta> = {
|
|
331
|
-
1: { source:
|
|
332
|
-
2: { source:
|
|
333
|
-
3: { source:
|
|
334
|
-
4: { source:
|
|
335
|
-
5: { source:
|
|
336
|
-
6: {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
491
|
+
1: { source: "flux", lead: "its Flux HelmRelease", code: (n) => n },
|
|
492
|
+
2: { source: "flux", lead: "its Flux Kustomization", code: (n) => n },
|
|
493
|
+
3: { source: "argocd", lead: "its Argo CD Application", code: (n) => n },
|
|
494
|
+
4: { source: "argocd", lead: "its Argo CD Application", code: (n) => n },
|
|
495
|
+
5: { source: "helm", lead: "its Helm release", code: (n) => n },
|
|
496
|
+
6: {
|
|
497
|
+
source: "label",
|
|
498
|
+
lead: "the",
|
|
499
|
+
code: () => "app.kubernetes.io/instance",
|
|
500
|
+
trail: "label",
|
|
501
|
+
},
|
|
502
|
+
7: {
|
|
503
|
+
source: "label",
|
|
504
|
+
lead: "the",
|
|
505
|
+
code: () => "app.kubernetes.io/part-of",
|
|
506
|
+
trail: "label",
|
|
507
|
+
},
|
|
508
|
+
8: {
|
|
509
|
+
source: "label",
|
|
510
|
+
lead: "the",
|
|
511
|
+
code: () => "app.kubernetes.io/name",
|
|
512
|
+
trail: "label",
|
|
513
|
+
},
|
|
514
|
+
9: { source: "label", lead: "the", code: () => "app", trail: "label" },
|
|
515
|
+
};
|
|
341
516
|
|
|
342
517
|
export function sourceOf(tier: number | undefined): AppSource {
|
|
343
|
-
if (!tier) return
|
|
344
|
-
return TIER_META[tier]?.source ??
|
|
518
|
+
if (!tier) return "ungrouped";
|
|
519
|
+
return TIER_META[tier]?.source ?? "label";
|
|
345
520
|
}
|
|
346
521
|
|
|
347
522
|
/** Short badge label for an app's provenance tier (which tool/source grouped it). */
|
|
348
523
|
export function overlayProvenance(tier: number | undefined): string {
|
|
349
|
-
return SOURCE_META[sourceOf(tier)].label
|
|
524
|
+
return SOURCE_META[sourceOf(tier)].label;
|
|
350
525
|
}
|
|
351
526
|
|
|
352
527
|
function appNameFromKey(key: string): string {
|
|
353
|
-
const slash = key.lastIndexOf(
|
|
354
|
-
return slash >= 0 && slash < key.length - 1 ? key.slice(slash + 1) : key
|
|
528
|
+
const slash = key.lastIndexOf("/");
|
|
529
|
+
return slash >= 0 && slash < key.length - 1 ? key.slice(slash + 1) : key;
|
|
355
530
|
}
|
|
356
531
|
|
|
357
532
|
// How an app was grouped, decomposed so the tooltip can render the source
|
|
@@ -359,33 +534,144 @@ function appNameFromKey(key: string): string {
|
|
|
359
534
|
// `lead` + `code` + `trail` reads as a phrase: "its Flux HelmRelease `argocd`"
|
|
360
535
|
// or "the `app.kubernetes.io/part-of` label". `code` empty → no chip.
|
|
361
536
|
export interface ProvenanceSource {
|
|
362
|
-
lead: string
|
|
363
|
-
code: string
|
|
364
|
-
trail?: string
|
|
537
|
+
lead: string;
|
|
538
|
+
code: string;
|
|
539
|
+
trail?: string;
|
|
365
540
|
}
|
|
366
541
|
|
|
367
|
-
export function provenanceSource(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
542
|
+
export function provenanceSource(
|
|
543
|
+
tier: number | undefined,
|
|
544
|
+
key: string,
|
|
545
|
+
): ProvenanceSource {
|
|
546
|
+
const meta = tier ? TIER_META[tier] : undefined;
|
|
547
|
+
if (!meta) return { lead: "cluster-native evidence", code: "" };
|
|
548
|
+
return {
|
|
549
|
+
lead: meta.lead,
|
|
550
|
+
code: meta.code(appNameFromKey(key)),
|
|
551
|
+
trail: meta.trail,
|
|
552
|
+
};
|
|
371
553
|
}
|
|
372
554
|
|
|
373
|
-
|
|
374
555
|
/** The distinct namespaces an app's workloads run in, sorted. Prefers the
|
|
375
556
|
* server's `namespaces` field, deriving from workloads for older payloads. */
|
|
376
557
|
export function namespacesOf(app: AppRow): string[] {
|
|
377
|
-
if (app.namespaces && app.namespaces.length > 0) return app.namespaces
|
|
378
|
-
const nss = Array.from(
|
|
379
|
-
|
|
380
|
-
|
|
558
|
+
if (app.namespaces && app.namespaces.length > 0) return app.namespaces;
|
|
559
|
+
const nss = Array.from(
|
|
560
|
+
new Set((app.workloads || []).map((w) => w.namespace).filter(Boolean)),
|
|
561
|
+
).sort();
|
|
562
|
+
if (nss.length > 0) return nss;
|
|
563
|
+
return app.namespace ? [app.namespace] : [];
|
|
381
564
|
}
|
|
382
565
|
|
|
383
566
|
/** An app's single namespace, or '' when it spans several — callers must not
|
|
384
567
|
* pick an arbitrary one (env inference and the system-namespace filter both
|
|
385
568
|
* key off this; a wrong pick misleads). Use namespacesOf for the full list. */
|
|
386
569
|
export function namespaceOf(app: AppRow): string {
|
|
387
|
-
const nss = namespacesOf(app)
|
|
388
|
-
return nss.length === 1 ? nss[0] :
|
|
570
|
+
const nss = namespacesOf(app);
|
|
571
|
+
return nss.length === 1 ? nss[0] : "";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// -----------------------------------------------------------------------------
|
|
575
|
+
// App membership index — the client-side join between the server's application
|
|
576
|
+
// grouping (GET /api/applications) and the timeline's per-resource lanes. The
|
|
577
|
+
// server is the single grouping authority (union-find + 9-tier evidence); this
|
|
578
|
+
// index lets the timeline attribute a lane (or a DELETED resource's events) to
|
|
579
|
+
// the same app without re-deriving anything. See plan-timeline-app-grouping.md.
|
|
580
|
+
// -----------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/** One app a timeline lane can belong to. `appKey` is stable per AppRow (the
|
|
583
|
+
* server row key), so two env-instances that share identity.key stay distinct
|
|
584
|
+
* header lanes (each with its own env chip). */
|
|
585
|
+
export interface AppMembership {
|
|
586
|
+
appKey: string;
|
|
587
|
+
appName: string;
|
|
588
|
+
env?: string;
|
|
589
|
+
evidence?: string;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export interface AppMembershipIndex {
|
|
593
|
+
/** 'Kind/namespace/name' → app. Built from row.workloads + satellite arrays. */
|
|
594
|
+
byResource: Map<string, AppMembership>;
|
|
595
|
+
/** 'kind:namespace:value' (instance/part-of/name/app/helm/argo) → app. Built
|
|
596
|
+
* from row.matchKeys EXCLUDING name-stem (v1 matches exact kinds only). Keys
|
|
597
|
+
* are namespace-scoped so a shared label value across namespaces can't
|
|
598
|
+
* cross-join; lookups use the event's namespace. Catches events from
|
|
599
|
+
* resources deleted before the current snapshot. */
|
|
600
|
+
byEvidence: Map<string, AppMembership>;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Collision policy: FIRST row wins (deterministic — rows arrive in the server's
|
|
604
|
+
// sorted order). A satellite/evidence shared by two apps attributes to the first.
|
|
605
|
+
function setFirst<V>(map: Map<string, V>, key: string, value: V): void {
|
|
606
|
+
if (!map.has(key)) map.set(key, value);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Build the resource + evidence membership index from the applications rows.
|
|
610
|
+
* Pure; no fetching. `byResource` keys workloads by their own kind/ns/name and
|
|
611
|
+
* satellites (Services/Ingresses/Routes) by the app's single namespace — a
|
|
612
|
+
* multi-namespace app can't place its satellites unambiguously, so those are
|
|
613
|
+
* skipped (workloads still index fine). `byEvidence` drops name-stem keys: the
|
|
614
|
+
* server ships them for display, but v1 only joins events on exact label kinds. */
|
|
615
|
+
export function buildAppMembershipIndex(rows: AppRow[]): AppMembershipIndex {
|
|
616
|
+
const byResource = new Map<string, AppMembership>();
|
|
617
|
+
const byEvidence = new Map<string, AppMembership>();
|
|
618
|
+
for (const row of rows) {
|
|
619
|
+
const membership: AppMembership = {
|
|
620
|
+
appKey: row.key,
|
|
621
|
+
appName: row.name,
|
|
622
|
+
env: row.identity?.env || undefined,
|
|
623
|
+
evidence: row.identity?.evidence || undefined,
|
|
624
|
+
};
|
|
625
|
+
for (const w of row.workloads || []) {
|
|
626
|
+
if (w.kind && w.name)
|
|
627
|
+
setFirst(byResource, `${w.kind}/${w.namespace}/${w.name}`, membership);
|
|
628
|
+
}
|
|
629
|
+
const ns = namespaceOf(row);
|
|
630
|
+
if (ns && row.relationships) {
|
|
631
|
+
for (const s of row.relationships.services || [])
|
|
632
|
+
setFirst(byResource, `Service/${ns}/${s}`, membership);
|
|
633
|
+
for (const i of row.relationships.ingresses || [])
|
|
634
|
+
setFirst(byResource, `Ingress/${ns}/${i}`, membership);
|
|
635
|
+
// Routes ship as "Kind/name" (HTTPRoute/GRPCRoute/…); index under the
|
|
636
|
+
// concrete kind so the key matches the route lane's id, not a generic "Route".
|
|
637
|
+
for (const r of row.relationships.routes || []) {
|
|
638
|
+
const slash = r.indexOf("/");
|
|
639
|
+
const routeKind = slash > 0 ? r.slice(0, slash) : "Route";
|
|
640
|
+
const routeName = slash > 0 ? r.slice(slash + 1) : r;
|
|
641
|
+
setFirst(byResource, `${routeKind}/${ns}/${routeName}`, membership);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (row.relationships) {
|
|
645
|
+
const explicitRefs = [
|
|
646
|
+
row.relationships.serviceRefs,
|
|
647
|
+
row.relationships.ingressRefs,
|
|
648
|
+
row.relationships.routeRefs,
|
|
649
|
+
row.relationships.configRefs,
|
|
650
|
+
row.relationships.scalerRefs,
|
|
651
|
+
row.relationships.storageRefs,
|
|
652
|
+
row.relationships.pdbRefs,
|
|
653
|
+
row.relationships.networkPolicyRefs,
|
|
654
|
+
];
|
|
655
|
+
for (const refs of explicitRefs) {
|
|
656
|
+
for (const ref of refs ?? []) {
|
|
657
|
+
if (ref.kind && ref.name)
|
|
658
|
+
setFirst(byResource, `${ref.kind}/${ref.namespace}/${ref.name}`, membership);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (row.sourceRef?.kind && row.sourceRef.name) {
|
|
663
|
+
setFirst(
|
|
664
|
+
byResource,
|
|
665
|
+
`${row.sourceRef.kind}/${row.sourceRef.namespace}/${row.sourceRef.name}`,
|
|
666
|
+
membership,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
for (const mk of row.matchKeys || []) {
|
|
670
|
+
if (mk.startsWith("name-stem:")) continue;
|
|
671
|
+
setFirst(byEvidence, mk, membership);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return { byResource, byEvidence };
|
|
389
675
|
}
|
|
390
676
|
|
|
391
677
|
// -----------------------------------------------------------------------------
|
|
@@ -401,20 +687,35 @@ export function namespaceOf(app: AppRow): string {
|
|
|
401
687
|
* overview. Callers extend it with the group's own (discovered) env tokens
|
|
402
688
|
* via the extraTokens parameter. */
|
|
403
689
|
const NAME_ENV_TOKENS = new Set([
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
690
|
+
"dev",
|
|
691
|
+
"development",
|
|
692
|
+
"staging",
|
|
693
|
+
"stage",
|
|
694
|
+
"stg",
|
|
695
|
+
"prod",
|
|
696
|
+
"production",
|
|
697
|
+
"prd",
|
|
698
|
+
"qa",
|
|
699
|
+
"uat",
|
|
700
|
+
"preprod",
|
|
701
|
+
"preview",
|
|
702
|
+
"canary",
|
|
703
|
+
]);
|
|
407
704
|
|
|
408
705
|
/** Strip a recognized env affix from a workload/app name —
|
|
409
706
|
* "billing-staging" → "billing", "qa-koala-backend" → "koala-backend".
|
|
410
707
|
* Used to match "the same workload" across app group env instances. */
|
|
411
|
-
export function stripEnvAffix(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
708
|
+
export function stripEnvAffix(
|
|
709
|
+
name: string,
|
|
710
|
+
extraTokens?: ReadonlySet<string>,
|
|
711
|
+
): string {
|
|
712
|
+
const isEnv = (tok: string) =>
|
|
713
|
+
NAME_ENV_TOKENS.has(tok) || (extraTokens?.has(tok) ?? false);
|
|
714
|
+
const i = name.lastIndexOf("-");
|
|
715
|
+
if (i > 0 && isEnv(name.slice(i + 1).toLowerCase())) return name.slice(0, i);
|
|
716
|
+
const j = name.indexOf("-");
|
|
717
|
+
if (j > 0 && isEnv(name.slice(0, j).toLowerCase())) return name.slice(j + 1);
|
|
718
|
+
return name;
|
|
418
719
|
}
|
|
419
720
|
|
|
420
721
|
/** Find "the same workload" in a sibling env instance: exact kind+name first,
|
|
@@ -423,61 +724,77 @@ export function stripEnvAffix(name: string, extraTokens?: ReadonlySet<string>):
|
|
|
423
724
|
* strip too. Null = no counterpart — the switch shows the instance overview. */
|
|
424
725
|
export function matchWorkloadAcrossInstances(
|
|
425
726
|
workloadKey: string,
|
|
426
|
-
targetWorkloads:
|
|
727
|
+
targetWorkloads:
|
|
728
|
+
Pick<AppWorkload, "kind" | "namespace" | "name">[] | undefined,
|
|
427
729
|
extraTokens?: ReadonlySet<string>,
|
|
428
|
-
): Pick<AppWorkload,
|
|
429
|
-
const [kind, namespace, name] = workloadKey.split(
|
|
430
|
-
if (!kind || !name) return null
|
|
431
|
-
const ws = targetWorkloads ?? []
|
|
730
|
+
): Pick<AppWorkload, "kind" | "namespace" | "name"> | null {
|
|
731
|
+
const [kind, namespace, name] = workloadKey.split("/");
|
|
732
|
+
if (!kind || !name) return null;
|
|
733
|
+
const ws = targetWorkloads ?? [];
|
|
432
734
|
return (
|
|
433
|
-
ws.find(
|
|
735
|
+
ws.find(
|
|
736
|
+
(w) => w.kind === kind && w.namespace === namespace && w.name === name,
|
|
737
|
+
) ??
|
|
434
738
|
uniqueMatch(ws, (w) => w.kind === kind && w.name === name) ??
|
|
435
|
-
uniqueMatch(
|
|
739
|
+
uniqueMatch(
|
|
740
|
+
ws,
|
|
741
|
+
(w) =>
|
|
742
|
+
w.kind === kind &&
|
|
743
|
+
stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens),
|
|
744
|
+
) ??
|
|
436
745
|
null
|
|
437
|
-
)
|
|
746
|
+
);
|
|
438
747
|
}
|
|
439
748
|
|
|
440
749
|
function uniqueMatch<T>(items: T[], pred: (item: T) => boolean): T | null {
|
|
441
|
-
let found: T | null = null
|
|
750
|
+
let found: T | null = null;
|
|
442
751
|
for (const item of items) {
|
|
443
|
-
if (!pred(item)) continue
|
|
444
|
-
if (found) return null
|
|
445
|
-
found = item
|
|
752
|
+
if (!pred(item)) continue;
|
|
753
|
+
if (found) return null;
|
|
754
|
+
found = item;
|
|
446
755
|
}
|
|
447
|
-
return found
|
|
756
|
+
return found;
|
|
448
757
|
}
|
|
449
758
|
|
|
450
759
|
/** Ladder order: ranked envs by rank (dev → staging → prod), then
|
|
451
760
|
* recognized-but-unranked alphabetically (qa, …). */
|
|
452
761
|
export function orderEnvs(envs: string[]): string[] {
|
|
453
762
|
return [...envs].sort((a, b) => {
|
|
454
|
-
const ra = envRank(a)
|
|
455
|
-
const rb = envRank(b)
|
|
456
|
-
if (ra !== null && rb !== null) return ra - rb
|
|
457
|
-
if (ra !== null) return -1
|
|
458
|
-
if (rb !== null) return 1
|
|
459
|
-
return a.localeCompare(b)
|
|
460
|
-
})
|
|
763
|
+
const ra = envRank(a);
|
|
764
|
+
const rb = envRank(b);
|
|
765
|
+
if (ra !== null && rb !== null) return ra - rb;
|
|
766
|
+
if (ra !== null) return -1;
|
|
767
|
+
if (rb !== null) return 1;
|
|
768
|
+
return a.localeCompare(b);
|
|
769
|
+
});
|
|
461
770
|
}
|
|
462
771
|
|
|
463
772
|
/** Promotion lag across an app group's env cells: fires only between RANKED envs,
|
|
464
773
|
* when a strictly-lower env runs a strictly-newer comparable version.
|
|
465
774
|
* Returns the human message ("staging is behind dev") or null. */
|
|
466
|
-
export function appGroupLagMessage(
|
|
775
|
+
export function appGroupLagMessage(
|
|
776
|
+
cells: { env: string; version?: string }[],
|
|
777
|
+
): string | null {
|
|
467
778
|
const ranked = cells
|
|
468
779
|
.map((c) => ({ ...c, rank: envRank(c.env) }))
|
|
469
|
-
.filter(
|
|
470
|
-
|
|
780
|
+
.filter(
|
|
781
|
+
(c): c is { env: string; version?: string; rank: number } =>
|
|
782
|
+
c.rank !== null && !!c.version,
|
|
783
|
+
)
|
|
784
|
+
.sort((a, b) => a.rank - b.rank);
|
|
471
785
|
for (let i = 0; i < ranked.length; i++) {
|
|
472
786
|
for (let j = i + 1; j < ranked.length; j++) {
|
|
473
787
|
// Strict rank inequality: two instances of the SAME env are siblings,
|
|
474
788
|
// not a promotion pair — without this, "prod is behind prod" can fire.
|
|
475
|
-
if (
|
|
476
|
-
|
|
789
|
+
if (
|
|
790
|
+
ranked[i].rank < ranked[j].rank &&
|
|
791
|
+
compareVersions(ranked[i].version, ranked[j].version) === 1
|
|
792
|
+
) {
|
|
793
|
+
return `${ranked[j].env} is behind ${ranked[i].env}`;
|
|
477
794
|
}
|
|
478
795
|
}
|
|
479
796
|
}
|
|
480
|
-
return null
|
|
797
|
+
return null;
|
|
481
798
|
}
|
|
482
799
|
|
|
483
800
|
// -----------------------------------------------------------------------------
|
|
@@ -490,53 +807,59 @@ export function appGroupLagMessage(cells: { env: string; version?: string }[]):
|
|
|
490
807
|
|
|
491
808
|
/** The slice of a list entry the fold needs. */
|
|
492
809
|
export interface AppGroupFoldEntry {
|
|
493
|
-
row: {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
810
|
+
row: {
|
|
811
|
+
key: string;
|
|
812
|
+
name: string;
|
|
813
|
+
identity?: AppIdentity;
|
|
814
|
+
appVersion?: string;
|
|
815
|
+
};
|
|
816
|
+
health: AppHealth;
|
|
817
|
+
versions: string[];
|
|
818
|
+
ready: number;
|
|
819
|
+
desired: number;
|
|
820
|
+
kinds: Record<string, number>;
|
|
821
|
+
classComposition: { cls: AppWorkloadClass; count: number }[];
|
|
500
822
|
}
|
|
501
823
|
|
|
502
824
|
export interface AppGroupEnvCell {
|
|
503
|
-
env: string
|
|
504
|
-
health: AppHealth
|
|
505
|
-
version?: string
|
|
506
|
-
count: number
|
|
507
|
-
firstKey: string
|
|
825
|
+
env: string;
|
|
826
|
+
health: AppHealth;
|
|
827
|
+
version?: string;
|
|
828
|
+
count: number;
|
|
829
|
+
firstKey: string;
|
|
508
830
|
}
|
|
509
831
|
|
|
510
832
|
export interface FoldedAppGroupRow<T extends AppGroupFoldEntry> {
|
|
511
|
-
kind:
|
|
512
|
-
key: string
|
|
513
|
-
label: string
|
|
514
|
-
members: T[]
|
|
515
|
-
expanded: boolean
|
|
516
|
-
cells: AppGroupEnvCell[]
|
|
517
|
-
lag: string | null
|
|
518
|
-
health: AppHealth
|
|
519
|
-
ready: number
|
|
520
|
-
desired: number
|
|
521
|
-
kinds: Record<string, number
|
|
522
|
-
classComposition: { cls: AppWorkloadClass; count: number }[]
|
|
523
|
-
workloadClass: AppWorkloadClass
|
|
524
|
-
confidence: string
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
export type FoldedRow<T extends AppGroupFoldEntry> =
|
|
833
|
+
kind: "group";
|
|
834
|
+
key: string;
|
|
835
|
+
label: string;
|
|
836
|
+
members: T[];
|
|
837
|
+
expanded: boolean;
|
|
838
|
+
cells: AppGroupEnvCell[];
|
|
839
|
+
lag: string | null;
|
|
840
|
+
health: AppHealth;
|
|
841
|
+
ready: number;
|
|
842
|
+
desired: number;
|
|
843
|
+
kinds: Record<string, number>;
|
|
844
|
+
classComposition: { cls: AppWorkloadClass; count: number }[];
|
|
845
|
+
workloadClass: AppWorkloadClass;
|
|
846
|
+
confidence: string;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export type FoldedRow<T extends AppGroupFoldEntry> =
|
|
850
|
+
FoldedAppGroupRow<T> | { kind: "instance"; entry: T; child?: boolean };
|
|
528
851
|
|
|
529
852
|
export interface FoldAppGroupsOptions<T extends AppGroupFoldEntry> {
|
|
530
853
|
/** Scope for non-portable identities. OSS leaves this empty; fleet callers
|
|
531
854
|
* should include the cluster id so local name/repo evidence cannot merge
|
|
532
855
|
* unrelated clusters. Portable identities ignore the scope. */
|
|
533
|
-
localScope?: (entry: T) => string | undefined
|
|
856
|
+
localScope?: (entry: T) => string | undefined;
|
|
534
857
|
/** Per-member env slices for the env ladder. A fleet member spans several
|
|
535
858
|
* per-cluster envs, so the host supplies them (cluster-coverage derived,
|
|
536
859
|
* authoritative) rather than the single `identity.env` — which the hub can
|
|
537
860
|
* stale when it joins the same overlay key across clusters. Defaults to the
|
|
538
861
|
* member's single identity env. */
|
|
539
|
-
envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }
|
|
862
|
+
envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>;
|
|
540
863
|
}
|
|
541
864
|
|
|
542
865
|
export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
@@ -546,78 +869,105 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
|
546
869
|
options: FoldAppGroupsOptions<T> = {},
|
|
547
870
|
): FoldedRow<T>[] {
|
|
548
871
|
const newest = (e: T): string | undefined =>
|
|
549
|
-
e.versions.reduce<string | undefined>(
|
|
872
|
+
e.versions.reduce<string | undefined>(
|
|
873
|
+
(best, v) => (!best || compareVersions(v, best) === 1 ? v : best),
|
|
874
|
+
undefined,
|
|
875
|
+
) ?? e.row.appVersion;
|
|
550
876
|
const groupKey = (e: T): string | null => {
|
|
551
|
-
const id = e.row.identity
|
|
552
|
-
if (!id) return null
|
|
553
|
-
const scope = options.localScope?.(e)
|
|
554
|
-
if (!scope) return id.key
|
|
877
|
+
const id = e.row.identity;
|
|
878
|
+
if (!id) return null;
|
|
879
|
+
const scope = options.localScope?.(e);
|
|
880
|
+
if (!scope) return id.key;
|
|
555
881
|
// A declared-PATH identity displays its name stem as the key but carries the
|
|
556
882
|
// path stem in pathKey; two different apps can share a name while declaring
|
|
557
883
|
// different paths, so disambiguate the portable cross-cluster fold by pathKey.
|
|
558
|
-
return id.portable
|
|
559
|
-
|
|
560
|
-
|
|
884
|
+
return id.portable
|
|
885
|
+
? `portable:${id.key}${id.pathKey ? `:${id.pathKey}` : ""}`
|
|
886
|
+
: `local:${scope}:${id.key}`;
|
|
887
|
+
};
|
|
888
|
+
const byGroup = new Map<string, T[]>();
|
|
561
889
|
for (const e of entries) {
|
|
562
|
-
const k = groupKey(e)
|
|
563
|
-
if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e])
|
|
890
|
+
const k = groupKey(e);
|
|
891
|
+
if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e]);
|
|
564
892
|
}
|
|
565
|
-
const emitted = new Set<string>()
|
|
566
|
-
const out: FoldedRow<T>[] = []
|
|
893
|
+
const emitted = new Set<string>();
|
|
894
|
+
const out: FoldedRow<T>[] = [];
|
|
567
895
|
for (const e of entries) {
|
|
568
|
-
const id = e.row.identity
|
|
569
|
-
const k = groupKey(e)
|
|
896
|
+
const id = e.row.identity;
|
|
897
|
+
const k = groupKey(e);
|
|
570
898
|
// A group needs ≥2 SURVIVING members — filters can orphan one, which
|
|
571
899
|
// then renders as the plain instance it is.
|
|
572
900
|
if (!id || !k || (byGroup.get(k)?.length ?? 0) < 2) {
|
|
573
|
-
out.push({ kind:
|
|
574
|
-
continue
|
|
901
|
+
out.push({ kind: "instance", entry: e });
|
|
902
|
+
continue;
|
|
575
903
|
}
|
|
576
|
-
if (emitted.has(k)) continue
|
|
577
|
-
emitted.add(k)
|
|
578
|
-
const members = byGroup.get(k)
|
|
579
|
-
|
|
580
|
-
const cellMap = new Map<string, AppGroupEnvCell>()
|
|
581
|
-
const kinds: Record<string, number> = {}
|
|
582
|
-
const compMap = new Map<AppWorkloadClass, number>()
|
|
583
|
-
let ready = 0
|
|
584
|
-
let desired = 0
|
|
904
|
+
if (emitted.has(k)) continue;
|
|
905
|
+
emitted.add(k);
|
|
906
|
+
const members = byGroup.get(k)!;
|
|
907
|
+
|
|
908
|
+
const cellMap = new Map<string, AppGroupEnvCell>();
|
|
909
|
+
const kinds: Record<string, number> = {};
|
|
910
|
+
const compMap = new Map<AppWorkloadClass, number>();
|
|
911
|
+
let ready = 0;
|
|
912
|
+
let desired = 0;
|
|
585
913
|
// Seed with the most-benign tier (rank 0) so the max-fold below has a valid
|
|
586
914
|
// identity now that `unknown` ranks above healthy — see worstHealth.
|
|
587
|
-
let health: AppHealth =
|
|
915
|
+
let health: AppHealth = "neutral";
|
|
588
916
|
for (const m of members) {
|
|
589
|
-
const v = newest(m)
|
|
917
|
+
const v = newest(m);
|
|
590
918
|
// A fleet member spans several per-cluster envs; the host supplies them so
|
|
591
919
|
// the ladder reflects every env, not just the member's (possibly stale)
|
|
592
920
|
// single identity env. Default: the one identity env.
|
|
593
|
-
const slices = options.envsOf?.(m) ?? [
|
|
921
|
+
const slices = options.envsOf?.(m) ?? [
|
|
922
|
+
{ env: m.row.identity!.env, health: m.health },
|
|
923
|
+
];
|
|
594
924
|
for (const slice of slices) {
|
|
595
|
-
const cur = cellMap.get(slice.env)
|
|
925
|
+
const cur = cellMap.get(slice.env);
|
|
596
926
|
if (!cur) {
|
|
597
|
-
cellMap.set(slice.env, {
|
|
927
|
+
cellMap.set(slice.env, {
|
|
928
|
+
env: slice.env,
|
|
929
|
+
health: slice.health,
|
|
930
|
+
version: v,
|
|
931
|
+
count: 1,
|
|
932
|
+
firstKey: m.row.key,
|
|
933
|
+
});
|
|
598
934
|
} else {
|
|
599
|
-
cur.count
|
|
600
|
-
if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0))
|
|
601
|
-
|
|
935
|
+
cur.count++;
|
|
936
|
+
if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0))
|
|
937
|
+
cur.health = slice.health;
|
|
938
|
+
if (v && (!cur.version || compareVersions(v, cur.version) === 1))
|
|
939
|
+
cur.version = v;
|
|
602
940
|
}
|
|
603
941
|
}
|
|
604
|
-
if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0))
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
for (const
|
|
942
|
+
if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0))
|
|
943
|
+
health = m.health;
|
|
944
|
+
ready += m.ready;
|
|
945
|
+
desired += m.desired;
|
|
946
|
+
for (const [k, n] of Object.entries(m.kinds))
|
|
947
|
+
kinds[k] = (kinds[k] ?? 0) + n;
|
|
948
|
+
for (const c of m.classComposition)
|
|
949
|
+
compMap.set(c.cls, (compMap.get(c.cls) ?? 0) + c.count);
|
|
609
950
|
}
|
|
610
|
-
const cells = orderEnvs([...cellMap.keys()]).map((env) =>
|
|
611
|
-
|
|
612
|
-
|
|
951
|
+
const cells = orderEnvs([...cellMap.keys()]).map((env) =>
|
|
952
|
+
cellMap.get(env)!,
|
|
953
|
+
);
|
|
954
|
+
const classComposition = CLASS_ORDER.filter((c) => compMap.has(c)).map(
|
|
955
|
+
(c) => ({ cls: c, count: compMap.get(c)! }),
|
|
956
|
+
);
|
|
957
|
+
const known = classComposition
|
|
958
|
+
.map((c) => c.cls)
|
|
959
|
+
.filter((c) => c !== "unknown");
|
|
613
960
|
const workloadClass: AppWorkloadClass =
|
|
614
|
-
known.length === 0
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
961
|
+
known.length === 0
|
|
962
|
+
? "unknown"
|
|
963
|
+
: known.includes("service") && !known.includes("job")
|
|
964
|
+
? "service"
|
|
965
|
+
: known.length === 1
|
|
966
|
+
? known[0]
|
|
967
|
+
: "mixed";
|
|
968
|
+
const expanded = autoExpand || expandedKeys.has(k);
|
|
619
969
|
out.push({
|
|
620
|
-
kind:
|
|
970
|
+
kind: "group",
|
|
621
971
|
key: k,
|
|
622
972
|
label: id.key,
|
|
623
973
|
members,
|
|
@@ -630,21 +980,27 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
|
630
980
|
kinds,
|
|
631
981
|
classComposition,
|
|
632
982
|
workloadClass,
|
|
633
|
-
confidence: members.some((m) => m.row.identity!.confidence ===
|
|
634
|
-
|
|
983
|
+
confidence: members.some((m) => m.row.identity!.confidence === "high")
|
|
984
|
+
? "high"
|
|
985
|
+
: "medium",
|
|
986
|
+
});
|
|
635
987
|
if (expanded) {
|
|
636
|
-
for (const m of members)
|
|
988
|
+
for (const m of members)
|
|
989
|
+
out.push({ kind: "instance", entry: m, child: true });
|
|
637
990
|
}
|
|
638
991
|
}
|
|
639
|
-
return out
|
|
992
|
+
return out;
|
|
640
993
|
}
|
|
641
994
|
|
|
642
995
|
/** Normalize a wire health string to the AppHealth union (the health twin of
|
|
643
996
|
* workloadClassOf — keeps `as AppHealth` casts out of components). */
|
|
644
997
|
export function healthOf(value: string | undefined): AppHealth {
|
|
645
|
-
return value ===
|
|
998
|
+
return value === "unhealthy" ||
|
|
999
|
+
value === "degraded" ||
|
|
1000
|
+
value === "healthy" ||
|
|
1001
|
+
value === "neutral"
|
|
646
1002
|
? value
|
|
647
|
-
:
|
|
1003
|
+
: "unknown";
|
|
648
1004
|
}
|
|
649
1005
|
|
|
650
1006
|
// -----------------------------------------------------------------------------
|
|
@@ -658,16 +1014,28 @@ export function healthOf(value: string | undefined): AppHealth {
|
|
|
658
1014
|
// healthy (a node-lost workload is worse than a running one); `neutral`
|
|
659
1015
|
// (intentional/idle) is the most-benign tier, so an all-idle app rolls up to
|
|
660
1016
|
// "Idle" while a mixed healthy+idle app still reads Healthy (healthy out-ranks
|
|
661
|
-
// neutral in the max).
|
|
662
|
-
//
|
|
663
|
-
export const HEALTH_ORDER: AppHealth[] = [
|
|
664
|
-
|
|
1017
|
+
// neutral in the max). `unknown` must outrank `healthy` here to agree with the
|
|
1018
|
+
// backend rollup — a node-lost workload can't read as healthy.
|
|
1019
|
+
export const HEALTH_ORDER: AppHealth[] = [
|
|
1020
|
+
"unhealthy",
|
|
1021
|
+
"degraded",
|
|
1022
|
+
"unknown",
|
|
1023
|
+
"healthy",
|
|
1024
|
+
"neutral",
|
|
1025
|
+
];
|
|
1026
|
+
export const HEALTH_RANK: Record<string, number> = {
|
|
1027
|
+
unhealthy: 4,
|
|
1028
|
+
degraded: 3,
|
|
1029
|
+
unknown: 2,
|
|
1030
|
+
healthy: 1,
|
|
1031
|
+
neutral: 0,
|
|
1032
|
+
};
|
|
665
1033
|
|
|
666
1034
|
export interface HealthMeta {
|
|
667
|
-
label: string
|
|
668
|
-
bar: string
|
|
669
|
-
text: string
|
|
670
|
-
pill: string
|
|
1035
|
+
label: string;
|
|
1036
|
+
bar: string;
|
|
1037
|
+
text: string;
|
|
1038
|
+
pill: string;
|
|
671
1039
|
}
|
|
672
1040
|
|
|
673
1041
|
// ─── Chip dialect ────────────────────────────────────────────────────────────
|
|
@@ -676,48 +1044,114 @@ export interface HealthMeta {
|
|
|
676
1044
|
// standalone status pills. A local dialect, but defined ONCE here: call sites
|
|
677
1045
|
// compose `CHIP` (chrome) + a `CHIP_TONE` (color), never inline the strings.
|
|
678
1046
|
// Literal class strings are required for Tailwind's content scanner.
|
|
679
|
-
export const CHIP =
|
|
1047
|
+
export const CHIP =
|
|
1048
|
+
"inline-flex items-center rounded-sm px-1.5 py-px text-[10px] font-medium ring-1 ring-inset";
|
|
680
1049
|
export const CHIP_TONE = {
|
|
681
|
-
rose:
|
|
682
|
-
amber:
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1050
|
+
rose: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900",
|
|
1051
|
+
amber:
|
|
1052
|
+
"bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900",
|
|
1053
|
+
emerald:
|
|
1054
|
+
"bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900",
|
|
1055
|
+
blue: "bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900",
|
|
1056
|
+
sky: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900",
|
|
1057
|
+
violet:
|
|
1058
|
+
"bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900",
|
|
1059
|
+
neutral: "bg-theme-hover text-theme-text-secondary ring-theme-border",
|
|
1060
|
+
muted: "bg-theme-hover text-theme-text-tertiary ring-theme-border",
|
|
1061
|
+
} as const;
|
|
690
1062
|
|
|
691
1063
|
export const HEALTH_META: Record<AppHealth, HealthMeta> = {
|
|
692
|
-
unhealthy: {
|
|
693
|
-
|
|
694
|
-
|
|
1064
|
+
unhealthy: {
|
|
1065
|
+
label: "Unhealthy",
|
|
1066
|
+
bar: "bg-rose-500",
|
|
1067
|
+
text: "text-rose-600 dark:text-rose-400",
|
|
1068
|
+
pill: CHIP_TONE.rose,
|
|
1069
|
+
},
|
|
1070
|
+
degraded: {
|
|
1071
|
+
label: "Degraded",
|
|
1072
|
+
bar: "bg-amber-500",
|
|
1073
|
+
text: "text-amber-600 dark:text-amber-400",
|
|
1074
|
+
pill: CHIP_TONE.amber,
|
|
1075
|
+
},
|
|
1076
|
+
healthy: {
|
|
1077
|
+
label: "Healthy",
|
|
1078
|
+
bar: "bg-emerald-500",
|
|
1079
|
+
text: "text-emerald-600 dark:text-emerald-400",
|
|
1080
|
+
pill: CHIP_TONE.emerald,
|
|
1081
|
+
},
|
|
695
1082
|
// neutral = intentionally idle/off (every workload suspended or scaled to 0).
|
|
696
1083
|
// Sky, calm — not "Healthy" (it isn't serving) and not a problem to act on.
|
|
697
|
-
neutral: {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
1084
|
+
neutral: {
|
|
1085
|
+
label: "Idle",
|
|
1086
|
+
bar: "bg-sky-500",
|
|
1087
|
+
text: "text-sky-600 dark:text-sky-400",
|
|
1088
|
+
pill: CHIP_TONE.sky,
|
|
1089
|
+
},
|
|
1090
|
+
unknown: {
|
|
1091
|
+
label: "Unknown",
|
|
1092
|
+
bar: "bg-slate-400",
|
|
1093
|
+
text: "text-theme-text-tertiary",
|
|
1094
|
+
pill: CHIP_TONE.muted,
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
export const CLASS_ORDER: AppWorkloadClass[] = [
|
|
1099
|
+
"service",
|
|
1100
|
+
"worker",
|
|
1101
|
+
"job",
|
|
1102
|
+
"unknown",
|
|
1103
|
+
];
|
|
1104
|
+
|
|
1105
|
+
export const CLASS_META: Record<
|
|
1106
|
+
AppWorkloadClass,
|
|
1107
|
+
{ label: string; pill: string; tooltip: string }
|
|
1108
|
+
> = {
|
|
1109
|
+
service: {
|
|
1110
|
+
label: "Service",
|
|
1111
|
+
pill: CHIP_TONE.blue,
|
|
1112
|
+
tooltip:
|
|
1113
|
+
"Long-running, request-serving (a Deployment/StatefulSet behind a Service/Ingress/route). Inferred from the workload shape + routing.",
|
|
1114
|
+
},
|
|
1115
|
+
worker: {
|
|
1116
|
+
label: "Worker",
|
|
1117
|
+
pill: CHIP_TONE.violet,
|
|
1118
|
+
tooltip:
|
|
1119
|
+
"Long-running background processor (no serving edge). Inferred from the workload shape.",
|
|
1120
|
+
},
|
|
1121
|
+
job: {
|
|
1122
|
+
label: "Job",
|
|
1123
|
+
pill: CHIP_TONE.amber,
|
|
1124
|
+
tooltip:
|
|
1125
|
+
"Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow/WorkflowTemplate/ClusterWorkflowTemplate/ScaledJob).",
|
|
1126
|
+
},
|
|
1127
|
+
mixed: {
|
|
1128
|
+
label: "Mixed",
|
|
1129
|
+
pill: CHIP_TONE.neutral,
|
|
1130
|
+
tooltip:
|
|
1131
|
+
"Contains workloads of more than one class (e.g. a service plus its scheduled jobs).",
|
|
1132
|
+
},
|
|
1133
|
+
unknown: {
|
|
1134
|
+
label: "Unknown",
|
|
1135
|
+
pill: CHIP_TONE.muted,
|
|
1136
|
+
tooltip: "Couldn't infer a runtime class from the workload.",
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
710
1139
|
|
|
711
1140
|
/** Per-class workload counts for an app, in CLASS_ORDER — the composition
|
|
712
1141
|
* behind a "Mixed" badge and the inclusive Class facet (filtering "Service"
|
|
713
1142
|
* matches mixed apps that contain a service). */
|
|
714
|
-
export function classCompositionOf(
|
|
715
|
-
|
|
1143
|
+
export function classCompositionOf(
|
|
1144
|
+
app: AppRow,
|
|
1145
|
+
): { cls: AppWorkloadClass; count: number }[] {
|
|
1146
|
+
const counts = new Map<AppWorkloadClass, number>();
|
|
716
1147
|
for (const w of app.workloads || []) {
|
|
717
|
-
const c = workloadClassOf(w.workload_class)
|
|
718
|
-
counts.set(c, (counts.get(c) ?? 0) + 1)
|
|
1148
|
+
const c = workloadClassOf(w.workload_class);
|
|
1149
|
+
counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
719
1150
|
}
|
|
720
|
-
return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({
|
|
1151
|
+
return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({
|
|
1152
|
+
cls: c,
|
|
1153
|
+
count: counts.get(c)!,
|
|
1154
|
+
}));
|
|
721
1155
|
}
|
|
722
1156
|
|
|
723
1157
|
/** The distinct KNOWN classes an app contains — the facet-matching set. Falls
|
|
@@ -725,20 +1159,358 @@ export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count:
|
|
|
725
1159
|
export function classSetOf(app: AppRow): AppWorkloadClass[] {
|
|
726
1160
|
const known = classCompositionOf(app)
|
|
727
1161
|
.map((c) => c.cls)
|
|
728
|
-
.filter((c) => c ===
|
|
729
|
-
if (known.length > 0) return known
|
|
730
|
-
return [workloadClassOf(app.workload_class)]
|
|
1162
|
+
.filter((c) => c === "service" || c === "worker" || c === "job");
|
|
1163
|
+
if (known.length > 0) return known;
|
|
1164
|
+
return [workloadClassOf(app.workload_class)];
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export interface BatchSignal {
|
|
1168
|
+
tone: "rose" | "amber" | "sky" | "emerald" | "muted";
|
|
1169
|
+
label: string;
|
|
1170
|
+
detail: string;
|
|
1171
|
+
workload: AppWorkload;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export interface AppBatchActivity {
|
|
1175
|
+
workload: AppWorkload;
|
|
1176
|
+
tone: BatchSignal["tone"];
|
|
1177
|
+
rank: number;
|
|
1178
|
+
label: string;
|
|
1179
|
+
detail: string;
|
|
1180
|
+
latestRunName?: string;
|
|
1181
|
+
latestRunPhase?: string;
|
|
1182
|
+
latestStartedAt?: string;
|
|
1183
|
+
latestFinishedAt?: string;
|
|
1184
|
+
schedule?: string;
|
|
1185
|
+
activeRuns: number;
|
|
1186
|
+
retainedRuns: number;
|
|
1187
|
+
failedRuns: number;
|
|
1188
|
+
lastScheduledAt?: string;
|
|
1189
|
+
lastSuccessfulAt?: string;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
export function batchActivityForApp(app: AppRow): AppBatchActivity[] {
|
|
1193
|
+
return (app.workloads || [])
|
|
1194
|
+
.map(batchActivityForWorkload)
|
|
1195
|
+
.filter((activity): activity is AppBatchActivity => Boolean(activity))
|
|
1196
|
+
.sort(
|
|
1197
|
+
(a, b) =>
|
|
1198
|
+
b.rank - a.rank || a.workload.name.localeCompare(b.workload.name),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export function batchSignalForApp(app: AppRow): BatchSignal | null {
|
|
1203
|
+
let best: BatchSignal | null = null;
|
|
1204
|
+
for (const workload of app.workloads || []) {
|
|
1205
|
+
const signal = batchSignalForWorkload(workload);
|
|
1206
|
+
if (!signal) continue;
|
|
1207
|
+
if (!best || batchSignalRank(signal) > batchSignalRank(best)) best = signal;
|
|
1208
|
+
}
|
|
1209
|
+
return best;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function batchSignalForWorkload(workload: AppWorkload): BatchSignal | null {
|
|
1213
|
+
const batch = workload.batch;
|
|
1214
|
+
if (!batch) return null;
|
|
1215
|
+
const latestPhase = batch.latestRunPhase || "";
|
|
1216
|
+
const name = `${workload.kind}/${workload.name}`;
|
|
1217
|
+
if (latestPhase === "Failed" || latestPhase === "Error") {
|
|
1218
|
+
return {
|
|
1219
|
+
tone: "rose",
|
|
1220
|
+
label: "batch failed",
|
|
1221
|
+
detail:
|
|
1222
|
+
batch.message ||
|
|
1223
|
+
`${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
|
|
1224
|
+
workload,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (batch.suspended) {
|
|
1228
|
+
return {
|
|
1229
|
+
tone: "sky",
|
|
1230
|
+
label: "schedule suspended",
|
|
1231
|
+
detail: `${name} will not create new runs until resumed.`,
|
|
1232
|
+
workload,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
if ((batch.activeRuns ?? 0) > 0) {
|
|
1236
|
+
return {
|
|
1237
|
+
tone: "sky",
|
|
1238
|
+
label: `${batch.activeRuns} running`,
|
|
1239
|
+
detail: `${name} currently has ${batch.activeRuns} active run${batch.activeRuns === 1 ? "" : "s"}.`,
|
|
1240
|
+
workload,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
export interface AppBatchRuntime {
|
|
1247
|
+
label: string;
|
|
1248
|
+
health: AppHealth;
|
|
1249
|
+
detail: string;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export function batchRuntimeForApp(app: AppRow): AppBatchRuntime {
|
|
1253
|
+
const activity = batchActivityForApp(app);
|
|
1254
|
+
if (
|
|
1255
|
+
activity.some(
|
|
1256
|
+
(item) => item.activeRuns > 0 || item.latestRunPhase === "Running",
|
|
1257
|
+
)
|
|
1258
|
+
) {
|
|
1259
|
+
const active = activity.reduce((sum, item) => sum + item.activeRuns, 0);
|
|
1260
|
+
return {
|
|
1261
|
+
label: active > 0 ? `${active} running` : "Running",
|
|
1262
|
+
health: "neutral",
|
|
1263
|
+
detail: "Batch work is executing now.",
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const failed = activity.filter(
|
|
1267
|
+
(item) =>
|
|
1268
|
+
item.latestRunPhase === "Failed" || item.latestRunPhase === "Error",
|
|
1269
|
+
);
|
|
1270
|
+
if (failed.length > 0) {
|
|
1271
|
+
const item = failed[0];
|
|
1272
|
+
const detail =
|
|
1273
|
+
failed.length === 1
|
|
1274
|
+
? `${item.workload.kind}/${item.workload.name} latest retained run${item.latestRunName ? ` ${item.latestRunName}` : ""} failed.`
|
|
1275
|
+
: `${failed.length} batch workloads have failed latest runs.`;
|
|
1276
|
+
return { label: "Failed", health: "unhealthy", detail };
|
|
1277
|
+
}
|
|
1278
|
+
const latest = [...activity].sort(
|
|
1279
|
+
(a, b) => batchActivityTimestamp(b) - batchActivityTimestamp(a),
|
|
1280
|
+
)[0];
|
|
1281
|
+
if (latest?.latestRunPhase === "Succeeded") {
|
|
1282
|
+
return {
|
|
1283
|
+
label: "Succeeded",
|
|
1284
|
+
health: "healthy",
|
|
1285
|
+
detail: latest.latestRunName
|
|
1286
|
+
? `Latest retained run: ${latest.latestRunName}`
|
|
1287
|
+
: "The latest retained run succeeded.",
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if (
|
|
1291
|
+
activity.length > 0 &&
|
|
1292
|
+
activity.every((item) => item.workload.batch?.suspended)
|
|
1293
|
+
) {
|
|
1294
|
+
return {
|
|
1295
|
+
label: "Suspended",
|
|
1296
|
+
health: "neutral",
|
|
1297
|
+
detail: "All batch launchers are suspended.",
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
if (activity.some((item) => item.retainedRuns > 0)) {
|
|
1301
|
+
return {
|
|
1302
|
+
label: latest?.latestRunPhase || "Idle",
|
|
1303
|
+
health: "neutral",
|
|
1304
|
+
detail: "No batch work is currently active.",
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
return {
|
|
1308
|
+
label: "Idle",
|
|
1309
|
+
health: "neutral",
|
|
1310
|
+
detail: "No retained runs are present in Kubernetes.",
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function batchActivityTimestamp(item: AppBatchActivity): number {
|
|
1315
|
+
const raw =
|
|
1316
|
+
item.latestStartedAt ||
|
|
1317
|
+
item.latestFinishedAt ||
|
|
1318
|
+
item.lastScheduledAt ||
|
|
1319
|
+
item.lastSuccessfulAt;
|
|
1320
|
+
if (!raw) return 0;
|
|
1321
|
+
const parsed = Date.parse(raw);
|
|
1322
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function applicationRuntimeHealth(app: AppRow): AppHealth {
|
|
1326
|
+
if (workloadClassOf(app.workload_class) === "job")
|
|
1327
|
+
return batchRuntimeForApp(app).health;
|
|
1328
|
+
const serving = (app.workloads || []).filter((workload) => !workload.batch);
|
|
1329
|
+
return serving.length > 0
|
|
1330
|
+
? worstHealth(serving.map((workload) => workload.health))
|
|
1331
|
+
: healthOf(app.health);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export function sourceSyncHealth(status: string): AppHealth {
|
|
1335
|
+
const normalized = status.toLowerCase();
|
|
1336
|
+
if (normalized === "synced") return "healthy";
|
|
1337
|
+
if (normalized === "outofsync") return "degraded";
|
|
1338
|
+
return "unknown";
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
export function sourceReportedHealth(status: string): AppHealth {
|
|
1342
|
+
const normalized = status.toLowerCase();
|
|
1343
|
+
if (normalized === "healthy") return "healthy";
|
|
1344
|
+
if (normalized === "progressing") return "neutral";
|
|
1345
|
+
if (normalized === "degraded") return "degraded";
|
|
1346
|
+
if (normalized === "missing") return "unhealthy";
|
|
1347
|
+
if (normalized === "suspended") return "neutral";
|
|
1348
|
+
return "unknown";
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function applicationDeliveryHealth(status?: AppSourceStatus): AppHealth {
|
|
1352
|
+
if (!status) return "neutral";
|
|
1353
|
+
const signals = [
|
|
1354
|
+
status.sync ? sourceSyncHealth(status.sync) : null,
|
|
1355
|
+
status.health ? sourceReportedHealth(status.health) : null,
|
|
1356
|
+
].filter((health): health is AppHealth => health !== null && health !== "unknown");
|
|
1357
|
+
return signals.length > 0 ? worstHealth(signals) : "neutral";
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
export function applicationDisplayHealth(app: AppRow): AppHealth {
|
|
1361
|
+
return worstHealth([
|
|
1362
|
+
applicationRuntimeHealth(app),
|
|
1363
|
+
applicationDeliveryHealth(app.sourceStatus),
|
|
1364
|
+
]);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
export function servingReadiness(workloads: AppWorkload[]): {
|
|
1368
|
+
ready: number;
|
|
1369
|
+
desired: number;
|
|
1370
|
+
} {
|
|
1371
|
+
let ready = 0;
|
|
1372
|
+
let desired = 0;
|
|
1373
|
+
for (const workload of workloads) {
|
|
1374
|
+
const cls = workloadClassOf(workload.workload_class);
|
|
1375
|
+
if (cls === "job" || workload.batch) continue;
|
|
1376
|
+
ready += workload.ready ?? 0;
|
|
1377
|
+
desired += workload.desired ?? 0;
|
|
1378
|
+
}
|
|
1379
|
+
return { ready, desired };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function batchActivityForWorkload(
|
|
1383
|
+
workload: AppWorkload,
|
|
1384
|
+
): AppBatchActivity | null {
|
|
1385
|
+
const batch = workload.batch;
|
|
1386
|
+
if (!batch) return null;
|
|
1387
|
+
const latestPhase = batch.latestRunPhase || "";
|
|
1388
|
+
const name = `${workload.kind}/${workload.name}`;
|
|
1389
|
+
const activeRuns = batch.activeRuns ?? 0;
|
|
1390
|
+
const retainedRuns = batch.retainedRuns ?? 0;
|
|
1391
|
+
const failedRuns = batch.failedRuns ?? 0;
|
|
1392
|
+
if (latestPhase === "Failed" || latestPhase === "Error") {
|
|
1393
|
+
return {
|
|
1394
|
+
workload,
|
|
1395
|
+
tone: "rose",
|
|
1396
|
+
rank: 50,
|
|
1397
|
+
label: "Latest run failed",
|
|
1398
|
+
detail:
|
|
1399
|
+
batch.message ||
|
|
1400
|
+
`${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
|
|
1401
|
+
latestRunName: batch.latestRunName,
|
|
1402
|
+
latestRunPhase: latestPhase,
|
|
1403
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1404
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1405
|
+
schedule: batch.schedule,
|
|
1406
|
+
activeRuns,
|
|
1407
|
+
retainedRuns,
|
|
1408
|
+
failedRuns,
|
|
1409
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1410
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
if (failedRuns > 0) {
|
|
1414
|
+
return {
|
|
1415
|
+
workload,
|
|
1416
|
+
tone: "amber",
|
|
1417
|
+
rank: 40,
|
|
1418
|
+
label: `${failedRuns} retained failed ${failedRuns === 1 ? "run" : "runs"}`,
|
|
1419
|
+
detail: `${name} has retained failed executions in Kubernetes.`,
|
|
1420
|
+
latestRunName: batch.latestRunName,
|
|
1421
|
+
latestRunPhase: latestPhase,
|
|
1422
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1423
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1424
|
+
schedule: batch.schedule,
|
|
1425
|
+
activeRuns,
|
|
1426
|
+
retainedRuns,
|
|
1427
|
+
failedRuns,
|
|
1428
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1429
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
if (activeRuns > 0) {
|
|
1433
|
+
return {
|
|
1434
|
+
workload,
|
|
1435
|
+
tone: "sky",
|
|
1436
|
+
rank: 35,
|
|
1437
|
+
label: `${activeRuns} active ${activeRuns === 1 ? "run" : "runs"}`,
|
|
1438
|
+
detail: `${name} is executing now.`,
|
|
1439
|
+
latestRunName: batch.latestRunName,
|
|
1440
|
+
latestRunPhase: latestPhase,
|
|
1441
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1442
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1443
|
+
schedule: batch.schedule,
|
|
1444
|
+
activeRuns,
|
|
1445
|
+
retainedRuns,
|
|
1446
|
+
failedRuns,
|
|
1447
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1448
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
if (batch.suspended) {
|
|
1452
|
+
return {
|
|
1453
|
+
workload,
|
|
1454
|
+
tone: "sky",
|
|
1455
|
+
rank: 30,
|
|
1456
|
+
label: "Schedule suspended",
|
|
1457
|
+
detail: `${name} will not create new runs until resumed.`,
|
|
1458
|
+
latestRunName: batch.latestRunName,
|
|
1459
|
+
latestRunPhase: latestPhase,
|
|
1460
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1461
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1462
|
+
schedule: batch.schedule,
|
|
1463
|
+
activeRuns,
|
|
1464
|
+
retainedRuns,
|
|
1465
|
+
failedRuns,
|
|
1466
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1467
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
workload,
|
|
1472
|
+
tone: "muted",
|
|
1473
|
+
rank: latestPhase === "Succeeded" ? 20 : 10,
|
|
1474
|
+
label:
|
|
1475
|
+
latestPhase === "Succeeded" ? "Latest run succeeded" : "No active runs",
|
|
1476
|
+
detail: `${name} has ${retainedRuns} retained ${retainedRuns === 1 ? "run" : "runs"}.`,
|
|
1477
|
+
latestRunName: batch.latestRunName,
|
|
1478
|
+
latestRunPhase: latestPhase,
|
|
1479
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1480
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1481
|
+
schedule: batch.schedule,
|
|
1482
|
+
activeRuns,
|
|
1483
|
+
retainedRuns,
|
|
1484
|
+
failedRuns,
|
|
1485
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1486
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function batchSignalRank(signal: BatchSignal): number {
|
|
1491
|
+
switch (signal.tone) {
|
|
1492
|
+
case "rose":
|
|
1493
|
+
return 5;
|
|
1494
|
+
case "amber":
|
|
1495
|
+
return 4;
|
|
1496
|
+
case "sky":
|
|
1497
|
+
return 3;
|
|
1498
|
+
case "emerald":
|
|
1499
|
+
return 2;
|
|
1500
|
+
default:
|
|
1501
|
+
return 1;
|
|
1502
|
+
}
|
|
731
1503
|
}
|
|
732
1504
|
|
|
733
1505
|
export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
|
|
734
1506
|
switch (value) {
|
|
735
|
-
case
|
|
736
|
-
case
|
|
737
|
-
case
|
|
738
|
-
case
|
|
739
|
-
return value
|
|
1507
|
+
case "service":
|
|
1508
|
+
case "worker":
|
|
1509
|
+
case "job":
|
|
1510
|
+
case "mixed":
|
|
1511
|
+
return value;
|
|
740
1512
|
default:
|
|
741
|
-
return
|
|
1513
|
+
return "unknown";
|
|
742
1514
|
}
|
|
743
1515
|
}
|
|
744
1516
|
|
|
@@ -749,20 +1521,21 @@ export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
|
|
|
749
1521
|
* ranks ABOVE healthy — an all-healthy set would never beat it and return
|
|
750
1522
|
* `unknown`. Mirrors pkg/health.WorseOf.) */
|
|
751
1523
|
export function worstHealth(hs: string[]): AppHealth {
|
|
752
|
-
let w: AppHealth =
|
|
753
|
-
for (const h of hs)
|
|
754
|
-
|
|
1524
|
+
let w: AppHealth = "neutral";
|
|
1525
|
+
for (const h of hs)
|
|
1526
|
+
if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth;
|
|
1527
|
+
return w;
|
|
755
1528
|
}
|
|
756
1529
|
|
|
757
1530
|
export function newestTag(versions: string[]): string | undefined {
|
|
758
|
-
let best: string | undefined
|
|
1531
|
+
let best: string | undefined;
|
|
759
1532
|
for (const v of versions) {
|
|
760
|
-
const t = v?.trim()
|
|
761
|
-
if (!t) continue
|
|
762
|
-
if (best === undefined) best = t
|
|
763
|
-
else if (compareVersions(t, best) === 1) best = t
|
|
1533
|
+
const t = v?.trim();
|
|
1534
|
+
if (!t) continue;
|
|
1535
|
+
if (best === undefined) best = t;
|
|
1536
|
+
else if (compareVersions(t, best) === 1) best = t;
|
|
764
1537
|
}
|
|
765
|
-
return best
|
|
1538
|
+
return best;
|
|
766
1539
|
}
|
|
767
1540
|
|
|
768
1541
|
// -----------------------------------------------------------------------------
|
|
@@ -775,78 +1548,85 @@ export function newestTag(versions: string[]): string | undefined {
|
|
|
775
1548
|
// -----------------------------------------------------------------------------
|
|
776
1549
|
|
|
777
1550
|
export interface AppEntryBase {
|
|
778
|
-
row: AppRow
|
|
779
|
-
health: AppHealth
|
|
780
|
-
versions: string[]
|
|
781
|
-
kinds: Record<string, number
|
|
782
|
-
workloadClass: AppWorkloadClass
|
|
1551
|
+
row: AppRow;
|
|
1552
|
+
health: AppHealth;
|
|
1553
|
+
versions: string[];
|
|
1554
|
+
kinds: Record<string, number>;
|
|
1555
|
+
workloadClass: AppWorkloadClass;
|
|
783
1556
|
/** Distinct contained classes — the inclusive facet-matching set. */
|
|
784
|
-
classSet: AppWorkloadClass[]
|
|
785
|
-
classComposition: { cls: AppWorkloadClass; count: number }[]
|
|
786
|
-
category: AppCategory
|
|
787
|
-
ready: number
|
|
788
|
-
desired: number
|
|
1557
|
+
classSet: AppWorkloadClass[];
|
|
1558
|
+
classComposition: { cls: AppWorkloadClass; count: number }[];
|
|
1559
|
+
category: AppCategory;
|
|
1560
|
+
ready: number;
|
|
1561
|
+
desired: number;
|
|
789
1562
|
/** ready/desired as a fraction for sorting; -1 when nothing is desired. */
|
|
790
|
-
readyRatio: number
|
|
791
|
-
source: AppSource
|
|
1563
|
+
readyRatio: number;
|
|
1564
|
+
source: AppSource;
|
|
792
1565
|
}
|
|
793
1566
|
|
|
794
1567
|
export interface SingleAppEntry extends AppEntryBase {
|
|
795
|
-
variant:
|
|
796
|
-
namespace: string
|
|
797
|
-
namespaces: string[]
|
|
798
|
-
env: string
|
|
799
|
-
envInferred: boolean
|
|
1568
|
+
variant: "single";
|
|
1569
|
+
namespace: string;
|
|
1570
|
+
namespaces: string[];
|
|
1571
|
+
env: string;
|
|
1572
|
+
envInferred: boolean;
|
|
800
1573
|
}
|
|
801
1574
|
|
|
802
1575
|
/** One environment instance within a fleet row (env name + worst health across
|
|
803
1576
|
* its clusters + whether the env was inferred from the namespace). */
|
|
804
1577
|
export interface EnvSlice {
|
|
805
|
-
env: string
|
|
806
|
-
health: AppHealth
|
|
807
|
-
inferred: boolean
|
|
1578
|
+
env: string;
|
|
1579
|
+
health: AppHealth;
|
|
1580
|
+
inferred: boolean;
|
|
808
1581
|
}
|
|
809
1582
|
|
|
810
1583
|
/** A cluster this fleet row's app runs in. */
|
|
811
1584
|
export interface AppClusterRef {
|
|
812
|
-
id: string
|
|
813
|
-
name: string
|
|
814
|
-
health: AppHealth
|
|
1585
|
+
id: string;
|
|
1586
|
+
name: string;
|
|
1587
|
+
health: AppHealth;
|
|
815
1588
|
}
|
|
816
1589
|
|
|
817
1590
|
export interface FleetAppEntry extends AppEntryBase {
|
|
818
|
-
variant:
|
|
819
|
-
envs: EnvSlice[]
|
|
820
|
-
clusters: AppClusterRef[]
|
|
1591
|
+
variant: "fleet";
|
|
1592
|
+
envs: EnvSlice[];
|
|
1593
|
+
clusters: AppClusterRef[];
|
|
821
1594
|
/** True when the app runs different versions across its clusters/envs. */
|
|
822
|
-
versionSkew: boolean
|
|
1595
|
+
versionSkew: boolean;
|
|
823
1596
|
}
|
|
824
1597
|
|
|
825
|
-
export type AppEntry = SingleAppEntry | FleetAppEntry
|
|
1598
|
+
export type AppEntry = SingleAppEntry | FleetAppEntry;
|
|
826
1599
|
|
|
827
1600
|
/** Build a single-cluster list entry from a wire row. The env is resolved via
|
|
828
1601
|
* the server's identity classification (authoritative) with a namespace
|
|
829
1602
|
* heuristic fallback for plain rows. */
|
|
830
|
-
export function buildSingleAppEntry(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1603
|
+
export function buildSingleAppEntry(
|
|
1604
|
+
row: AppRow,
|
|
1605
|
+
discoveredEnvs?: ReadonlySet<string>,
|
|
1606
|
+
): SingleAppEntry {
|
|
1607
|
+
const kinds: Record<string, number> = {};
|
|
1608
|
+
let ready = 0;
|
|
1609
|
+
let desired = 0;
|
|
834
1610
|
for (const wl of row.workloads || []) {
|
|
835
|
-
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
|
|
836
|
-
|
|
837
|
-
|
|
1611
|
+
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1;
|
|
1612
|
+
if (workloadClassOf(wl.workload_class) !== "job" && !wl.batch) {
|
|
1613
|
+
ready += wl.ready ?? 0;
|
|
1614
|
+
desired += wl.desired ?? 0;
|
|
1615
|
+
}
|
|
838
1616
|
}
|
|
839
|
-
const namespace = namespaceOf(row)
|
|
1617
|
+
const namespace = namespaceOf(row);
|
|
840
1618
|
// The server's identity classification carries the authoritative env (label/
|
|
841
1619
|
// declared/discovered); plain rows fall back to the trio + discovered-token
|
|
842
1620
|
// namespace heuristic.
|
|
843
|
-
const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
844
|
-
const env = row.identity?.env ?? resolved.env
|
|
845
|
-
const inferred = row.identity
|
|
1621
|
+
const resolved = resolveEnv(undefined, namespace, discoveredEnvs);
|
|
1622
|
+
const env = row.identity?.env ?? resolved.env;
|
|
1623
|
+
const inferred = row.identity
|
|
1624
|
+
? identityEnvInferred(row.identity)
|
|
1625
|
+
: resolved.inferred;
|
|
846
1626
|
return {
|
|
847
|
-
variant:
|
|
1627
|
+
variant: "single",
|
|
848
1628
|
row,
|
|
849
|
-
health:
|
|
1629
|
+
health: applicationDisplayHealth(row),
|
|
850
1630
|
versions: Array.from(new Set((row.versions || []).filter(Boolean))),
|
|
851
1631
|
namespace,
|
|
852
1632
|
namespaces: namespacesOf(row),
|
|
@@ -861,16 +1641,25 @@ export function buildSingleAppEntry(row: AppRow, discoveredEnvs?: ReadonlySet<st
|
|
|
861
1641
|
desired,
|
|
862
1642
|
readyRatio: desired > 0 ? ready / desired : -1,
|
|
863
1643
|
source: sourceOf(row.tier),
|
|
864
|
-
}
|
|
1644
|
+
};
|
|
865
1645
|
}
|
|
866
1646
|
|
|
867
1647
|
/** The text an entry matches free-text search against — name, key, namespace,
|
|
868
1648
|
* source/class/category labels, versions, env, workload kinds + identity. Pure
|
|
869
1649
|
* and exported so the list core and tests share one definition. */
|
|
870
1650
|
export function searchTextForEntry(e: AppEntry): string {
|
|
871
|
-
const workloadText = (e.row.workloads || []).flatMap((wl) => [
|
|
872
|
-
|
|
873
|
-
|
|
1651
|
+
const workloadText = (e.row.workloads || []).flatMap((wl) => [
|
|
1652
|
+
wl.kind,
|
|
1653
|
+
wl.namespace,
|
|
1654
|
+
wl.name,
|
|
1655
|
+
wl.version,
|
|
1656
|
+
]);
|
|
1657
|
+
const envParts =
|
|
1658
|
+
e.variant === "single"
|
|
1659
|
+
? [e.env || "unlabeled"]
|
|
1660
|
+
: e.envs.map((s) => s.env || "unlabeled");
|
|
1661
|
+
const nsParts =
|
|
1662
|
+
e.variant === "single" ? [e.namespace] : e.clusters.map((c) => c.name);
|
|
874
1663
|
return [
|
|
875
1664
|
e.row.name,
|
|
876
1665
|
e.row.key,
|
|
@@ -885,6 +1674,6 @@ export function searchTextForEntry(e: AppEntry): string {
|
|
|
885
1674
|
...workloadText,
|
|
886
1675
|
]
|
|
887
1676
|
.filter(Boolean)
|
|
888
|
-
.join(
|
|
889
|
-
.toLowerCase()
|
|
1677
|
+
.join(" ")
|
|
1678
|
+
.toLowerCase();
|
|
890
1679
|
}
|