@skyhook-io/k8s-ui 1.10.4 → 1.10.5
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 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/topology/K8sResourceNode.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +503 -0
- package/src/components/trace/ReachabilityView.tsx +972 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1407 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +669 -0
- package/src/components/trace/reachInspector.ts +667 -0
- package/src/components/trace/reachMarks.test.ts +667 -0
- package/src/components/trace/reachMarks.ts +579 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +437 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +389 -0
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +38 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
X,
|
|
30
30
|
BarChart3,
|
|
31
31
|
Network,
|
|
32
|
+
Stethoscope,
|
|
32
33
|
DollarSign,
|
|
33
34
|
} from 'lucide-react'
|
|
34
35
|
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
|
|
@@ -69,7 +70,7 @@ import {
|
|
|
69
70
|
} from '../resources/resource-utils'
|
|
70
71
|
import { ServicePortCards, type ServicePortRenderProps } from '../resources/renderers/ServiceRenderer'
|
|
71
72
|
|
|
72
|
-
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'cost' | 'yaml'
|
|
73
|
+
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
|
|
73
74
|
type TabType = WorkloadTabType
|
|
74
75
|
|
|
75
76
|
export interface ResourceOwnershipContext {
|
|
@@ -273,6 +274,17 @@ interface WorkloadViewProps {
|
|
|
273
274
|
}) => ReactNode
|
|
274
275
|
/** Render the metrics tab content */
|
|
275
276
|
renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
277
|
+
/** Render the Diagnose tab content - a path-shaped trace for network
|
|
278
|
+
* entry kinds (Service / Ingress / HTTPRoute / GRPCRoute / Gateway). The
|
|
279
|
+
* tab is hidden when this callback is not provided OR when the focused
|
|
280
|
+
* kind is neither a network entry kind nor reachable through one. */
|
|
281
|
+
renderDiagnoseTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
282
|
+
/** Set when the focused resource is NOT itself a network entry kind but is
|
|
283
|
+
* served by one - a Deployment behind a Service. Reachability for a workload
|
|
284
|
+
* IS the reachability of the Services in front of it, so the tab opens here
|
|
285
|
+
* instead of sending the operator to another page to start over. Empty or
|
|
286
|
+
* omitted keeps the tab hidden for non-entry kinds. */
|
|
287
|
+
reachableVia?: { kind: string; namespace?: string; name: string }[]
|
|
276
288
|
/** Render the cost tab content */
|
|
277
289
|
renderCostTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
278
290
|
/** Render a read-only YAML view for a related object from the workload's
|
|
@@ -285,8 +297,12 @@ interface WorkloadViewProps {
|
|
|
285
297
|
isMetricsAvailable?: (kind: string, resource: any) => boolean
|
|
286
298
|
/** Whether cost is available for this resource kind */
|
|
287
299
|
isCostAvailable?: (kind: string, resource: any) => boolean
|
|
288
|
-
/** Render extra content at the bottom of the overview tab (e.g. audit
|
|
289
|
-
|
|
300
|
+
/** Render extra content at the bottom of the overview tab (e.g. audit
|
|
301
|
+
* findings). `context` lets the host suppress sections that are already
|
|
302
|
+
* surfaced by a dedicated tab in expanded mode - e.g. the Diagnose card
|
|
303
|
+
* belongs inline when there are no tabs (drawer) but would duplicate the
|
|
304
|
+
* Diagnose tab when it is. */
|
|
305
|
+
renderOverviewExtra?: (props: { kind: string; namespace: string; name: string; group?: string; context: 'drawer' | 'expanded' }) => ReactNode
|
|
290
306
|
/** Render lightweight overview intro content before the default renderer. */
|
|
291
307
|
renderOverviewIntro?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
292
308
|
/** Render content at the TOP of the overview tab, above the renderer (e.g. live
|
|
@@ -385,6 +401,8 @@ export function WorkloadView({
|
|
|
385
401
|
onTabChange,
|
|
386
402
|
// Render props
|
|
387
403
|
renderLogsTab,
|
|
404
|
+
renderDiagnoseTab,
|
|
405
|
+
reachableVia,
|
|
388
406
|
renderExpandedOverview,
|
|
389
407
|
renderRelatedYaml,
|
|
390
408
|
renderMetricsTab,
|
|
@@ -695,6 +713,12 @@ export function WorkloadView({
|
|
|
695
713
|
},
|
|
696
714
|
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
|
|
697
715
|
{ id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
|
|
716
|
+
{
|
|
717
|
+
id: 'reachability',
|
|
718
|
+
label: 'Reachability',
|
|
719
|
+
icon: <Stethoscope className="w-4 h-4" />,
|
|
720
|
+
hidden: !(renderDiagnoseTab && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)),
|
|
721
|
+
},
|
|
698
722
|
{ id: 'cost', label: 'Cost', icon: <DollarSign className="w-4 h-4" />, hidden: !costTabVisible },
|
|
699
723
|
{ id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
|
|
700
724
|
]
|
|
@@ -861,7 +885,7 @@ export function WorkloadView({
|
|
|
861
885
|
updates={resourceFocusedUpdates}
|
|
862
886
|
eventsError={resourceFocusedK8sError}
|
|
863
887
|
updatesError={resourceFocusedUpdatesError}
|
|
864
|
-
mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
888
|
+
mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name, group, context: 'drawer' })}
|
|
865
889
|
/>
|
|
866
890
|
</OperationalIssuesShownContext.Provider>
|
|
867
891
|
)}
|
|
@@ -1028,7 +1052,7 @@ export function WorkloadView({
|
|
|
1028
1052
|
updates={resourceFocusedUpdates}
|
|
1029
1053
|
eventsError={overviewEventsError}
|
|
1030
1054
|
updatesError={resourceFocusedUpdatesError}
|
|
1031
|
-
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
1055
|
+
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name, group, context: 'expanded' })}
|
|
1032
1056
|
introContent={overviewIntro}
|
|
1033
1057
|
leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
|
|
1034
1058
|
onEvaluateCapacity={onEvaluateCapacity}
|
|
@@ -1080,6 +1104,11 @@ export function WorkloadView({
|
|
|
1080
1104
|
{renderMetricsTab({ kind: resource?.kind || kind, namespace, name })}
|
|
1081
1105
|
</div>
|
|
1082
1106
|
)}
|
|
1107
|
+
{effectiveTab === 'reachability' && renderDiagnoseTab && (
|
|
1108
|
+
<div className="flex h-full min-h-0 flex-col p-3">
|
|
1109
|
+
{renderDiagnoseTab({ kind: resource?.kind || kind, namespace, name })}
|
|
1110
|
+
</div>
|
|
1111
|
+
)}
|
|
1083
1112
|
{effectiveTab === 'cost' && renderCostTab && (
|
|
1084
1113
|
<div className="h-full overflow-auto p-4">
|
|
1085
1114
|
{renderCostTab({ kind: resource?.kind || kind, namespace, name })}
|
|
@@ -1145,6 +1174,30 @@ export function WorkloadView({
|
|
|
1145
1174
|
// HELPER FUNCTIONS
|
|
1146
1175
|
// ============================================================================
|
|
1147
1176
|
|
|
1177
|
+
// Diagnose tab is only meaningful for the five network entry kinds. The
|
|
1178
|
+
// canonical predicate is internal/trace.IsEntryKind in Go; we mirror the
|
|
1179
|
+
// allowlist here so TypeScript callers don't need a fetch to decide whether
|
|
1180
|
+
// to render the tab. The two MUST stay in sync - when a kind is added on
|
|
1181
|
+
// either side, update both. Exported so the web wrapper can reuse the same
|
|
1182
|
+
// predicate for the inline drawer section.
|
|
1183
|
+
export function isDiagnoseKind(kind: string, group?: string): boolean {
|
|
1184
|
+
const k = kind.toLowerCase()
|
|
1185
|
+
const g = (group ?? '').toLowerCase()
|
|
1186
|
+
// When the API group is known, require it to match - a CRD sharing a core kind
|
|
1187
|
+
// name (Knative Service, Istio Gateway) must NOT enable the trace, which would
|
|
1188
|
+
// fire /trace/<kind> against the wrong (core) object. group===undefined keeps the
|
|
1189
|
+
// legacy kind-only behavior for callers that don't thread the group yet.
|
|
1190
|
+
const groupOk = (expected: string[]): boolean => group === undefined || expected.includes(g)
|
|
1191
|
+
if (k === 'service' || k === 'services') return groupOk([''])
|
|
1192
|
+
if (k === 'ingress' || k === 'ingresses') return groupOk(['', 'networking.k8s.io'])
|
|
1193
|
+
if (
|
|
1194
|
+
k === 'httproute' || k === 'httproutes' ||
|
|
1195
|
+
k === 'grpcroute' || k === 'grpcroutes' ||
|
|
1196
|
+
k === 'gateway' || k === 'gateways'
|
|
1197
|
+
) return groupOk(['gateway.networking.k8s.io'])
|
|
1198
|
+
return false
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1148
1201
|
function extractMetadata(kind: string, resource: any): { label: string; value: string }[] {
|
|
1149
1202
|
if (!resource) return []
|
|
1150
1203
|
const items: { label: string; value: string }[] = []
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { WorkloadView, type ResourceOwnershipContext, type ServingResourceDetail, type WorkloadTabType } from './WorkloadView'
|
|
1
|
+
export { WorkloadView, isDiagnoseKind, type ResourceOwnershipContext, type ServingResourceDetail, type WorkloadTabType } from './WorkloadView'
|
|
2
2
|
export { ResourceDetailDrawer } from './ResourceDetailDrawer'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isDiagnoseKind } from './WorkloadView'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rule that decides whether a resource gets a Reachability tab.
|
|
6
|
+
*
|
|
7
|
+
* `isDiagnoseKind` alone excluded every workload kind, so a Deployment had no
|
|
8
|
+
* Reachability tab at all - the operator got a link to the Service and had to
|
|
9
|
+
* navigate away and restart the investigation there. The tab now also opens for
|
|
10
|
+
* a resource that is SERVED by an entry kind, which is what makes "reachability
|
|
11
|
+
* for a Service or workload" true of workloads.
|
|
12
|
+
*/
|
|
13
|
+
const tabVisible = (kind: string, group: string | undefined, servingServices: unknown[]): boolean =>
|
|
14
|
+
isDiagnoseKind(kind, group) || servingServices.length > 0
|
|
15
|
+
|
|
16
|
+
const svc = [{ kind: 'Service', namespace: 'prod', name: 'web' }]
|
|
17
|
+
|
|
18
|
+
describe('who gets a Reachability tab', () => {
|
|
19
|
+
it('entry kinds trace themselves, with no Service needed', () => {
|
|
20
|
+
expect(tabVisible('Service', '', [])).toBe(true)
|
|
21
|
+
expect(tabVisible('Ingress', 'networking.k8s.io', [])).toBe(true)
|
|
22
|
+
expect(tabVisible('HTTPRoute', 'gateway.networking.k8s.io', [])).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('a workload behind a Service now gets one', () => {
|
|
26
|
+
for (const kind of ['Deployment', 'StatefulSet', 'DaemonSet', 'Pod']) {
|
|
27
|
+
expect(tabVisible(kind, 'apps', svc)).toBe(true)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Nothing selects it, so there is no path to trace and no honest tab to show.
|
|
32
|
+
it('a workload nothing exposes does not', () => {
|
|
33
|
+
expect(tabVisible('Deployment', 'apps', [])).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// A CRD sharing a core kind name must not enable a trace against the wrong
|
|
37
|
+
// object - the group check that guards this is load-bearing, not incidental.
|
|
38
|
+
it('a Knative Service is not a core Service', () => {
|
|
39
|
+
expect(tabVisible('Service', 'serving.knative.dev', [])).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ...but if something does select its pods, it still earns the tab through
|
|
43
|
+
// the serving Service, exactly like any other workload.
|
|
44
|
+
it('a same-named CRD still qualifies via its serving Service', () => {
|
|
45
|
+
expect(tabVisible('Service', 'serving.knative.dev', svc)).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,11 @@ export * from './components/topology'
|
|
|
40
40
|
// Cluster audit (AuditCard, AuditAlerts, AuditFindingsTable)
|
|
41
41
|
export * from './components/audit'
|
|
42
42
|
|
|
43
|
+
// Network path trace (ReachabilityView + types) - diagnostics for Service /
|
|
44
|
+
// Ingress / HTTPRoute / GRPCRoute / Gateway. Backed by /api/trace and the
|
|
45
|
+
// internal/trace Go package; the view is presentation-only.
|
|
46
|
+
export * from './components/trace'
|
|
47
|
+
|
|
43
48
|
// Checks remediation queue (ChecksView, shared types + severity vocabulary).
|
|
44
49
|
// Host-agnostic: Hub feeds fleet-resolved data, OSS can feed a single-cluster
|
|
45
50
|
// resolve.
|
package/src/theme/components.css
CHANGED
|
@@ -235,3 +235,41 @@
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
} /* end @layer components */
|
|
238
|
+
|
|
239
|
+
/* Brief pulse the trace panel uses to flash the broken hop after the
|
|
240
|
+
verdict banner's "Show me" button scrolls it into view. Outside the
|
|
241
|
+
components layer so it's not subject to specificity tweaks. */
|
|
242
|
+
@keyframes trace-broken-pulse {
|
|
243
|
+
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
|
244
|
+
35% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.45); }
|
|
245
|
+
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
|
246
|
+
}
|
|
247
|
+
.trace-broken-pulse {
|
|
248
|
+
animation: trace-broken-pulse 1.2s ease-out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* reach-flash: briefly highlights a value that just CHANGED after a probe run, so a
|
|
252
|
+
fast/identical-looking result is still visibly "new". Fades from the accent tint. */
|
|
253
|
+
@keyframes reach-flash {
|
|
254
|
+
0% { background-color: var(--accent-muted); }
|
|
255
|
+
100% { background-color: transparent; }
|
|
256
|
+
}
|
|
257
|
+
.reach-flash {
|
|
258
|
+
animation: reach-flash 1.6s ease-out;
|
|
259
|
+
border-radius: 4px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* reach-edge-testing: a request is IN FLIGHT along this edge right now.
|
|
263
|
+
The dash pattern flows toward the target, so the graph shows the probe
|
|
264
|
+
travelling the path it is testing rather than the page just saying
|
|
265
|
+
"running" in a button. Semantic, not decorative: only edges the running
|
|
266
|
+
vantage is actually exercising get it. */
|
|
267
|
+
@keyframes reach-edge-flow {
|
|
268
|
+
to { stroke-dashoffset: -24; }
|
|
269
|
+
}
|
|
270
|
+
.reach-edge-testing {
|
|
271
|
+
animation: reach-edge-flow 0.9s linear infinite;
|
|
272
|
+
}
|
|
273
|
+
@media (prefers-reduced-motion: reduce) {
|
|
274
|
+
.reach-edge-testing { animation: none; }
|
|
275
|
+
}
|
package/src/theme/variables.css
CHANGED
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
--color-error-light: #F87171;
|
|
30
30
|
--color-error-dark: #DC2626;
|
|
31
31
|
--color-info: #3B82F6;
|
|
32
|
+
/* alert = the orange tier between warning (amber) and error (red). The
|
|
33
|
+
.status-alert class predates these tokens and inlines the same hues. */
|
|
34
|
+
--color-alert: #F97316;
|
|
35
|
+
--color-alert-light: #FB923C;
|
|
36
|
+
--color-alert-dark: #EA580C;
|
|
32
37
|
|
|
33
38
|
/* ── Backgrounds (elevation scale) ──
|
|
34
39
|
sidebar = primary nav rail — the DEEPEST layer, its own token so the rail
|
package/src/types/core.ts
CHANGED
|
@@ -258,8 +258,16 @@ export interface TopologyEdge {
|
|
|
258
258
|
target: string
|
|
259
259
|
type: EdgeType
|
|
260
260
|
label?: string
|
|
261
|
+
/** Hover tooltip for the edge label. Used by the Reachability view to keep the
|
|
262
|
+
* DECLARED route path available when the label shows an overridden tested path. */
|
|
263
|
+
labelTitle?: string
|
|
261
264
|
skipIfKindVisible?: string // Hide this edge if this kind is visible (for shortcut edges)
|
|
262
265
|
policyEffect?: 'allowed' | 'blocked' | 'unprotected'
|
|
266
|
+
/** Reachability outcome for THIS route/hop edge (distinct from policyEffect, a
|
|
267
|
+
* NetworkPolicy concept). When set, the topology renderer colors the edge by
|
|
268
|
+
* reachability: verified/reached = green, unreachable = red, blocked (downstream
|
|
269
|
+
* of a break) = dashed gray, not-tested = neutral. Used by the Reachability view. */
|
|
270
|
+
reachOutcome?: 'verified' | 'reached' | 'unreachable' | 'blocked' | 'not-tested'
|
|
263
271
|
}
|
|
264
272
|
|
|
265
273
|
export interface Topology {
|
|
@@ -200,7 +200,7 @@ describe('buildApplicationHistoryItems', () => {
|
|
|
200
200
|
name: 'shop-config',
|
|
201
201
|
diff: {
|
|
202
202
|
summary: 'Added keys: API_URL',
|
|
203
|
-
fields: [{ path: 'data (added keys)', newValue: 'API_URL' }],
|
|
203
|
+
fields: [{ path: 'data (added keys)', oldValue: undefined, newValue: 'API_URL' }],
|
|
204
204
|
},
|
|
205
205
|
}),
|
|
206
206
|
event({
|
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, buildAppMembershipIndex, batchActivityForApp, batchRuntimeForApp, servingReadiness, type AppGroupFoldEntry, type AppRow, type AppWorkload } from './applications'
|
|
2
|
+
import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, buildAppMembershipIndex, batchActivityForApp, batchRuntimeForApp, servingReadiness, resolveAppWorkloadSelection, type AppGroupFoldEntry, type AppRow, type AppWorkload } from './applications'
|
|
3
|
+
|
|
4
|
+
describe('application workload scope resolution', () => {
|
|
5
|
+
const sole = ['Deployment/prod/api']
|
|
6
|
+
const many = ['Deployment/prod/api', 'Deployment/prod/worker']
|
|
7
|
+
|
|
8
|
+
it('resolves application scope to the sole workload — a one-workload app has none', () => {
|
|
9
|
+
expect(resolveAppWorkloadSelection(sole, null)).toEqual({
|
|
10
|
+
selected: 'Deployment/prod/api',
|
|
11
|
+
hostKeyIsStale: false,
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('keeps application scope for a multi-workload app', () => {
|
|
16
|
+
expect(resolveAppWorkloadSelection(many, null)).toEqual({
|
|
17
|
+
selected: null,
|
|
18
|
+
hostKeyIsStale: false,
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('passes a key that names a current workload straight through', () => {
|
|
23
|
+
expect(resolveAppWorkloadSelection(many, 'Deployment/prod/worker')).toEqual({
|
|
24
|
+
selected: 'Deployment/prod/worker',
|
|
25
|
+
hostKeyIsStale: false,
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// The stale key still has to leave the URL even though the sole workload
|
|
30
|
+
// renders in its place — the two outcomes are deliberately independent.
|
|
31
|
+
it('renders the sole workload from a stale key and still reports the key stale', () => {
|
|
32
|
+
expect(resolveAppWorkloadSelection(sole, 'Deployment/prod/gone')).toEqual({
|
|
33
|
+
selected: 'Deployment/prod/api',
|
|
34
|
+
hostKeyIsStale: true,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls back to application scope when a stale key has no sole workload to land on', () => {
|
|
39
|
+
expect(resolveAppWorkloadSelection(many, 'Deployment/prod/gone')).toEqual({
|
|
40
|
+
selected: null,
|
|
41
|
+
hostKeyIsStale: true,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('reports no stale key when none was supplied', () => {
|
|
46
|
+
expect(resolveAppWorkloadSelection([], null)).toEqual({
|
|
47
|
+
selected: null,
|
|
48
|
+
hostKeyIsStale: false,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// A bare `?workload=` reaches a host as '' rather than null, and names no
|
|
53
|
+
// workload — it has to leave the URL like any other unusable key.
|
|
54
|
+
it('treats the empty string a bare query param yields as a stale key', () => {
|
|
55
|
+
expect(resolveAppWorkloadSelection(sole, '')).toEqual({
|
|
56
|
+
selected: 'Deployment/prod/api',
|
|
57
|
+
hostKeyIsStale: true,
|
|
58
|
+
})
|
|
59
|
+
expect(resolveAppWorkloadSelection(many, '')).toEqual({
|
|
60
|
+
selected: null,
|
|
61
|
+
hostKeyIsStale: true,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
3
65
|
|
|
4
66
|
describe('batch application runtime', () => {
|
|
5
67
|
it('uses the latest retained outcome instead of historical failure count', () => {
|
|
@@ -1364,6 +1364,33 @@ export function applicationDisplayHealth(app: AppRow): AppHealth {
|
|
|
1364
1364
|
]);
|
|
1365
1365
|
}
|
|
1366
1366
|
|
|
1367
|
+
/** Which workload an application detail view renders, and whether the host's
|
|
1368
|
+
* key should be dropped from the URL.
|
|
1369
|
+
*
|
|
1370
|
+
* A single-workload app has no application scope, so it resolves to its sole
|
|
1371
|
+
* workload even when the caller asks for application scope (`null`) or names a
|
|
1372
|
+
* workload the app no longer has. Deciding this here rather than in each host
|
|
1373
|
+
* is the point: a controlled host's only way to say "no workload in the URL"
|
|
1374
|
+
* is `null`, so leaving the rule to hosts makes the forbidden state their
|
|
1375
|
+
* default. `hostKeyIsStale` stays independent of what gets rendered — a key
|
|
1376
|
+
* naming no current workload must leave the URL even when the sole workload
|
|
1377
|
+
* renders in its place. */
|
|
1378
|
+
export function resolveAppWorkloadSelection(
|
|
1379
|
+
workloadKeys: readonly string[],
|
|
1380
|
+
hostSelected: string | null,
|
|
1381
|
+
): { selected: string | null; hostKeyIsStale: boolean } {
|
|
1382
|
+
const matched =
|
|
1383
|
+
hostSelected && workloadKeys.includes(hostSelected) ? hostSelected : null;
|
|
1384
|
+
const soleWorkload = workloadKeys.length === 1 ? workloadKeys[0] : null;
|
|
1385
|
+
return {
|
|
1386
|
+
selected: matched ?? soleWorkload,
|
|
1387
|
+
// Any non-null value counts as a key the host supplied, including the empty
|
|
1388
|
+
// string a bare `?workload=` yields — that names no workload either, and
|
|
1389
|
+
// leaving it in the URL would strand a param nothing can act on.
|
|
1390
|
+
hostKeyIsStale: hostSelected !== null && !matched,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1367
1394
|
export function servingReadiness(workloads: AppWorkload[]): {
|
|
1368
1395
|
ready: number;
|
|
1369
1396
|
desired: number;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { inClusterConsentGiven, rememberInClusterConsent, consentRequestRows } from './inClusterConsent'
|
|
3
|
+
|
|
4
|
+
describe('inClusterConsent', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
const store: Record<string, string> = {}
|
|
7
|
+
vi.stubGlobal('localStorage', {
|
|
8
|
+
getItem: (k: string) => (k in store ? store[k] : null),
|
|
9
|
+
setItem: (k: string, v: string) => { store[k] = v },
|
|
10
|
+
removeItem: (k: string) => { delete store[k] },
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('defaults to not-given', () => {
|
|
15
|
+
expect(inClusterConsentGiven('prod')).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('remembers consent for a cluster', () => {
|
|
19
|
+
rememberInClusterConsent('prod')
|
|
20
|
+
expect(inClusterConsentGiven('prod')).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is keyed per cluster - consent on one does not grant another', () => {
|
|
24
|
+
rememberInClusterConsent('dev')
|
|
25
|
+
expect(inClusterConsentGiven('dev')).toBe(true)
|
|
26
|
+
expect(inClusterConsentGiven('prod')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not throw when localStorage is unavailable, and reports not-given', () => {
|
|
30
|
+
vi.stubGlobal('localStorage', {
|
|
31
|
+
getItem: () => { throw new Error('denied') },
|
|
32
|
+
setItem: () => { throw new Error('denied') },
|
|
33
|
+
})
|
|
34
|
+
expect(() => rememberInClusterConsent('prod')).not.toThrow()
|
|
35
|
+
expect(inClusterConsentGiven('prod')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// No stable cluster identity → consent is never remembered, in ANY store. A
|
|
40
|
+
// persisted fallback key is shared by every cluster a single origin (Radar
|
|
41
|
+
// Hub) serves, and even an in-memory session flag is shared across every
|
|
42
|
+
// identity-less cluster in the session - either would let one cluster's
|
|
43
|
+
// "don't ask again" suppress the pod-creating confirm on all of them. So an
|
|
44
|
+
// identity-less run always asks: given() is always false, remember() no-ops.
|
|
45
|
+
describe('inClusterConsent without a cluster identity', () => {
|
|
46
|
+
it('never writes to localStorage', () => {
|
|
47
|
+
const setItem = vi.fn()
|
|
48
|
+
vi.stubGlobal('localStorage', { getItem: () => null, setItem })
|
|
49
|
+
rememberInClusterConsent(undefined)
|
|
50
|
+
rememberInClusterConsent('')
|
|
51
|
+
expect(setItem).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('never remembers consent - remember() is a no-op and given() stays false', () => {
|
|
55
|
+
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {} })
|
|
56
|
+
expect(inClusterConsentGiven(undefined)).toBe(false)
|
|
57
|
+
rememberInClusterConsent(undefined)
|
|
58
|
+
expect(inClusterConsentGiven(undefined)).toBe(false)
|
|
59
|
+
expect(inClusterConsentGiven('')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not read a persisted key when no cluster is known', () => {
|
|
63
|
+
// Even if some earlier version persisted a shared sentinel, an identity-less
|
|
64
|
+
// check must not honor it.
|
|
65
|
+
const store: Record<string, string> = { 'radar.inClusterConsent.current': '1' }
|
|
66
|
+
vi.stubGlobal('localStorage', {
|
|
67
|
+
getItem: (k: string) => (k in store ? store[k] : null),
|
|
68
|
+
setItem: (k: string, v: string) => { store[k] = v },
|
|
69
|
+
})
|
|
70
|
+
expect(inClusterConsentGiven(undefined)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('identity-less remember does not leak into a named cluster', () => {
|
|
74
|
+
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {} })
|
|
75
|
+
rememberInClusterConsent(undefined)
|
|
76
|
+
expect(inClusterConsentGiven('prod')).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('consent rows name the traffic the Job actually sends', () => {
|
|
81
|
+
it('a TCP-only candidate is a TCP connection, never a fabricated GET', () => {
|
|
82
|
+
const rows = consentRequestRows(
|
|
83
|
+
[{ route: 'database', target: 'database:6379', inClusterRequest: { protocol: 'tcp' } }],
|
|
84
|
+
'',
|
|
85
|
+
)
|
|
86
|
+
expect(rows).toEqual([{ route: 'database', request: 'TCP connection to database:6379' }])
|
|
87
|
+
})
|
|
88
|
+
it('HTTP(S) candidates keep the full request with dialled address and Host header', () => {
|
|
89
|
+
const rows = consentRequestRows(
|
|
90
|
+
[{ route: '/web', target: 'shop:80', inClusterRequest: { protocol: 'http', scheme: 'http', host: 'shop.example.com', path: '/web' } }],
|
|
91
|
+
'',
|
|
92
|
+
)
|
|
93
|
+
expect(rows[0].request).toBe('GET http://shop:80/web (Host: shop.example.com)')
|
|
94
|
+
})
|
|
95
|
+
it('a path override applies to HTTP only - a TCP dial has no path to override', () => {
|
|
96
|
+
const rows = consentRequestRows(
|
|
97
|
+
[
|
|
98
|
+
{ route: 'database', target: 'database:6379', inClusterRequest: { protocol: 'tcp' } },
|
|
99
|
+
{ route: '/web', target: 'shop:80', inClusterRequest: { protocol: 'http', scheme: 'http', path: '/' } },
|
|
100
|
+
],
|
|
101
|
+
'/healthz',
|
|
102
|
+
)
|
|
103
|
+
expect(rows[0].request).toBe('TCP connection to database:6379')
|
|
104
|
+
expect(rows[1].request).toContain('/healthz')
|
|
105
|
+
})
|
|
106
|
+
it('benign dormant routes are never presented for approval', () => {
|
|
107
|
+
expect(consentRequestRows([{ route: 'x', target: 'x:80', benign: true, inClusterRequest: { protocol: 'http' } }], '')).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Consent memory for the mutating in-cluster reachability test (it creates a
|
|
2
|
+
// short-lived Job/pod). Keyed by CLUSTER so a "don't ask again" on one cluster
|
|
3
|
+
// never silently suppresses the confirm on another - the cluster the pod lands
|
|
4
|
+
// in is the whole point of asking. Switching kube-context re-prompts.
|
|
5
|
+
//
|
|
6
|
+
// No cluster identity → no memory of ANY kind: the operator is asked on every
|
|
7
|
+
// run. localStorage is per-ORIGIN, and one origin (Radar Hub) can front many
|
|
8
|
+
// clusters, so a shared fallback key would let "don't ask again" on one cluster
|
|
9
|
+
// suppress the pod-creating confirm on all of them - and even an in-memory
|
|
10
|
+
// session flag is shared across every identity-less cluster in the session.
|
|
11
|
+
// The dialog hides its "don't ask again" checkbox in that case, so nothing is
|
|
12
|
+
// promised that doesn't stick.
|
|
13
|
+
|
|
14
|
+
const key = (cluster: string) => `radar.inClusterConsent.${cluster}`
|
|
15
|
+
|
|
16
|
+
export function inClusterConsentGiven(cluster?: string): boolean {
|
|
17
|
+
if (!cluster) return false
|
|
18
|
+
try {
|
|
19
|
+
return localStorage.getItem(key(cluster)) === '1'
|
|
20
|
+
} catch {
|
|
21
|
+
// localStorage unavailable (private mode / non-browser) - fail toward asking.
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rememberInClusterConsent(cluster?: string): void {
|
|
27
|
+
if (!cluster) return
|
|
28
|
+
try {
|
|
29
|
+
localStorage.setItem(key(cluster), '1')
|
|
30
|
+
} catch {
|
|
31
|
+
// Non-fatal: if we can't persist, the user is simply asked again next time.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One consent row per runnable route - the words the operator approves.
|
|
36
|
+
* This is the MUTATION CONSENT, so it must name the traffic the Job actually
|
|
37
|
+
* sends: a TCP-only candidate (redis, postgres) opens a socket and sends no
|
|
38
|
+
* request - presenting it as "GET http://database:6379/" asks approval for
|
|
39
|
+
* traffic that will never happen. Path overrides apply to HTTP(S) only. */
|
|
40
|
+
export function consentRequestRows(
|
|
41
|
+
routes: Array<{
|
|
42
|
+
route: string
|
|
43
|
+
target?: string
|
|
44
|
+
benign?: boolean
|
|
45
|
+
inClusterRequest?: { protocol?: string; scheme?: string; host?: string; path?: string }
|
|
46
|
+
}>,
|
|
47
|
+
override: string,
|
|
48
|
+
): { route: string; request: string }[] {
|
|
49
|
+
const rows: { route: string; request: string }[] = []
|
|
50
|
+
for (const r of routes) {
|
|
51
|
+
const req = r.inClusterRequest
|
|
52
|
+
if (!req) continue
|
|
53
|
+
// The runner skips benign (deliberately scaled-to-0) routes outright, so
|
|
54
|
+
// listing them as requests overstates what the operator is agreeing to.
|
|
55
|
+
if (r.benign) continue
|
|
56
|
+
const dialled = r.target || ''
|
|
57
|
+
if (req.protocol === 'tcp') {
|
|
58
|
+
rows.push({ route: r.route, request: `TCP connection to ${dialled || req.host || ''}`.trim() })
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const path = override || req.path || '/'
|
|
62
|
+
// The probe dials the SERVICE and passes the hostname as a Host/SNI header.
|
|
63
|
+
// Showing only the hostname put a public-looking address on a consent
|
|
64
|
+
// screen for traffic that never leaves the cluster - so name the address
|
|
65
|
+
// actually dialled, and the Host header as the header it is.
|
|
66
|
+
const scheme = req.scheme || 'http'
|
|
67
|
+
const addr = dialled ? `${scheme}://${dialled}${path}` : `${scheme}://${req.host ?? ''}${path}`
|
|
68
|
+
const asHost = req.host && dialled ? ` (Host: ${req.host})` : ''
|
|
69
|
+
rows.push({ route: r.route, request: `GET ${addr}${asHost}`.trim() })
|
|
70
|
+
}
|
|
71
|
+
return rows
|
|
72
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, afterEach } from 'vitest'
|
|
2
|
+
import { englishPlural } from './pluralize'
|
|
2
3
|
import { kindToPlural, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
|
|
3
4
|
|
|
4
5
|
afterEach(() => {
|
|
@@ -65,6 +66,66 @@ describe('kindToPlural', () => {
|
|
|
65
66
|
expect(kindToPlural('pvc')).toBe('persistentvolumeclaims')
|
|
66
67
|
expect(kindToPlural('PodGroup')).toBe('pods')
|
|
67
68
|
})
|
|
69
|
+
|
|
70
|
+
// A cold direct-URL load runs kindToPlural against the URL's plural slug one
|
|
71
|
+
// round-trip BEFORE initNavigationMap lands, so the discovered-plural guard
|
|
72
|
+
// can't fire for a CRD absent from BUILTIN_PLURAL_TO_KIND.
|
|
73
|
+
describe('CRD plurals before the discovery map arrives', () => {
|
|
74
|
+
test('idempotent on unknown lowercase plurals', () => {
|
|
75
|
+
expect(kindToPlural('schedules')).toBe('schedules')
|
|
76
|
+
expect(kindToPlural('validatingpolicies')).toBe('validatingpolicies')
|
|
77
|
+
expect(kindToPlural('virtualservices')).toBe('virtualservices')
|
|
78
|
+
expect(kindToPlural('clusters')).toBe('clusters')
|
|
79
|
+
expect(kindToPlural('backups')).toBe('backups')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Plurals of `*se` singulars — Flux's HelmRelease and coordination.k8s.io's
|
|
83
|
+
// Lease are both kinds Radar handles, and both reach this cold path.
|
|
84
|
+
test('idempotent on plurals of *se singulars', () => {
|
|
85
|
+
expect(kindToPlural('helmreleases')).toBe('helmreleases')
|
|
86
|
+
expect(kindToPlural('leases')).toBe('leases')
|
|
87
|
+
expect(kindToPlural('databases')).toBe('databases')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('still pluralizes singular PascalCase CRD kinds', () => {
|
|
91
|
+
expect(kindToPlural('Schedule')).toBe('schedules')
|
|
92
|
+
expect(kindToPlural('ValidatingPolicy')).toBe('validatingpolicies')
|
|
93
|
+
expect(kindToPlural('VirtualService')).toBe('virtualservices')
|
|
94
|
+
expect(kindToPlural('Cluster')).toBe('clusters')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Lowercase does NOT imply plural. WorkloadViewRoute passes the URL segment
|
|
98
|
+
// through verbatim, so /workload/deployment/ns/name yields "deployment";
|
|
99
|
+
// pkg/topology's normalizeKind likewise returns its input unchanged when a
|
|
100
|
+
// kind resolves through neither its static map nor discovery, so a
|
|
101
|
+
// ResourceRef can carry "certificaterequest". Both must still pluralize —
|
|
102
|
+
// including the singulars that already end in 's'.
|
|
103
|
+
test('still pluralizes lowercase singular kinds', () => {
|
|
104
|
+
expect(kindToPlural('deployment')).toBe('deployments')
|
|
105
|
+
expect(kindToPlural('pod')).toBe('pods')
|
|
106
|
+
expect(kindToPlural('cronjob')).toBe('cronjobs')
|
|
107
|
+
expect(kindToPlural('certificaterequest')).toBe('certificaterequests')
|
|
108
|
+
expect(kindToPlural('validatingpolicy')).toBe('validatingpolicies')
|
|
109
|
+
expect(kindToPlural('ingress')).toBe('ingresses')
|
|
110
|
+
expect(kindToPlural('nodeclass')).toBe('nodeclasses')
|
|
111
|
+
expect(kindToPlural('ec2nodeclass')).toBe('ec2nodeclasses')
|
|
112
|
+
expect(kindToPlural('storageclass')).toBe('storageclasses')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('leaves the empty-string result unchanged', () => {
|
|
116
|
+
expect(kindToPlural('')).toBe(englishPlural(''))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('agrees with the post-discovery answer', () => {
|
|
120
|
+
const inputs = ['schedules', 'validatingpolicies', 'Schedule', 'ValidatingPolicy']
|
|
121
|
+
const cold = inputs.map(kindToPlural)
|
|
122
|
+
initNavigationMap([
|
|
123
|
+
{ group: 'velero.io', version: 'v1', kind: 'Schedule', name: 'schedules', namespaced: true, isCrd: true, verbs: [] },
|
|
124
|
+
{ group: 'kyverno.io', version: 'v1alpha1', kind: 'ValidatingPolicy', name: 'validatingpolicies', namespaced: false, isCrd: true, verbs: [] },
|
|
125
|
+
])
|
|
126
|
+
expect(inputs.map(kindToPlural)).toEqual(cold)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
68
129
|
})
|
|
69
130
|
|
|
70
131
|
// Demonstrate that the naive .toLowerCase() + 's' pattern used by renderers is broken.
|