@skyhook-io/k8s-ui 1.8.9 → 1.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -1,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,119 @@ 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
|
+
for (const mk of row.matchKeys || []) {
|
|
645
|
+
if (mk.startsWith("name-stem:")) continue;
|
|
646
|
+
setFirst(byEvidence, mk, membership);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return { byResource, byEvidence };
|
|
389
650
|
}
|
|
390
651
|
|
|
391
652
|
// -----------------------------------------------------------------------------
|
|
@@ -401,20 +662,35 @@ export function namespaceOf(app: AppRow): string {
|
|
|
401
662
|
* overview. Callers extend it with the group's own (discovered) env tokens
|
|
402
663
|
* via the extraTokens parameter. */
|
|
403
664
|
const NAME_ENV_TOKENS = new Set([
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
665
|
+
"dev",
|
|
666
|
+
"development",
|
|
667
|
+
"staging",
|
|
668
|
+
"stage",
|
|
669
|
+
"stg",
|
|
670
|
+
"prod",
|
|
671
|
+
"production",
|
|
672
|
+
"prd",
|
|
673
|
+
"qa",
|
|
674
|
+
"uat",
|
|
675
|
+
"preprod",
|
|
676
|
+
"preview",
|
|
677
|
+
"canary",
|
|
678
|
+
]);
|
|
407
679
|
|
|
408
680
|
/** Strip a recognized env affix from a workload/app name —
|
|
409
681
|
* "billing-staging" → "billing", "qa-koala-backend" → "koala-backend".
|
|
410
682
|
* Used to match "the same workload" across app group env instances. */
|
|
411
|
-
export function stripEnvAffix(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
683
|
+
export function stripEnvAffix(
|
|
684
|
+
name: string,
|
|
685
|
+
extraTokens?: ReadonlySet<string>,
|
|
686
|
+
): string {
|
|
687
|
+
const isEnv = (tok: string) =>
|
|
688
|
+
NAME_ENV_TOKENS.has(tok) || (extraTokens?.has(tok) ?? false);
|
|
689
|
+
const i = name.lastIndexOf("-");
|
|
690
|
+
if (i > 0 && isEnv(name.slice(i + 1).toLowerCase())) return name.slice(0, i);
|
|
691
|
+
const j = name.indexOf("-");
|
|
692
|
+
if (j > 0 && isEnv(name.slice(0, j).toLowerCase())) return name.slice(j + 1);
|
|
693
|
+
return name;
|
|
418
694
|
}
|
|
419
695
|
|
|
420
696
|
/** Find "the same workload" in a sibling env instance: exact kind+name first,
|
|
@@ -423,61 +699,77 @@ export function stripEnvAffix(name: string, extraTokens?: ReadonlySet<string>):
|
|
|
423
699
|
* strip too. Null = no counterpart — the switch shows the instance overview. */
|
|
424
700
|
export function matchWorkloadAcrossInstances(
|
|
425
701
|
workloadKey: string,
|
|
426
|
-
targetWorkloads:
|
|
702
|
+
targetWorkloads:
|
|
703
|
+
Pick<AppWorkload, "kind" | "namespace" | "name">[] | undefined,
|
|
427
704
|
extraTokens?: ReadonlySet<string>,
|
|
428
|
-
): Pick<AppWorkload,
|
|
429
|
-
const [kind, namespace, name] = workloadKey.split(
|
|
430
|
-
if (!kind || !name) return null
|
|
431
|
-
const ws = targetWorkloads ?? []
|
|
705
|
+
): Pick<AppWorkload, "kind" | "namespace" | "name"> | null {
|
|
706
|
+
const [kind, namespace, name] = workloadKey.split("/");
|
|
707
|
+
if (!kind || !name) return null;
|
|
708
|
+
const ws = targetWorkloads ?? [];
|
|
432
709
|
return (
|
|
433
|
-
ws.find(
|
|
710
|
+
ws.find(
|
|
711
|
+
(w) => w.kind === kind && w.namespace === namespace && w.name === name,
|
|
712
|
+
) ??
|
|
434
713
|
uniqueMatch(ws, (w) => w.kind === kind && w.name === name) ??
|
|
435
|
-
uniqueMatch(
|
|
714
|
+
uniqueMatch(
|
|
715
|
+
ws,
|
|
716
|
+
(w) =>
|
|
717
|
+
w.kind === kind &&
|
|
718
|
+
stripEnvAffix(w.name, extraTokens) === stripEnvAffix(name, extraTokens),
|
|
719
|
+
) ??
|
|
436
720
|
null
|
|
437
|
-
)
|
|
721
|
+
);
|
|
438
722
|
}
|
|
439
723
|
|
|
440
724
|
function uniqueMatch<T>(items: T[], pred: (item: T) => boolean): T | null {
|
|
441
|
-
let found: T | null = null
|
|
725
|
+
let found: T | null = null;
|
|
442
726
|
for (const item of items) {
|
|
443
|
-
if (!pred(item)) continue
|
|
444
|
-
if (found) return null
|
|
445
|
-
found = item
|
|
727
|
+
if (!pred(item)) continue;
|
|
728
|
+
if (found) return null;
|
|
729
|
+
found = item;
|
|
446
730
|
}
|
|
447
|
-
return found
|
|
731
|
+
return found;
|
|
448
732
|
}
|
|
449
733
|
|
|
450
734
|
/** Ladder order: ranked envs by rank (dev → staging → prod), then
|
|
451
735
|
* recognized-but-unranked alphabetically (qa, …). */
|
|
452
736
|
export function orderEnvs(envs: string[]): string[] {
|
|
453
737
|
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
|
-
})
|
|
738
|
+
const ra = envRank(a);
|
|
739
|
+
const rb = envRank(b);
|
|
740
|
+
if (ra !== null && rb !== null) return ra - rb;
|
|
741
|
+
if (ra !== null) return -1;
|
|
742
|
+
if (rb !== null) return 1;
|
|
743
|
+
return a.localeCompare(b);
|
|
744
|
+
});
|
|
461
745
|
}
|
|
462
746
|
|
|
463
747
|
/** Promotion lag across an app group's env cells: fires only between RANKED envs,
|
|
464
748
|
* when a strictly-lower env runs a strictly-newer comparable version.
|
|
465
749
|
* Returns the human message ("staging is behind dev") or null. */
|
|
466
|
-
export function appGroupLagMessage(
|
|
750
|
+
export function appGroupLagMessage(
|
|
751
|
+
cells: { env: string; version?: string }[],
|
|
752
|
+
): string | null {
|
|
467
753
|
const ranked = cells
|
|
468
754
|
.map((c) => ({ ...c, rank: envRank(c.env) }))
|
|
469
|
-
.filter(
|
|
470
|
-
|
|
755
|
+
.filter(
|
|
756
|
+
(c): c is { env: string; version?: string; rank: number } =>
|
|
757
|
+
c.rank !== null && !!c.version,
|
|
758
|
+
)
|
|
759
|
+
.sort((a, b) => a.rank - b.rank);
|
|
471
760
|
for (let i = 0; i < ranked.length; i++) {
|
|
472
761
|
for (let j = i + 1; j < ranked.length; j++) {
|
|
473
762
|
// Strict rank inequality: two instances of the SAME env are siblings,
|
|
474
763
|
// not a promotion pair — without this, "prod is behind prod" can fire.
|
|
475
|
-
if (
|
|
476
|
-
|
|
764
|
+
if (
|
|
765
|
+
ranked[i].rank < ranked[j].rank &&
|
|
766
|
+
compareVersions(ranked[i].version, ranked[j].version) === 1
|
|
767
|
+
) {
|
|
768
|
+
return `${ranked[j].env} is behind ${ranked[i].env}`;
|
|
477
769
|
}
|
|
478
770
|
}
|
|
479
771
|
}
|
|
480
|
-
return null
|
|
772
|
+
return null;
|
|
481
773
|
}
|
|
482
774
|
|
|
483
775
|
// -----------------------------------------------------------------------------
|
|
@@ -490,53 +782,59 @@ export function appGroupLagMessage(cells: { env: string; version?: string }[]):
|
|
|
490
782
|
|
|
491
783
|
/** The slice of a list entry the fold needs. */
|
|
492
784
|
export interface AppGroupFoldEntry {
|
|
493
|
-
row: {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
785
|
+
row: {
|
|
786
|
+
key: string;
|
|
787
|
+
name: string;
|
|
788
|
+
identity?: AppIdentity;
|
|
789
|
+
appVersion?: string;
|
|
790
|
+
};
|
|
791
|
+
health: AppHealth;
|
|
792
|
+
versions: string[];
|
|
793
|
+
ready: number;
|
|
794
|
+
desired: number;
|
|
795
|
+
kinds: Record<string, number>;
|
|
796
|
+
classComposition: { cls: AppWorkloadClass; count: number }[];
|
|
500
797
|
}
|
|
501
798
|
|
|
502
799
|
export interface AppGroupEnvCell {
|
|
503
|
-
env: string
|
|
504
|
-
health: AppHealth
|
|
505
|
-
version?: string
|
|
506
|
-
count: number
|
|
507
|
-
firstKey: string
|
|
800
|
+
env: string;
|
|
801
|
+
health: AppHealth;
|
|
802
|
+
version?: string;
|
|
803
|
+
count: number;
|
|
804
|
+
firstKey: string;
|
|
508
805
|
}
|
|
509
806
|
|
|
510
807
|
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> =
|
|
808
|
+
kind: "group";
|
|
809
|
+
key: string;
|
|
810
|
+
label: string;
|
|
811
|
+
members: T[];
|
|
812
|
+
expanded: boolean;
|
|
813
|
+
cells: AppGroupEnvCell[];
|
|
814
|
+
lag: string | null;
|
|
815
|
+
health: AppHealth;
|
|
816
|
+
ready: number;
|
|
817
|
+
desired: number;
|
|
818
|
+
kinds: Record<string, number>;
|
|
819
|
+
classComposition: { cls: AppWorkloadClass; count: number }[];
|
|
820
|
+
workloadClass: AppWorkloadClass;
|
|
821
|
+
confidence: string;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export type FoldedRow<T extends AppGroupFoldEntry> =
|
|
825
|
+
FoldedAppGroupRow<T> | { kind: "instance"; entry: T; child?: boolean };
|
|
528
826
|
|
|
529
827
|
export interface FoldAppGroupsOptions<T extends AppGroupFoldEntry> {
|
|
530
828
|
/** Scope for non-portable identities. OSS leaves this empty; fleet callers
|
|
531
829
|
* should include the cluster id so local name/repo evidence cannot merge
|
|
532
830
|
* unrelated clusters. Portable identities ignore the scope. */
|
|
533
|
-
localScope?: (entry: T) => string | undefined
|
|
831
|
+
localScope?: (entry: T) => string | undefined;
|
|
534
832
|
/** Per-member env slices for the env ladder. A fleet member spans several
|
|
535
833
|
* per-cluster envs, so the host supplies them (cluster-coverage derived,
|
|
536
834
|
* authoritative) rather than the single `identity.env` — which the hub can
|
|
537
835
|
* stale when it joins the same overlay key across clusters. Defaults to the
|
|
538
836
|
* member's single identity env. */
|
|
539
|
-
envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }
|
|
837
|
+
envsOf?: (entry: T) => Array<{ env: string; health: AppHealth }>;
|
|
540
838
|
}
|
|
541
839
|
|
|
542
840
|
export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
@@ -546,78 +844,105 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
|
546
844
|
options: FoldAppGroupsOptions<T> = {},
|
|
547
845
|
): FoldedRow<T>[] {
|
|
548
846
|
const newest = (e: T): string | undefined =>
|
|
549
|
-
e.versions.reduce<string | undefined>(
|
|
847
|
+
e.versions.reduce<string | undefined>(
|
|
848
|
+
(best, v) => (!best || compareVersions(v, best) === 1 ? v : best),
|
|
849
|
+
undefined,
|
|
850
|
+
) ?? e.row.appVersion;
|
|
550
851
|
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
|
|
852
|
+
const id = e.row.identity;
|
|
853
|
+
if (!id) return null;
|
|
854
|
+
const scope = options.localScope?.(e);
|
|
855
|
+
if (!scope) return id.key;
|
|
555
856
|
// A declared-PATH identity displays its name stem as the key but carries the
|
|
556
857
|
// path stem in pathKey; two different apps can share a name while declaring
|
|
557
858
|
// different paths, so disambiguate the portable cross-cluster fold by pathKey.
|
|
558
|
-
return id.portable
|
|
559
|
-
|
|
560
|
-
|
|
859
|
+
return id.portable
|
|
860
|
+
? `portable:${id.key}${id.pathKey ? `:${id.pathKey}` : ""}`
|
|
861
|
+
: `local:${scope}:${id.key}`;
|
|
862
|
+
};
|
|
863
|
+
const byGroup = new Map<string, T[]>();
|
|
561
864
|
for (const e of entries) {
|
|
562
|
-
const k = groupKey(e)
|
|
563
|
-
if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e])
|
|
865
|
+
const k = groupKey(e);
|
|
866
|
+
if (k) byGroup.set(k, [...(byGroup.get(k) ?? []), e]);
|
|
564
867
|
}
|
|
565
|
-
const emitted = new Set<string>()
|
|
566
|
-
const out: FoldedRow<T>[] = []
|
|
868
|
+
const emitted = new Set<string>();
|
|
869
|
+
const out: FoldedRow<T>[] = [];
|
|
567
870
|
for (const e of entries) {
|
|
568
|
-
const id = e.row.identity
|
|
569
|
-
const k = groupKey(e)
|
|
871
|
+
const id = e.row.identity;
|
|
872
|
+
const k = groupKey(e);
|
|
570
873
|
// A group needs ≥2 SURVIVING members — filters can orphan one, which
|
|
571
874
|
// then renders as the plain instance it is.
|
|
572
875
|
if (!id || !k || (byGroup.get(k)?.length ?? 0) < 2) {
|
|
573
|
-
out.push({ kind:
|
|
574
|
-
continue
|
|
876
|
+
out.push({ kind: "instance", entry: e });
|
|
877
|
+
continue;
|
|
575
878
|
}
|
|
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
|
|
879
|
+
if (emitted.has(k)) continue;
|
|
880
|
+
emitted.add(k);
|
|
881
|
+
const members = byGroup.get(k)!;
|
|
882
|
+
|
|
883
|
+
const cellMap = new Map<string, AppGroupEnvCell>();
|
|
884
|
+
const kinds: Record<string, number> = {};
|
|
885
|
+
const compMap = new Map<AppWorkloadClass, number>();
|
|
886
|
+
let ready = 0;
|
|
887
|
+
let desired = 0;
|
|
585
888
|
// Seed with the most-benign tier (rank 0) so the max-fold below has a valid
|
|
586
889
|
// identity now that `unknown` ranks above healthy — see worstHealth.
|
|
587
|
-
let health: AppHealth =
|
|
890
|
+
let health: AppHealth = "neutral";
|
|
588
891
|
for (const m of members) {
|
|
589
|
-
const v = newest(m)
|
|
892
|
+
const v = newest(m);
|
|
590
893
|
// A fleet member spans several per-cluster envs; the host supplies them so
|
|
591
894
|
// the ladder reflects every env, not just the member's (possibly stale)
|
|
592
895
|
// single identity env. Default: the one identity env.
|
|
593
|
-
const slices = options.envsOf?.(m) ?? [
|
|
896
|
+
const slices = options.envsOf?.(m) ?? [
|
|
897
|
+
{ env: m.row.identity!.env, health: m.health },
|
|
898
|
+
];
|
|
594
899
|
for (const slice of slices) {
|
|
595
|
-
const cur = cellMap.get(slice.env)
|
|
900
|
+
const cur = cellMap.get(slice.env);
|
|
596
901
|
if (!cur) {
|
|
597
|
-
cellMap.set(slice.env, {
|
|
902
|
+
cellMap.set(slice.env, {
|
|
903
|
+
env: slice.env,
|
|
904
|
+
health: slice.health,
|
|
905
|
+
version: v,
|
|
906
|
+
count: 1,
|
|
907
|
+
firstKey: m.row.key,
|
|
908
|
+
});
|
|
598
909
|
} else {
|
|
599
|
-
cur.count
|
|
600
|
-
if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0))
|
|
601
|
-
|
|
910
|
+
cur.count++;
|
|
911
|
+
if ((HEALTH_RANK[slice.health] ?? 0) > (HEALTH_RANK[cur.health] ?? 0))
|
|
912
|
+
cur.health = slice.health;
|
|
913
|
+
if (v && (!cur.version || compareVersions(v, cur.version) === 1))
|
|
914
|
+
cur.version = v;
|
|
602
915
|
}
|
|
603
916
|
}
|
|
604
|
-
if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0))
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
for (const
|
|
917
|
+
if ((HEALTH_RANK[m.health] ?? 0) > (HEALTH_RANK[health] ?? 0))
|
|
918
|
+
health = m.health;
|
|
919
|
+
ready += m.ready;
|
|
920
|
+
desired += m.desired;
|
|
921
|
+
for (const [k, n] of Object.entries(m.kinds))
|
|
922
|
+
kinds[k] = (kinds[k] ?? 0) + n;
|
|
923
|
+
for (const c of m.classComposition)
|
|
924
|
+
compMap.set(c.cls, (compMap.get(c.cls) ?? 0) + c.count);
|
|
609
925
|
}
|
|
610
|
-
const cells = orderEnvs([...cellMap.keys()]).map((env) =>
|
|
611
|
-
|
|
612
|
-
|
|
926
|
+
const cells = orderEnvs([...cellMap.keys()]).map((env) =>
|
|
927
|
+
cellMap.get(env)!,
|
|
928
|
+
);
|
|
929
|
+
const classComposition = CLASS_ORDER.filter((c) => compMap.has(c)).map(
|
|
930
|
+
(c) => ({ cls: c, count: compMap.get(c)! }),
|
|
931
|
+
);
|
|
932
|
+
const known = classComposition
|
|
933
|
+
.map((c) => c.cls)
|
|
934
|
+
.filter((c) => c !== "unknown");
|
|
613
935
|
const workloadClass: AppWorkloadClass =
|
|
614
|
-
known.length === 0
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
936
|
+
known.length === 0
|
|
937
|
+
? "unknown"
|
|
938
|
+
: known.includes("service") && !known.includes("job")
|
|
939
|
+
? "service"
|
|
940
|
+
: known.length === 1
|
|
941
|
+
? known[0]
|
|
942
|
+
: "mixed";
|
|
943
|
+
const expanded = autoExpand || expandedKeys.has(k);
|
|
619
944
|
out.push({
|
|
620
|
-
kind:
|
|
945
|
+
kind: "group",
|
|
621
946
|
key: k,
|
|
622
947
|
label: id.key,
|
|
623
948
|
members,
|
|
@@ -630,21 +955,27 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
|
|
|
630
955
|
kinds,
|
|
631
956
|
classComposition,
|
|
632
957
|
workloadClass,
|
|
633
|
-
confidence: members.some((m) => m.row.identity!.confidence ===
|
|
634
|
-
|
|
958
|
+
confidence: members.some((m) => m.row.identity!.confidence === "high")
|
|
959
|
+
? "high"
|
|
960
|
+
: "medium",
|
|
961
|
+
});
|
|
635
962
|
if (expanded) {
|
|
636
|
-
for (const m of members)
|
|
963
|
+
for (const m of members)
|
|
964
|
+
out.push({ kind: "instance", entry: m, child: true });
|
|
637
965
|
}
|
|
638
966
|
}
|
|
639
|
-
return out
|
|
967
|
+
return out;
|
|
640
968
|
}
|
|
641
969
|
|
|
642
970
|
/** Normalize a wire health string to the AppHealth union (the health twin of
|
|
643
971
|
* workloadClassOf — keeps `as AppHealth` casts out of components). */
|
|
644
972
|
export function healthOf(value: string | undefined): AppHealth {
|
|
645
|
-
return value ===
|
|
973
|
+
return value === "unhealthy" ||
|
|
974
|
+
value === "degraded" ||
|
|
975
|
+
value === "healthy" ||
|
|
976
|
+
value === "neutral"
|
|
646
977
|
? value
|
|
647
|
-
:
|
|
978
|
+
: "unknown";
|
|
648
979
|
}
|
|
649
980
|
|
|
650
981
|
// -----------------------------------------------------------------------------
|
|
@@ -658,16 +989,28 @@ export function healthOf(value: string | undefined): AppHealth {
|
|
|
658
989
|
// healthy (a node-lost workload is worse than a running one); `neutral`
|
|
659
990
|
// (intentional/idle) is the most-benign tier, so an all-idle app rolls up to
|
|
660
991
|
// "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
|
-
|
|
992
|
+
// neutral in the max). `unknown` must outrank `healthy` here to agree with the
|
|
993
|
+
// backend rollup — a node-lost workload can't read as healthy.
|
|
994
|
+
export const HEALTH_ORDER: AppHealth[] = [
|
|
995
|
+
"unhealthy",
|
|
996
|
+
"degraded",
|
|
997
|
+
"unknown",
|
|
998
|
+
"healthy",
|
|
999
|
+
"neutral",
|
|
1000
|
+
];
|
|
1001
|
+
export const HEALTH_RANK: Record<string, number> = {
|
|
1002
|
+
unhealthy: 4,
|
|
1003
|
+
degraded: 3,
|
|
1004
|
+
unknown: 2,
|
|
1005
|
+
healthy: 1,
|
|
1006
|
+
neutral: 0,
|
|
1007
|
+
};
|
|
665
1008
|
|
|
666
1009
|
export interface HealthMeta {
|
|
667
|
-
label: string
|
|
668
|
-
bar: string
|
|
669
|
-
text: string
|
|
670
|
-
pill: string
|
|
1010
|
+
label: string;
|
|
1011
|
+
bar: string;
|
|
1012
|
+
text: string;
|
|
1013
|
+
pill: string;
|
|
671
1014
|
}
|
|
672
1015
|
|
|
673
1016
|
// ─── Chip dialect ────────────────────────────────────────────────────────────
|
|
@@ -676,48 +1019,114 @@ export interface HealthMeta {
|
|
|
676
1019
|
// standalone status pills. A local dialect, but defined ONCE here: call sites
|
|
677
1020
|
// compose `CHIP` (chrome) + a `CHIP_TONE` (color), never inline the strings.
|
|
678
1021
|
// Literal class strings are required for Tailwind's content scanner.
|
|
679
|
-
export const CHIP =
|
|
1022
|
+
export const CHIP =
|
|
1023
|
+
"inline-flex items-center rounded-sm px-1.5 py-px text-[10px] font-medium ring-1 ring-inset";
|
|
680
1024
|
export const CHIP_TONE = {
|
|
681
|
-
rose:
|
|
682
|
-
amber:
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1025
|
+
rose: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900",
|
|
1026
|
+
amber:
|
|
1027
|
+
"bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900",
|
|
1028
|
+
emerald:
|
|
1029
|
+
"bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900",
|
|
1030
|
+
blue: "bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900",
|
|
1031
|
+
sky: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900",
|
|
1032
|
+
violet:
|
|
1033
|
+
"bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900",
|
|
1034
|
+
neutral: "bg-theme-hover text-theme-text-secondary ring-theme-border",
|
|
1035
|
+
muted: "bg-theme-hover text-theme-text-tertiary ring-theme-border",
|
|
1036
|
+
} as const;
|
|
690
1037
|
|
|
691
1038
|
export const HEALTH_META: Record<AppHealth, HealthMeta> = {
|
|
692
|
-
unhealthy: {
|
|
693
|
-
|
|
694
|
-
|
|
1039
|
+
unhealthy: {
|
|
1040
|
+
label: "Unhealthy",
|
|
1041
|
+
bar: "bg-rose-500",
|
|
1042
|
+
text: "text-rose-600 dark:text-rose-400",
|
|
1043
|
+
pill: CHIP_TONE.rose,
|
|
1044
|
+
},
|
|
1045
|
+
degraded: {
|
|
1046
|
+
label: "Degraded",
|
|
1047
|
+
bar: "bg-amber-500",
|
|
1048
|
+
text: "text-amber-600 dark:text-amber-400",
|
|
1049
|
+
pill: CHIP_TONE.amber,
|
|
1050
|
+
},
|
|
1051
|
+
healthy: {
|
|
1052
|
+
label: "Healthy",
|
|
1053
|
+
bar: "bg-emerald-500",
|
|
1054
|
+
text: "text-emerald-600 dark:text-emerald-400",
|
|
1055
|
+
pill: CHIP_TONE.emerald,
|
|
1056
|
+
},
|
|
695
1057
|
// neutral = intentionally idle/off (every workload suspended or scaled to 0).
|
|
696
1058
|
// 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
|
-
}
|
|
1059
|
+
neutral: {
|
|
1060
|
+
label: "Idle",
|
|
1061
|
+
bar: "bg-sky-500",
|
|
1062
|
+
text: "text-sky-600 dark:text-sky-400",
|
|
1063
|
+
pill: CHIP_TONE.sky,
|
|
1064
|
+
},
|
|
1065
|
+
unknown: {
|
|
1066
|
+
label: "Unknown",
|
|
1067
|
+
bar: "bg-slate-400",
|
|
1068
|
+
text: "text-theme-text-tertiary",
|
|
1069
|
+
pill: CHIP_TONE.muted,
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
export const CLASS_ORDER: AppWorkloadClass[] = [
|
|
1074
|
+
"service",
|
|
1075
|
+
"worker",
|
|
1076
|
+
"job",
|
|
1077
|
+
"unknown",
|
|
1078
|
+
];
|
|
1079
|
+
|
|
1080
|
+
export const CLASS_META: Record<
|
|
1081
|
+
AppWorkloadClass,
|
|
1082
|
+
{ label: string; pill: string; tooltip: string }
|
|
1083
|
+
> = {
|
|
1084
|
+
service: {
|
|
1085
|
+
label: "Service",
|
|
1086
|
+
pill: CHIP_TONE.blue,
|
|
1087
|
+
tooltip:
|
|
1088
|
+
"Long-running, request-serving (a Deployment/StatefulSet behind a Service/Ingress/route). Inferred from the workload shape + routing.",
|
|
1089
|
+
},
|
|
1090
|
+
worker: {
|
|
1091
|
+
label: "Worker",
|
|
1092
|
+
pill: CHIP_TONE.violet,
|
|
1093
|
+
tooltip:
|
|
1094
|
+
"Long-running background processor (no serving edge). Inferred from the workload shape.",
|
|
1095
|
+
},
|
|
1096
|
+
job: {
|
|
1097
|
+
label: "Job",
|
|
1098
|
+
pill: CHIP_TONE.amber,
|
|
1099
|
+
tooltip:
|
|
1100
|
+
"Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow/WorkflowTemplate/ClusterWorkflowTemplate/ScaledJob).",
|
|
1101
|
+
},
|
|
1102
|
+
mixed: {
|
|
1103
|
+
label: "Mixed",
|
|
1104
|
+
pill: CHIP_TONE.neutral,
|
|
1105
|
+
tooltip:
|
|
1106
|
+
"Contains workloads of more than one class (e.g. a service plus its scheduled jobs).",
|
|
1107
|
+
},
|
|
1108
|
+
unknown: {
|
|
1109
|
+
label: "Unknown",
|
|
1110
|
+
pill: CHIP_TONE.muted,
|
|
1111
|
+
tooltip: "Couldn't infer a runtime class from the workload.",
|
|
1112
|
+
},
|
|
1113
|
+
};
|
|
710
1114
|
|
|
711
1115
|
/** Per-class workload counts for an app, in CLASS_ORDER — the composition
|
|
712
1116
|
* behind a "Mixed" badge and the inclusive Class facet (filtering "Service"
|
|
713
1117
|
* matches mixed apps that contain a service). */
|
|
714
|
-
export function classCompositionOf(
|
|
715
|
-
|
|
1118
|
+
export function classCompositionOf(
|
|
1119
|
+
app: AppRow,
|
|
1120
|
+
): { cls: AppWorkloadClass; count: number }[] {
|
|
1121
|
+
const counts = new Map<AppWorkloadClass, number>();
|
|
716
1122
|
for (const w of app.workloads || []) {
|
|
717
|
-
const c = workloadClassOf(w.workload_class)
|
|
718
|
-
counts.set(c, (counts.get(c) ?? 0) + 1)
|
|
1123
|
+
const c = workloadClassOf(w.workload_class);
|
|
1124
|
+
counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
719
1125
|
}
|
|
720
|
-
return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({
|
|
1126
|
+
return CLASS_ORDER.filter((c) => counts.has(c)).map((c) => ({
|
|
1127
|
+
cls: c,
|
|
1128
|
+
count: counts.get(c)!,
|
|
1129
|
+
}));
|
|
721
1130
|
}
|
|
722
1131
|
|
|
723
1132
|
/** The distinct KNOWN classes an app contains — the facet-matching set. Falls
|
|
@@ -725,20 +1134,358 @@ export function classCompositionOf(app: AppRow): { cls: AppWorkloadClass; count:
|
|
|
725
1134
|
export function classSetOf(app: AppRow): AppWorkloadClass[] {
|
|
726
1135
|
const known = classCompositionOf(app)
|
|
727
1136
|
.map((c) => c.cls)
|
|
728
|
-
.filter((c) => c ===
|
|
729
|
-
if (known.length > 0) return known
|
|
730
|
-
return [workloadClassOf(app.workload_class)]
|
|
1137
|
+
.filter((c) => c === "service" || c === "worker" || c === "job");
|
|
1138
|
+
if (known.length > 0) return known;
|
|
1139
|
+
return [workloadClassOf(app.workload_class)];
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export interface BatchSignal {
|
|
1143
|
+
tone: "rose" | "amber" | "sky" | "emerald" | "muted";
|
|
1144
|
+
label: string;
|
|
1145
|
+
detail: string;
|
|
1146
|
+
workload: AppWorkload;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
export interface AppBatchActivity {
|
|
1150
|
+
workload: AppWorkload;
|
|
1151
|
+
tone: BatchSignal["tone"];
|
|
1152
|
+
rank: number;
|
|
1153
|
+
label: string;
|
|
1154
|
+
detail: string;
|
|
1155
|
+
latestRunName?: string;
|
|
1156
|
+
latestRunPhase?: string;
|
|
1157
|
+
latestStartedAt?: string;
|
|
1158
|
+
latestFinishedAt?: string;
|
|
1159
|
+
schedule?: string;
|
|
1160
|
+
activeRuns: number;
|
|
1161
|
+
retainedRuns: number;
|
|
1162
|
+
failedRuns: number;
|
|
1163
|
+
lastScheduledAt?: string;
|
|
1164
|
+
lastSuccessfulAt?: string;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export function batchActivityForApp(app: AppRow): AppBatchActivity[] {
|
|
1168
|
+
return (app.workloads || [])
|
|
1169
|
+
.map(batchActivityForWorkload)
|
|
1170
|
+
.filter((activity): activity is AppBatchActivity => Boolean(activity))
|
|
1171
|
+
.sort(
|
|
1172
|
+
(a, b) =>
|
|
1173
|
+
b.rank - a.rank || a.workload.name.localeCompare(b.workload.name),
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
export function batchSignalForApp(app: AppRow): BatchSignal | null {
|
|
1178
|
+
let best: BatchSignal | null = null;
|
|
1179
|
+
for (const workload of app.workloads || []) {
|
|
1180
|
+
const signal = batchSignalForWorkload(workload);
|
|
1181
|
+
if (!signal) continue;
|
|
1182
|
+
if (!best || batchSignalRank(signal) > batchSignalRank(best)) best = signal;
|
|
1183
|
+
}
|
|
1184
|
+
return best;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function batchSignalForWorkload(workload: AppWorkload): BatchSignal | null {
|
|
1188
|
+
const batch = workload.batch;
|
|
1189
|
+
if (!batch) return null;
|
|
1190
|
+
const latestPhase = batch.latestRunPhase || "";
|
|
1191
|
+
const name = `${workload.kind}/${workload.name}`;
|
|
1192
|
+
if (latestPhase === "Failed" || latestPhase === "Error") {
|
|
1193
|
+
return {
|
|
1194
|
+
tone: "rose",
|
|
1195
|
+
label: "batch failed",
|
|
1196
|
+
detail:
|
|
1197
|
+
batch.message ||
|
|
1198
|
+
`${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
|
|
1199
|
+
workload,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
if (batch.suspended) {
|
|
1203
|
+
return {
|
|
1204
|
+
tone: "sky",
|
|
1205
|
+
label: "schedule suspended",
|
|
1206
|
+
detail: `${name} will not create new runs until resumed.`,
|
|
1207
|
+
workload,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
if ((batch.activeRuns ?? 0) > 0) {
|
|
1211
|
+
return {
|
|
1212
|
+
tone: "sky",
|
|
1213
|
+
label: `${batch.activeRuns} running`,
|
|
1214
|
+
detail: `${name} currently has ${batch.activeRuns} active run${batch.activeRuns === 1 ? "" : "s"}.`,
|
|
1215
|
+
workload,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
export interface AppBatchRuntime {
|
|
1222
|
+
label: string;
|
|
1223
|
+
health: AppHealth;
|
|
1224
|
+
detail: string;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function batchRuntimeForApp(app: AppRow): AppBatchRuntime {
|
|
1228
|
+
const activity = batchActivityForApp(app);
|
|
1229
|
+
if (
|
|
1230
|
+
activity.some(
|
|
1231
|
+
(item) => item.activeRuns > 0 || item.latestRunPhase === "Running",
|
|
1232
|
+
)
|
|
1233
|
+
) {
|
|
1234
|
+
const active = activity.reduce((sum, item) => sum + item.activeRuns, 0);
|
|
1235
|
+
return {
|
|
1236
|
+
label: active > 0 ? `${active} running` : "Running",
|
|
1237
|
+
health: "neutral",
|
|
1238
|
+
detail: "Batch work is executing now.",
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
const failed = activity.filter(
|
|
1242
|
+
(item) =>
|
|
1243
|
+
item.latestRunPhase === "Failed" || item.latestRunPhase === "Error",
|
|
1244
|
+
);
|
|
1245
|
+
if (failed.length > 0) {
|
|
1246
|
+
const item = failed[0];
|
|
1247
|
+
const detail =
|
|
1248
|
+
failed.length === 1
|
|
1249
|
+
? `${item.workload.kind}/${item.workload.name} latest retained run${item.latestRunName ? ` ${item.latestRunName}` : ""} failed.`
|
|
1250
|
+
: `${failed.length} batch workloads have failed latest runs.`;
|
|
1251
|
+
return { label: "Failed", health: "unhealthy", detail };
|
|
1252
|
+
}
|
|
1253
|
+
const latest = [...activity].sort(
|
|
1254
|
+
(a, b) => batchActivityTimestamp(b) - batchActivityTimestamp(a),
|
|
1255
|
+
)[0];
|
|
1256
|
+
if (latest?.latestRunPhase === "Succeeded") {
|
|
1257
|
+
return {
|
|
1258
|
+
label: "Succeeded",
|
|
1259
|
+
health: "healthy",
|
|
1260
|
+
detail: latest.latestRunName
|
|
1261
|
+
? `Latest retained run: ${latest.latestRunName}`
|
|
1262
|
+
: "The latest retained run succeeded.",
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
if (
|
|
1266
|
+
activity.length > 0 &&
|
|
1267
|
+
activity.every((item) => item.workload.batch?.suspended)
|
|
1268
|
+
) {
|
|
1269
|
+
return {
|
|
1270
|
+
label: "Suspended",
|
|
1271
|
+
health: "neutral",
|
|
1272
|
+
detail: "All batch launchers are suspended.",
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (activity.some((item) => item.retainedRuns > 0)) {
|
|
1276
|
+
return {
|
|
1277
|
+
label: latest?.latestRunPhase || "Idle",
|
|
1278
|
+
health: "neutral",
|
|
1279
|
+
detail: "No batch work is currently active.",
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
return {
|
|
1283
|
+
label: "Idle",
|
|
1284
|
+
health: "neutral",
|
|
1285
|
+
detail: "No retained runs are present in Kubernetes.",
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function batchActivityTimestamp(item: AppBatchActivity): number {
|
|
1290
|
+
const raw =
|
|
1291
|
+
item.latestStartedAt ||
|
|
1292
|
+
item.latestFinishedAt ||
|
|
1293
|
+
item.lastScheduledAt ||
|
|
1294
|
+
item.lastSuccessfulAt;
|
|
1295
|
+
if (!raw) return 0;
|
|
1296
|
+
const parsed = Date.parse(raw);
|
|
1297
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function applicationRuntimeHealth(app: AppRow): AppHealth {
|
|
1301
|
+
if (workloadClassOf(app.workload_class) === "job")
|
|
1302
|
+
return batchRuntimeForApp(app).health;
|
|
1303
|
+
const serving = (app.workloads || []).filter((workload) => !workload.batch);
|
|
1304
|
+
return serving.length > 0
|
|
1305
|
+
? worstHealth(serving.map((workload) => workload.health))
|
|
1306
|
+
: healthOf(app.health);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
export function sourceSyncHealth(status: string): AppHealth {
|
|
1310
|
+
const normalized = status.toLowerCase();
|
|
1311
|
+
if (normalized === "synced") return "healthy";
|
|
1312
|
+
if (normalized === "outofsync") return "degraded";
|
|
1313
|
+
return "unknown";
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export function sourceReportedHealth(status: string): AppHealth {
|
|
1317
|
+
const normalized = status.toLowerCase();
|
|
1318
|
+
if (normalized === "healthy") return "healthy";
|
|
1319
|
+
if (normalized === "progressing") return "neutral";
|
|
1320
|
+
if (normalized === "degraded") return "degraded";
|
|
1321
|
+
if (normalized === "missing") return "unhealthy";
|
|
1322
|
+
if (normalized === "suspended") return "neutral";
|
|
1323
|
+
return "unknown";
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function applicationDeliveryHealth(status?: AppSourceStatus): AppHealth {
|
|
1327
|
+
if (!status) return "neutral";
|
|
1328
|
+
const signals = [
|
|
1329
|
+
status.sync ? sourceSyncHealth(status.sync) : null,
|
|
1330
|
+
status.health ? sourceReportedHealth(status.health) : null,
|
|
1331
|
+
].filter((health): health is AppHealth => health !== null && health !== "unknown");
|
|
1332
|
+
return signals.length > 0 ? worstHealth(signals) : "neutral";
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
export function applicationDisplayHealth(app: AppRow): AppHealth {
|
|
1336
|
+
return worstHealth([
|
|
1337
|
+
applicationRuntimeHealth(app),
|
|
1338
|
+
applicationDeliveryHealth(app.sourceStatus),
|
|
1339
|
+
]);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
export function servingReadiness(workloads: AppWorkload[]): {
|
|
1343
|
+
ready: number;
|
|
1344
|
+
desired: number;
|
|
1345
|
+
} {
|
|
1346
|
+
let ready = 0;
|
|
1347
|
+
let desired = 0;
|
|
1348
|
+
for (const workload of workloads) {
|
|
1349
|
+
const cls = workloadClassOf(workload.workload_class);
|
|
1350
|
+
if (cls === "job" || workload.batch) continue;
|
|
1351
|
+
ready += workload.ready ?? 0;
|
|
1352
|
+
desired += workload.desired ?? 0;
|
|
1353
|
+
}
|
|
1354
|
+
return { ready, desired };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function batchActivityForWorkload(
|
|
1358
|
+
workload: AppWorkload,
|
|
1359
|
+
): AppBatchActivity | null {
|
|
1360
|
+
const batch = workload.batch;
|
|
1361
|
+
if (!batch) return null;
|
|
1362
|
+
const latestPhase = batch.latestRunPhase || "";
|
|
1363
|
+
const name = `${workload.kind}/${workload.name}`;
|
|
1364
|
+
const activeRuns = batch.activeRuns ?? 0;
|
|
1365
|
+
const retainedRuns = batch.retainedRuns ?? 0;
|
|
1366
|
+
const failedRuns = batch.failedRuns ?? 0;
|
|
1367
|
+
if (latestPhase === "Failed" || latestPhase === "Error") {
|
|
1368
|
+
return {
|
|
1369
|
+
workload,
|
|
1370
|
+
tone: "rose",
|
|
1371
|
+
rank: 50,
|
|
1372
|
+
label: "Latest run failed",
|
|
1373
|
+
detail:
|
|
1374
|
+
batch.message ||
|
|
1375
|
+
`${name} latest run ${batch.latestRunName || ""} ended ${latestPhase}.`,
|
|
1376
|
+
latestRunName: batch.latestRunName,
|
|
1377
|
+
latestRunPhase: latestPhase,
|
|
1378
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1379
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1380
|
+
schedule: batch.schedule,
|
|
1381
|
+
activeRuns,
|
|
1382
|
+
retainedRuns,
|
|
1383
|
+
failedRuns,
|
|
1384
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1385
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
if (failedRuns > 0) {
|
|
1389
|
+
return {
|
|
1390
|
+
workload,
|
|
1391
|
+
tone: "amber",
|
|
1392
|
+
rank: 40,
|
|
1393
|
+
label: `${failedRuns} retained failed ${failedRuns === 1 ? "run" : "runs"}`,
|
|
1394
|
+
detail: `${name} has retained failed executions in Kubernetes.`,
|
|
1395
|
+
latestRunName: batch.latestRunName,
|
|
1396
|
+
latestRunPhase: latestPhase,
|
|
1397
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1398
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1399
|
+
schedule: batch.schedule,
|
|
1400
|
+
activeRuns,
|
|
1401
|
+
retainedRuns,
|
|
1402
|
+
failedRuns,
|
|
1403
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1404
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
if (activeRuns > 0) {
|
|
1408
|
+
return {
|
|
1409
|
+
workload,
|
|
1410
|
+
tone: "sky",
|
|
1411
|
+
rank: 35,
|
|
1412
|
+
label: `${activeRuns} active ${activeRuns === 1 ? "run" : "runs"}`,
|
|
1413
|
+
detail: `${name} is executing now.`,
|
|
1414
|
+
latestRunName: batch.latestRunName,
|
|
1415
|
+
latestRunPhase: latestPhase,
|
|
1416
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1417
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1418
|
+
schedule: batch.schedule,
|
|
1419
|
+
activeRuns,
|
|
1420
|
+
retainedRuns,
|
|
1421
|
+
failedRuns,
|
|
1422
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1423
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
if (batch.suspended) {
|
|
1427
|
+
return {
|
|
1428
|
+
workload,
|
|
1429
|
+
tone: "sky",
|
|
1430
|
+
rank: 30,
|
|
1431
|
+
label: "Schedule suspended",
|
|
1432
|
+
detail: `${name} will not create new runs until resumed.`,
|
|
1433
|
+
latestRunName: batch.latestRunName,
|
|
1434
|
+
latestRunPhase: latestPhase,
|
|
1435
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1436
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1437
|
+
schedule: batch.schedule,
|
|
1438
|
+
activeRuns,
|
|
1439
|
+
retainedRuns,
|
|
1440
|
+
failedRuns,
|
|
1441
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1442
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
workload,
|
|
1447
|
+
tone: "muted",
|
|
1448
|
+
rank: latestPhase === "Succeeded" ? 20 : 10,
|
|
1449
|
+
label:
|
|
1450
|
+
latestPhase === "Succeeded" ? "Latest run succeeded" : "No active runs",
|
|
1451
|
+
detail: `${name} has ${retainedRuns} retained ${retainedRuns === 1 ? "run" : "runs"}.`,
|
|
1452
|
+
latestRunName: batch.latestRunName,
|
|
1453
|
+
latestRunPhase: latestPhase,
|
|
1454
|
+
latestStartedAt: batch.latestStartedAt,
|
|
1455
|
+
latestFinishedAt: batch.latestFinishedAt,
|
|
1456
|
+
schedule: batch.schedule,
|
|
1457
|
+
activeRuns,
|
|
1458
|
+
retainedRuns,
|
|
1459
|
+
failedRuns,
|
|
1460
|
+
lastScheduledAt: batch.lastScheduledAt,
|
|
1461
|
+
lastSuccessfulAt: batch.lastSuccessfulAt,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function batchSignalRank(signal: BatchSignal): number {
|
|
1466
|
+
switch (signal.tone) {
|
|
1467
|
+
case "rose":
|
|
1468
|
+
return 5;
|
|
1469
|
+
case "amber":
|
|
1470
|
+
return 4;
|
|
1471
|
+
case "sky":
|
|
1472
|
+
return 3;
|
|
1473
|
+
case "emerald":
|
|
1474
|
+
return 2;
|
|
1475
|
+
default:
|
|
1476
|
+
return 1;
|
|
1477
|
+
}
|
|
731
1478
|
}
|
|
732
1479
|
|
|
733
1480
|
export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
|
|
734
1481
|
switch (value) {
|
|
735
|
-
case
|
|
736
|
-
case
|
|
737
|
-
case
|
|
738
|
-
case
|
|
739
|
-
return value
|
|
1482
|
+
case "service":
|
|
1483
|
+
case "worker":
|
|
1484
|
+
case "job":
|
|
1485
|
+
case "mixed":
|
|
1486
|
+
return value;
|
|
740
1487
|
default:
|
|
741
|
-
return
|
|
1488
|
+
return "unknown";
|
|
742
1489
|
}
|
|
743
1490
|
}
|
|
744
1491
|
|
|
@@ -749,20 +1496,21 @@ export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
|
|
|
749
1496
|
* ranks ABOVE healthy — an all-healthy set would never beat it and return
|
|
750
1497
|
* `unknown`. Mirrors pkg/health.WorseOf.) */
|
|
751
1498
|
export function worstHealth(hs: string[]): AppHealth {
|
|
752
|
-
let w: AppHealth =
|
|
753
|
-
for (const h of hs)
|
|
754
|
-
|
|
1499
|
+
let w: AppHealth = "neutral";
|
|
1500
|
+
for (const h of hs)
|
|
1501
|
+
if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth;
|
|
1502
|
+
return w;
|
|
755
1503
|
}
|
|
756
1504
|
|
|
757
1505
|
export function newestTag(versions: string[]): string | undefined {
|
|
758
|
-
let best: string | undefined
|
|
1506
|
+
let best: string | undefined;
|
|
759
1507
|
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
|
|
1508
|
+
const t = v?.trim();
|
|
1509
|
+
if (!t) continue;
|
|
1510
|
+
if (best === undefined) best = t;
|
|
1511
|
+
else if (compareVersions(t, best) === 1) best = t;
|
|
764
1512
|
}
|
|
765
|
-
return best
|
|
1513
|
+
return best;
|
|
766
1514
|
}
|
|
767
1515
|
|
|
768
1516
|
// -----------------------------------------------------------------------------
|
|
@@ -775,78 +1523,85 @@ export function newestTag(versions: string[]): string | undefined {
|
|
|
775
1523
|
// -----------------------------------------------------------------------------
|
|
776
1524
|
|
|
777
1525
|
export interface AppEntryBase {
|
|
778
|
-
row: AppRow
|
|
779
|
-
health: AppHealth
|
|
780
|
-
versions: string[]
|
|
781
|
-
kinds: Record<string, number
|
|
782
|
-
workloadClass: AppWorkloadClass
|
|
1526
|
+
row: AppRow;
|
|
1527
|
+
health: AppHealth;
|
|
1528
|
+
versions: string[];
|
|
1529
|
+
kinds: Record<string, number>;
|
|
1530
|
+
workloadClass: AppWorkloadClass;
|
|
783
1531
|
/** 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
|
|
1532
|
+
classSet: AppWorkloadClass[];
|
|
1533
|
+
classComposition: { cls: AppWorkloadClass; count: number }[];
|
|
1534
|
+
category: AppCategory;
|
|
1535
|
+
ready: number;
|
|
1536
|
+
desired: number;
|
|
789
1537
|
/** ready/desired as a fraction for sorting; -1 when nothing is desired. */
|
|
790
|
-
readyRatio: number
|
|
791
|
-
source: AppSource
|
|
1538
|
+
readyRatio: number;
|
|
1539
|
+
source: AppSource;
|
|
792
1540
|
}
|
|
793
1541
|
|
|
794
1542
|
export interface SingleAppEntry extends AppEntryBase {
|
|
795
|
-
variant:
|
|
796
|
-
namespace: string
|
|
797
|
-
namespaces: string[]
|
|
798
|
-
env: string
|
|
799
|
-
envInferred: boolean
|
|
1543
|
+
variant: "single";
|
|
1544
|
+
namespace: string;
|
|
1545
|
+
namespaces: string[];
|
|
1546
|
+
env: string;
|
|
1547
|
+
envInferred: boolean;
|
|
800
1548
|
}
|
|
801
1549
|
|
|
802
1550
|
/** One environment instance within a fleet row (env name + worst health across
|
|
803
1551
|
* its clusters + whether the env was inferred from the namespace). */
|
|
804
1552
|
export interface EnvSlice {
|
|
805
|
-
env: string
|
|
806
|
-
health: AppHealth
|
|
807
|
-
inferred: boolean
|
|
1553
|
+
env: string;
|
|
1554
|
+
health: AppHealth;
|
|
1555
|
+
inferred: boolean;
|
|
808
1556
|
}
|
|
809
1557
|
|
|
810
1558
|
/** A cluster this fleet row's app runs in. */
|
|
811
1559
|
export interface AppClusterRef {
|
|
812
|
-
id: string
|
|
813
|
-
name: string
|
|
814
|
-
health: AppHealth
|
|
1560
|
+
id: string;
|
|
1561
|
+
name: string;
|
|
1562
|
+
health: AppHealth;
|
|
815
1563
|
}
|
|
816
1564
|
|
|
817
1565
|
export interface FleetAppEntry extends AppEntryBase {
|
|
818
|
-
variant:
|
|
819
|
-
envs: EnvSlice[]
|
|
820
|
-
clusters: AppClusterRef[]
|
|
1566
|
+
variant: "fleet";
|
|
1567
|
+
envs: EnvSlice[];
|
|
1568
|
+
clusters: AppClusterRef[];
|
|
821
1569
|
/** True when the app runs different versions across its clusters/envs. */
|
|
822
|
-
versionSkew: boolean
|
|
1570
|
+
versionSkew: boolean;
|
|
823
1571
|
}
|
|
824
1572
|
|
|
825
|
-
export type AppEntry = SingleAppEntry | FleetAppEntry
|
|
1573
|
+
export type AppEntry = SingleAppEntry | FleetAppEntry;
|
|
826
1574
|
|
|
827
1575
|
/** Build a single-cluster list entry from a wire row. The env is resolved via
|
|
828
1576
|
* the server's identity classification (authoritative) with a namespace
|
|
829
1577
|
* heuristic fallback for plain rows. */
|
|
830
|
-
export function buildSingleAppEntry(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1578
|
+
export function buildSingleAppEntry(
|
|
1579
|
+
row: AppRow,
|
|
1580
|
+
discoveredEnvs?: ReadonlySet<string>,
|
|
1581
|
+
): SingleAppEntry {
|
|
1582
|
+
const kinds: Record<string, number> = {};
|
|
1583
|
+
let ready = 0;
|
|
1584
|
+
let desired = 0;
|
|
834
1585
|
for (const wl of row.workloads || []) {
|
|
835
|
-
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
|
|
836
|
-
|
|
837
|
-
|
|
1586
|
+
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1;
|
|
1587
|
+
if (workloadClassOf(wl.workload_class) !== "job" && !wl.batch) {
|
|
1588
|
+
ready += wl.ready ?? 0;
|
|
1589
|
+
desired += wl.desired ?? 0;
|
|
1590
|
+
}
|
|
838
1591
|
}
|
|
839
|
-
const namespace = namespaceOf(row)
|
|
1592
|
+
const namespace = namespaceOf(row);
|
|
840
1593
|
// The server's identity classification carries the authoritative env (label/
|
|
841
1594
|
// declared/discovered); plain rows fall back to the trio + discovered-token
|
|
842
1595
|
// namespace heuristic.
|
|
843
|
-
const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
844
|
-
const env = row.identity?.env ?? resolved.env
|
|
845
|
-
const inferred = row.identity
|
|
1596
|
+
const resolved = resolveEnv(undefined, namespace, discoveredEnvs);
|
|
1597
|
+
const env = row.identity?.env ?? resolved.env;
|
|
1598
|
+
const inferred = row.identity
|
|
1599
|
+
? identityEnvInferred(row.identity)
|
|
1600
|
+
: resolved.inferred;
|
|
846
1601
|
return {
|
|
847
|
-
variant:
|
|
1602
|
+
variant: "single",
|
|
848
1603
|
row,
|
|
849
|
-
health:
|
|
1604
|
+
health: applicationDisplayHealth(row),
|
|
850
1605
|
versions: Array.from(new Set((row.versions || []).filter(Boolean))),
|
|
851
1606
|
namespace,
|
|
852
1607
|
namespaces: namespacesOf(row),
|
|
@@ -861,16 +1616,25 @@ export function buildSingleAppEntry(row: AppRow, discoveredEnvs?: ReadonlySet<st
|
|
|
861
1616
|
desired,
|
|
862
1617
|
readyRatio: desired > 0 ? ready / desired : -1,
|
|
863
1618
|
source: sourceOf(row.tier),
|
|
864
|
-
}
|
|
1619
|
+
};
|
|
865
1620
|
}
|
|
866
1621
|
|
|
867
1622
|
/** The text an entry matches free-text search against — name, key, namespace,
|
|
868
1623
|
* source/class/category labels, versions, env, workload kinds + identity. Pure
|
|
869
1624
|
* and exported so the list core and tests share one definition. */
|
|
870
1625
|
export function searchTextForEntry(e: AppEntry): string {
|
|
871
|
-
const workloadText = (e.row.workloads || []).flatMap((wl) => [
|
|
872
|
-
|
|
873
|
-
|
|
1626
|
+
const workloadText = (e.row.workloads || []).flatMap((wl) => [
|
|
1627
|
+
wl.kind,
|
|
1628
|
+
wl.namespace,
|
|
1629
|
+
wl.name,
|
|
1630
|
+
wl.version,
|
|
1631
|
+
]);
|
|
1632
|
+
const envParts =
|
|
1633
|
+
e.variant === "single"
|
|
1634
|
+
? [e.env || "unlabeled"]
|
|
1635
|
+
: e.envs.map((s) => s.env || "unlabeled");
|
|
1636
|
+
const nsParts =
|
|
1637
|
+
e.variant === "single" ? [e.namespace] : e.clusters.map((c) => c.name);
|
|
874
1638
|
return [
|
|
875
1639
|
e.row.name,
|
|
876
1640
|
e.row.key,
|
|
@@ -885,6 +1649,6 @@ export function searchTextForEntry(e: AppEntry): string {
|
|
|
885
1649
|
...workloadText,
|
|
886
1650
|
]
|
|
887
1651
|
.filter(Boolean)
|
|
888
|
-
.join(
|
|
889
|
-
.toLowerCase()
|
|
1652
|
+
.join(" ")
|
|
1653
|
+
.toLowerCase();
|
|
890
1654
|
}
|