@skyhook-io/k8s-ui 1.12.0 → 1.13.3

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.
Files changed (117) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.tsx +2 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
  4. package/src/components/issues/severity.ts +1 -0
  5. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  6. package/src/components/resources/ResourcesView.tsx +419 -9
  7. package/src/components/resources/index.ts +27 -0
  8. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  9. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  10. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  11. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  17. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  18. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  19. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  20. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  23. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  24. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  25. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  26. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  27. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  28. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  29. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  30. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  31. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  32. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  33. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  34. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  35. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  36. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  37. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  38. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  39. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  40. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  41. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  42. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  43. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  44. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  45. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  46. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  47. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  48. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  49. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  52. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  53. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  54. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  55. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  56. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  57. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  58. package/src/components/resources/renderers/index.ts +16 -1
  59. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  60. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  61. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  62. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  63. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  64. package/src/components/resources/resource-utils-calico.ts +261 -0
  65. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  66. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  67. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  68. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  69. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  70. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  71. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  72. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  73. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  74. package/src/components/resources/resource-utils-velero.ts +68 -0
  75. package/src/components/resources/resource-utils.ts +98 -0
  76. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  77. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  78. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  79. package/src/components/shared/index.ts +1 -1
  80. package/src/components/shared/revision-role-badges.test.ts +120 -0
  81. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  82. package/src/components/topology/K8sResourceNode.tsx +22 -0
  83. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  84. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  85. package/src/components/topology/TopologyGraph.tsx +6 -6
  86. package/src/components/topology/TopologySearch.tsx +7 -0
  87. package/src/components/topology/layout.ts +3 -0
  88. package/src/components/trace/ReachabilityView.tsx +1 -0
  89. package/src/components/trace/TracePanel.tsx +13 -2
  90. package/src/components/trace/reachFixtures.ts +26 -1
  91. package/src/components/trace/reachGraphModel.test.ts +20 -0
  92. package/src/components/trace/reachGraphModel.ts +8 -1
  93. package/src/components/trace/reachOrigins.test.ts +31 -0
  94. package/src/components/trace/reachOrigins.ts +11 -1
  95. package/src/components/ui/Badge.tsx +10 -0
  96. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  97. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  98. package/src/components/ui/ScheduleValue.tsx +85 -0
  99. package/src/components/ui/SelectMenu.tsx +202 -28
  100. package/src/components/ui/drawer-components.test.tsx +17 -1
  101. package/src/components/ui/drawer-components.tsx +9 -2
  102. package/src/components/ui/index.ts +2 -0
  103. package/src/components/workload/WorkloadView.tsx +5 -2
  104. package/src/types/core.ts +36 -4
  105. package/src/types/policy.ts +153 -2
  106. package/src/utils/clipboard.test.ts +86 -0
  107. package/src/utils/clipboard.ts +50 -0
  108. package/src/utils/extended-resources.ts +4 -7
  109. package/src/utils/format.test.ts +83 -1
  110. package/src/utils/format.ts +80 -1
  111. package/src/utils/inClusterConsent.test.ts +9 -0
  112. package/src/utils/inClusterConsent.ts +9 -9
  113. package/src/utils/index.ts +1 -0
  114. package/src/utils/navigation.test.ts +5 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/resource-icons.test.ts +49 -2
  117. package/src/utils/resource-icons.ts +29 -2
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import { TopologyFilterSidebar } from "./TopologyFilterSidebar";
4
+
5
+ describe("TopologyFilterSidebar Calico policy category", () => {
6
+ it.each([
7
+ "CalicoNetworkPolicy",
8
+ "CalicoGlobalNetworkPolicy",
9
+ "CalicoStagedNetworkPolicy",
10
+ "CalicoStagedGlobalNetworkPolicy",
11
+ "CalicoStagedKubernetesNetworkPolicy",
12
+ ])("places %s under Custom Resources with its kind color", (kind) => {
13
+ const html = renderToString(
14
+ <TopologyFilterSidebar
15
+ nodes={[
16
+ { id: "policy", kind, name: "policy", status: "healthy", data: {} },
17
+ ]}
18
+ visibleKinds={new Set([kind])}
19
+ onToggleKind={() => {}}
20
+ onShowAll={() => {}}
21
+ onHideAll={() => {}}
22
+ />,
23
+ );
24
+
25
+ expect(html).toContain("Custom Resources");
26
+ expect(html).not.toContain(">Networking<");
27
+ expect(html).toContain(
28
+ kind.includes("Staged") ? "text-amber-400" : "text-teal-400",
29
+ );
30
+ });
31
+ });
@@ -34,6 +34,11 @@ const RESOURCE_KINDS: {
34
34
  { kind: 'TCPRoute', label: 'TCPRoute', icon: getTopologyIcon('TCPRoute'), color: 'text-purple-300', category: 'networking' },
35
35
  { kind: 'TLSRoute', label: 'TLSRoute', icon: getTopologyIcon('TLSRoute'), color: 'text-purple-300', category: 'networking' },
36
36
  { kind: 'Service', label: 'Service', icon: getTopologyIcon('Service'), color: 'text-blue-400', category: 'networking' },
37
+ { kind: 'CalicoNetworkPolicy', label: 'Calico NetworkPolicy', icon: getTopologyIcon('CalicoNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
38
+ { kind: 'CalicoGlobalNetworkPolicy', label: 'Calico GlobalNetworkPolicy', icon: getTopologyIcon('CalicoGlobalNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
39
+ { kind: 'CalicoStagedNetworkPolicy', label: 'Calico StagedNetworkPolicy', icon: getTopologyIcon('CalicoStagedNetworkPolicy'), color: 'text-amber-400', category: 'custom' },
40
+ { kind: 'CalicoStagedGlobalNetworkPolicy', label: 'Calico StagedGlobalNetworkPolicy', icon: getTopologyIcon('CalicoStagedGlobalNetworkPolicy'), color: 'text-amber-400', category: 'custom' },
41
+ { kind: 'CalicoStagedKubernetesNetworkPolicy', label: 'Calico StagedKubernetesNetworkPolicy', icon: getTopologyIcon('CalicoStagedKubernetesNetworkPolicy'), color: 'text-amber-400', category: 'custom' },
37
42
 
38
43
  // Workloads
39
44
  { kind: 'Deployment', label: 'Deployment', icon: getTopologyIcon('Deployment'), color: 'text-emerald-400', category: 'workloads' },
@@ -68,15 +68,15 @@ const EDGE_LEGEND: { label: string; color: string }[] = [
68
68
  // Memoized edge style cache to avoid creating new objects on every render
69
69
  const edgeStyleCache = new Map<string, React.CSSProperties>()
70
70
 
71
- function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean): React.CSSProperties {
72
- const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}`
71
+ function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean, partial: boolean): React.CSSProperties {
72
+ const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}-${partial}`
73
73
  let style = edgeStyleCache.get(cacheKey)
74
74
  if (!style) {
75
75
  const edgeColor = getEdgeColor(type, isTrafficView)
76
76
  style = {
77
77
  stroke: edgeColor,
78
78
  strokeWidth: isTrafficView ? 2 : 1.5,
79
- strokeDasharray: isTrafficView && isTrafficEdge && animated ? '5 5' : undefined,
79
+ strokeDasharray: partial ? '6 3' : isTrafficView && isTrafficEdge && animated ? '5 5' : undefined,
80
80
  }
81
81
  edgeStyleCache.set(cacheKey, style)
82
82
  }
@@ -168,7 +168,7 @@ function buildEdges(
168
168
  // label in the key: two route edges between the same source/target/type but
169
169
  // with different outcomes (one verified, one unreachable) are DISTINCT routes -
170
170
  // collapsing them would drop the failing route from the diagram.
171
- const edgeId = `${source}-${target}-${edge.type}${edge.reachOutcome ? `-${edge.reachOutcome}` : ''}${edge.label ? `-${edge.label}` : ''}`
171
+ const edgeId = `${source}-${target}-${edge.type}${edge.partial ? '-partial' : ''}${edge.reachOutcome ? `-${edge.reachOutcome}` : ''}${edge.label ? `-${edge.label}` : ''}`
172
172
  if (seenEdgeIds.has(edgeId)) continue
173
173
  seenEdgeIds.add(edgeId)
174
174
 
@@ -176,7 +176,7 @@ function buildEdges(
176
176
  const edgeColor = reach ? (REACH_COLORS[reach] || '#94a3b8') : getEdgeColor(edge.type, isTrafficView)
177
177
  const isTrafficEdge = edge.type === 'routes-to' || edge.type === 'exposes'
178
178
  // A reachability edge never animates (a dashed "blocked" must not look like flow).
179
- const animated = enableAnimations && isTrafficView && isTrafficEdge && !reach
179
+ const animated = enableAnimations && isTrafficView && isTrafficEdge && !reach && !edge.partial
180
180
 
181
181
  edges.push({
182
182
  id: edgeId,
@@ -196,7 +196,7 @@ function buildEdges(
196
196
  width: 12,
197
197
  height: 12,
198
198
  },
199
- style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated),
199
+ style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated, edge.partial === true),
200
200
  })
201
201
  }
202
202
 
@@ -51,6 +51,13 @@ function getKindColor(kind: string): string {
51
51
  return 'text-purple-300'
52
52
  case 'Service':
53
53
  return 'text-blue-400'
54
+ case 'CalicoNetworkPolicy':
55
+ case 'CalicoGlobalNetworkPolicy':
56
+ return 'text-teal-400'
57
+ case 'CalicoStagedNetworkPolicy':
58
+ case 'CalicoStagedGlobalNetworkPolicy':
59
+ case 'CalicoStagedKubernetesNetworkPolicy':
60
+ return 'text-amber-400'
54
61
  case 'Deployment':
55
62
  case 'Rollout':
56
63
  return 'text-emerald-400'
@@ -865,6 +865,9 @@ const KIND_PRIORITY: Record<string, number> = {
865
865
  'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
866
866
  'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
867
867
  'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7,
868
+ 'CalicoNetworkPolicy': 10, 'CalicoGlobalNetworkPolicy': 10,
869
+ 'CalicoStagedNetworkPolicy': 10, 'CalicoStagedGlobalNetworkPolicy': 10,
870
+ 'CalicoStagedKubernetesNetworkPolicy': 10,
868
871
  'ReplicaSet': 8, 'Pod': 9, 'PodGroup': 9,
869
872
  'ConfigMap': 10, 'Secret': 10, 'ServiceAccount': 10, 'SealedSecret': 10, 'ServiceMonitor': 10, 'PodMonitor': 10, 'WorkflowTemplate': 10, 'ClusterWorkflowTemplate': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10,
870
873
  'KnativeService': 1, 'KnativeConfiguration': 3, 'KnativeRevision': 4, 'KnativeRoute': 2,
@@ -270,6 +270,7 @@ function ReachabilityBoard(props: BoardProps) {
270
270
  actions={
271
271
  <ReachActions
272
272
  {...props}
273
+ runVantage={trace.runVantage}
273
274
  inClusterRunnable={traceInClusterRunnable(trace)}
274
275
  inClusterTested={origins.some((o) => o.id === 'incluster' && o.mark !== 'untested')}
275
276
  supportsHTTPPath={(trace.routes ?? []).some((r) => {
@@ -87,6 +87,7 @@ export interface InClusterCapability {
87
87
  allowed: boolean
88
88
  reason?: string
89
89
  cluster?: string
90
+ clusterKey?: string
90
91
  namespace: string
91
92
  /** Per-call ceiling on probe Pods. Routes past it never run - ordering is
92
93
  * deterministic, so re-running starts from the same first maxProbes. */
@@ -384,7 +385,7 @@ function ProbeOptionsMenu({ probePath, onApplyProbePath }: { probePath?: string;
384
385
  // test" (proxy/laptop vantage) plus, once allowed, the secondary "Test inside the
385
386
  // cluster" (real pod-to-pod) - one grouped control instead of two split corners.
386
387
  // Uses the design-system button classes (.btn-brand / .btn-brand-muted).
387
- export function ReachActions({ onRunProbes, probeRequested, probed, onRunInCluster, inClusterRunning, inClusterAllowed, inClusterRunnable, inClusterTested, probePath, onApplyProbePath, supportsHTTPPath = true }: {
388
+ export function ReachActions({ onRunProbes, probeRequested, probed, onRunInCluster, inClusterRunning, inClusterAllowed, inClusterRunnable, inClusterTested, probePath, onApplyProbePath, supportsHTTPPath = true, runVantage }: {
388
389
  onRunProbes?: () => void
389
390
  probeRequested?: boolean
390
391
  // probed/inClusterTested: each test has already produced a result, so its
@@ -403,6 +404,10 @@ export function ReachActions({ onRunProbes, probeRequested, probed, onRunInClust
403
404
  probePath?: string
404
405
  onApplyProbePath?: (p: string) => void
405
406
  supportsHTTPPath?: boolean
407
+ /** Where Radar itself runs. The primary test is sent by the Radar PROCESS, so
408
+ * "from your machine" is only true when Radar is on one - a hosted or
409
+ * in-cluster Radar dials from its own Pod. */
410
+ runVantage?: 'in-cluster' | 'local'
406
411
  }) {
407
412
  // The in-cluster test stays available whenever the cluster allows it - NEVER
408
413
  // gated on the verdict. Hiding it after it succeeds (when the verdict turns
@@ -437,7 +442,13 @@ export function ReachActions({ onRunProbes, probeRequested, probed, onRunInClust
437
442
  {/* Primary: the reachability test. The refresh icon signals it's
438
443
  re-runnable (it auto-ran on load), so re-running reads as a refresh. */}
439
444
  {onRunProbes && (
440
- <Tooltip content="Tests reachability from your machine and through the API server. Covers EVERY path on this resource, not only the one selected above.">
445
+ <Tooltip
446
+ content={
447
+ runVantage === 'in-cluster'
448
+ ? 'Tests reachability from Radar’s own Pod and through the API server. Covers EVERY path on this resource, not only the one selected above.'
449
+ : 'Tests reachability from your machine and through the API server. Covers EVERY path on this resource, not only the one selected above.'
450
+ }
451
+ >
441
452
  <button
442
453
  type="button"
443
454
  onClick={onRunProbes}
@@ -12,7 +12,7 @@ import type { Trace, PodStatus, ProbeResult } from './types'
12
12
  * so two disagreeing origins cannot both be retained. Faking it here would
13
13
  * imply a capability the model does not have.
14
14
  */
15
- export type DevState = 'mixed' | 'break' | 'front' | 'synth' | 'scale' | 'fanout' | 'none' | 'running' | 'stale' | 'rbac'
15
+ export type DevState = 'mixed' | 'break' | 'front' | 'synth' | 'scale' | 'fanout' | 'none' | 'running' | 'stale' | 'rbac' | 'hosted'
16
16
 
17
17
  export const DEV_STATES: { id: DevState; label: string }[] = [
18
18
  { id: 'mixed', label: '1 · Mixed vantages' },
@@ -25,6 +25,7 @@ export const DEV_STATES: { id: DevState; label: string }[] = [
25
25
  { id: 'running', label: 'Probe running' },
26
26
  { id: 'stale', label: 'Stale + changed' },
27
27
  { id: 'rbac', label: 'Probe not permitted' },
28
+ { id: 'hosted', label: 'Radar runs in-cluster' },
28
29
  ]
29
30
 
30
31
  const NS = 'payments'
@@ -233,6 +234,30 @@ export function devTrace(state: DevState): Trace {
233
234
  }
234
235
  }
235
236
 
237
+ // Radar deployed as a Pod - the hosted and self-hosted-in-cluster shape. The
238
+ // only way to see the vantage rail as those users see it without running a
239
+ // cluster-side Radar: no workstation vantage exists there, and Radar's own
240
+ // process is a first-class origin rather than the throwaway Job.
241
+ if (state === 'hosted') {
242
+ return {
243
+ ...t,
244
+ runVantage: 'in-cluster',
245
+ headline: 'Reachable in-cluster on :80 — the front door is still unproven',
246
+ coverage: { tested: 3, passed: 3, failed: 0, skipped: 1 },
247
+ downstream: [
248
+ serviceHop(),
249
+ podsHop(PODS, [
250
+ ...okProbes.map((p) => ({ ...p, source: 'radar' as const })),
251
+ probe({ path: 'apiserver', target: 'checkout-api port 80', detail: 'HTTP 200 via proxy', latencyNs: 12_000_000 }),
252
+ ]),
253
+ ],
254
+ routes: [
255
+ { route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'verified', confidence: 'real', evidence: 'DNS 2 ms · TCP 1 ms · HTTP 200 · 11 ms' },
256
+ ],
257
+ notTested: [{ route: 'front door · primary-gateway', reason: 'no request has entered through the Gateway', reasonClass: 'coverage' }],
258
+ }
259
+ }
260
+
236
261
  if (state === 'synth') {
237
262
  return {
238
263
  ...t,
@@ -65,6 +65,26 @@ describe('buildGraph lanes', () => {
65
65
  }
66
66
  })
67
67
 
68
+ // The board must not offer a vantage the deployment cannot use: a Radar
69
+ // running as a Pod dials from that Pod, never from the operator's machine.
70
+ it('draws no workstation capsule when Radar runs in the cluster', () => {
71
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ source: 'radar' })])
72
+ t.runVantage = 'in-cluster'
73
+ const os = originsFor(t)
74
+ const g = buildGraph({ trace: t, route: route(), origin: os.find((o) => o.id === 'radar-incluster')!, origins: os })
75
+ expect(g.nodes.find((n) => n.id === 'origin:local')).toBeUndefined()
76
+ expect(g.nodes.filter((n) => n.isOrigin).map((n) => n.id).sort()).toEqual(['origin:apiserver', 'origin:incluster', 'origin:radar-incluster'])
77
+ })
78
+
79
+ it('drops the two-mechanism control-lane note with it', () => {
80
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
81
+ t.runVantage = 'in-cluster'
82
+ const os = originsFor(t)
83
+ const g = buildGraph({ trace: t, route: route(), origin: os.find((o) => o.id === 'apiserver')!, origins: os })
84
+ expect(g.laneControl!.help).not.toMatch(/your machine/)
85
+ expect(g.laneControl!.help).toMatch(/control plane/)
86
+ })
87
+
68
88
  it('the origin is drawn as a vantage capsule, never as a Kubernetes resource', () => {
69
89
  const t = trace([pod('a', true, '10.0.0.1')], [p({})])
70
90
  const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
@@ -1425,10 +1425,17 @@ export function buildGraph({ trace, route, origin, origins, stale, running }: Bu
1425
1425
  const y1 = Math.max(...list.map((n) => n.y + n.h)) + LANE_PAD.bottom
1426
1426
  return { x: x0, y: y0, w: x1 - x0, h: y1 - y0, label, help, color, dashed }
1427
1427
  }
1428
+ // The two-mechanism wording only holds while the workstation vantage is drawn.
1429
+ // With Radar running as a Pod there is no dial from a machine outside, and
1430
+ // contrasting it with the relay described a lane with one member as if it had
1431
+ // two.
1432
+ const laptopDrawn = nodes.some((n) => n.id === 'origin:local')
1428
1433
  const laneControl = boxFor(
1429
1434
  nodes.filter((n) => n.lane === 'control'),
1430
1435
  'NOT FROM INSIDE THE CLUSTER',
1431
- 'Neither request originates inside the cluster’s dataplane, but they differ: a dial from your machine sends real traffic from outside - through a real entry it exercises the path from the entry inward (routing, NetworkPolicy, mesh included). A relayed request is carried by the Kubernetes control plane and bypasses the traffic path entirely.',
1436
+ laptopDrawn
1437
+ ? 'Neither request originates inside the cluster’s dataplane, but they differ: a dial from your machine sends real traffic from outside - through a real entry it exercises the path from the entry inward (routing, NetworkPolicy, mesh included). A relayed request is carried by the Kubernetes control plane and bypasses the traffic path entirely.'
1438
+ : 'This request is carried by the Kubernetes control plane rather than sent along the traffic path: the API server relays it to the target, so routing, NetworkPolicy and the mesh are all bypassed.',
1432
1439
  'var(--color-info)',
1433
1440
  true,
1434
1441
  )
@@ -218,6 +218,37 @@ describe('Radar-as-a-Pod is not the throwaway probe Job', () => {
218
218
  expect(radar?.unsupported).toBeFalsy()
219
219
  })
220
220
 
221
+ // The mirror rule. A Radar that runs as a Pod has no process on the operator's
222
+ // machine, so a workstation vantage there is not a test anyone can run - it is
223
+ // a control that would never do anything, standing where a real gap should be.
224
+ it('omits the workstation vantage entirely when Radar runs in the cluster', () => {
225
+ const t = traceWith([p({ vantage: 'in-cluster', path: 'data', source: 'radar', ok: true })])
226
+ t.runVantage = 'in-cluster'
227
+ const os = buildOrigins(t)
228
+ expect(os.find((o) => o.id === 'local')).toBeUndefined()
229
+ expect(os.map((o) => o.id)).toEqual(['caller', 'external', 'incluster', 'radar-incluster', 'apiserver'])
230
+ })
231
+
232
+ it('keeps the external gap declared there — it is what nothing can prove', () => {
233
+ const t = traceWith([p({ vantage: 'in-cluster', path: 'data', source: 'radar', ok: true })])
234
+ t.runVantage = 'in-cluster'
235
+ expect(buildOrigins(t).find((o) => o.id === 'external')?.unsupported).toBe(true)
236
+ })
237
+
238
+ // Hiding a vantage must never hide evidence. If a laptop-side result somehow
239
+ // arrives on an in-cluster trace, the row it belongs to has to stay.
240
+ it('keeps the workstation vantage when it actually produced a result', () => {
241
+ const t = traceWith([p({ vantage: 'local', path: 'data', ok: true })])
242
+ t.runVantage = 'in-cluster'
243
+ expect(buildOrigins(t).find((o) => o.id === 'local')).toBeDefined()
244
+ })
245
+
246
+ it('keeps it on a laptop trace that has not been probed yet', () => {
247
+ const t = traceWith([])
248
+ t.runVantage = 'local'
249
+ expect(buildOrigins(t).find((o) => o.id === 'local')).toBeDefined()
250
+ })
251
+
221
252
  it('describes Radar-in-cluster with Radar\'s own identity, not the Job\'s', () => {
222
253
  const t = traceWith([p({ vantage: 'in-cluster', path: 'data', source: 'radar', ok: true })])
223
254
  t.runVantage = 'in-cluster'
@@ -357,7 +357,17 @@ export function buildOrigins(trace: Trace | undefined, ctx: OriginContext = {}):
357
357
  // same ground. Listing it as "never tested" would pad the coverage caveat with
358
358
  // a deployment detail, next to genuine holes like "as your application".
359
359
  const radarIsAbsent = radarProbes.length === 0 && trace?.runVantage !== 'in-cluster'
360
- return ORIGIN_STRENGTH.filter((id) => !(id === 'radar-incluster' && radarIsAbsent)).map((id) => all[id])
360
+ // The mirror image, and the same reasoning. When Radar runs as a Pod - hosted,
361
+ // or self-hosted in-cluster - there is no Radar on the operator's machine to
362
+ // dial from, so this vantage is not one that can be run and then isn't. The
363
+ // browser being on a laptop is irrelevant: the request is sent by the Radar
364
+ // process. What a machine outside the cluster would see is still declared, by
365
+ // the external origin, which is a real gap and stays. Guarded on having no
366
+ // probes so a mislabelled result can never hide evidence that exists.
367
+ const laptopIsAbsent = localProbes.length === 0 && trace?.runVantage === 'in-cluster'
368
+ return ORIGIN_STRENGTH.filter(
369
+ (id) => !(id === 'radar-incluster' && radarIsAbsent) && !(id === 'local' && laptopIsAbsent),
370
+ ).map((id) => all[id])
361
371
  }
362
372
 
363
373
  /**
@@ -102,6 +102,11 @@ const KIND: Record<string, string> = {
102
102
  PersistentVolumeClaim: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
103
103
  PodDisruptionBudget: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
104
104
  NetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
105
+ CalicoNetworkPolicy: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-300 dark:border-teal-700/40',
106
+ CalicoGlobalNetworkPolicy: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-300 dark:border-teal-700/40',
107
+ CalicoStagedNetworkPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
108
+ CalicoStagedGlobalNetworkPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
109
+ CalicoStagedKubernetesNetworkPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
105
110
  CiliumNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
106
111
  CiliumClusterwideNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
107
112
  ClusterNetworkPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
@@ -263,6 +268,11 @@ export function getKindColorClass(kind: string): string {
263
268
  persistentvolumeclaims: 'PersistentVolumeClaim',
264
269
  poddisruptionbudgets: 'PodDisruptionBudget',
265
270
  networkpolicies: 'NetworkPolicy',
271
+ caliconetworkpolicies: 'CalicoNetworkPolicy',
272
+ calicoglobalnetworkpolicies: 'CalicoGlobalNetworkPolicy',
273
+ calicostagednetworkpolicies: 'CalicoStagedNetworkPolicy',
274
+ calicostagedglobalnetworkpolicies: 'CalicoStagedGlobalNetworkPolicy',
275
+ calicostagedkubernetesnetworkpolicies: 'CalicoStagedKubernetesNetworkPolicy',
266
276
  ciliumnetworkpolicies: 'CiliumNetworkPolicy',
267
277
  ciliumclusterwidenetworkpolicies: 'CiliumClusterwideNetworkPolicy',
268
278
  clusternetworkpolicies: 'ClusterNetworkPolicy',
@@ -13,6 +13,7 @@ export interface ConsentRequest {
13
13
  interface InClusterConsentDialogProps {
14
14
  open: boolean
15
15
  cluster?: string
16
+ clusterKey?: string
16
17
  namespace: string
17
18
  /** The requests this run will send, one per declared route. */
18
19
  requests?: ConsentRequest[]
@@ -85,7 +86,7 @@ function cluster(name?: string): string {
85
86
  // Confirms the mutating in-cluster reachability test before it spawns a Job/pod,
86
87
  // naming the cluster it lands in. Permission is enforced upstream (the button only
87
88
  // renders when the capability SSAR allows), so this is a safety confirm, not authz.
88
- export function InClusterConsentDialog({ open, cluster, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
+ export function InClusterConsentDialog({ open, cluster, clusterKey, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
90
  const [dontAskAgain, setDontAskAgain] = useState(false)
90
91
 
91
92
  function handleClose() {
@@ -94,7 +95,7 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
94
95
  }
95
96
 
96
97
  function handleConfirm() {
97
- if (dontAskAgain && cluster) rememberInClusterConsent(cluster)
98
+ if (dontAskAgain && clusterKey) rememberInClusterConsent(clusterKey)
98
99
  setDontAskAgain(false)
99
100
  onConfirm()
100
101
  }
@@ -117,11 +118,11 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
117
118
  cancelLabel="Cancel"
118
119
  variant="warning"
119
120
  >
120
- {/* "Don't ask again" is stored per cluster. Without a cluster identity
121
+ {/* "Don't ask again" is stored per cluster source. Without a source identity
121
122
  there is nothing safe to key it on (one origin can front many
122
123
  clusters), so consent is never remembered - hide the checkbox rather
123
124
  than promise a persistence that doesn't stick. */}
124
- {cluster ? (
125
+ {clusterKey ? (
125
126
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
126
127
  <input
127
128
  type="checkbox"
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { CronValue, TimeValue, cronHint } from './ScheduleValue'
4
+
5
+ const render = (node: React.ReactElement) => renderToString(node)
6
+
7
+ /**
8
+ * The expression stays on screen verbatim — it is the data operators copy and
9
+ * diff. What is under test is the annotation around it, and above all the cases
10
+ * where there must not be one.
11
+ */
12
+ describe('CronValue', () => {
13
+ it('keeps the expression exactly as written', () => {
14
+ expect(render(<CronValue cron="0 2 * * *" />)).toContain('0 2 * * *')
15
+ })
16
+
17
+ // The tooltip renders into a portal on hover, so the markup carries the
18
+ // affordance rather than the wording. Without the affordance it is a secret.
19
+ it('marks a schedule it understands as worth hovering', () => {
20
+ const html = render(<CronValue cron="0 2 * * *" />)
21
+ expect(html).toContain('cursor-help')
22
+ expect(html).toContain('border-dotted')
23
+ })
24
+
25
+ it('reads the shapes these policies actually use', () => {
26
+ expect(cronHint('0 2 * * *')).toBe('Daily at 2:00')
27
+ expect(cronHint('0 */6 * * *')).toBe('Every 6 hours')
28
+ expect(cronHint('* * * * *')).toBe('Every minute')
29
+ expect(cronHint('30 3 * * 1-5')).toBe('Weekdays at 3:30')
30
+ })
31
+
32
+ it('offers no reading it cannot stand behind', () => {
33
+ expect(cronHint('0 2 1 * *')).toBeUndefined()
34
+ expect(cronHint('0 0 2 * * *', 'seconds')).toBeUndefined()
35
+ expect(cronHint('0 2 * * *', 'seconds')).toBeUndefined()
36
+ expect(cronHint('')).toBeUndefined()
37
+ })
38
+
39
+ // A tooltip that repeats the value teaches the reader that the underline is
40
+ // not worth hovering, which costs more than the one it would have explained.
41
+ it('stays a plain value when it cannot describe the schedule', () => {
42
+ const monthly = render(<CronValue cron="0 2 1 * *" />)
43
+ expect(monthly).toContain('0 2 1 * *')
44
+ expect(monthly).not.toContain('cursor-help')
45
+ })
46
+
47
+ // CloudNativePG counts seconds first. Read as five fields, `0 0 2 * * *`
48
+ // describes midnight rather than 2am — a backup window stated an hour wrong.
49
+ it('never reads a seconds-first expression as if it were five fields', () => {
50
+ const html = render(<CronValue cron="0 0 2 * * *" dialect="seconds" />)
51
+ expect(html).toContain('0 0 2 * * *')
52
+ expect(html).not.toContain('cursor-help')
53
+ expect(html).not.toContain('Daily at')
54
+ })
55
+
56
+ // The same guard from the other direction: even a five-field expression is
57
+ // left alone once the field is declared seconds-first, so a hand-written
58
+ // value cannot pick up the wrong dialect's meaning.
59
+ it('leaves a five-field expression alone in a seconds-first field', () => {
60
+ const html = render(<CronValue cron="0 2 * * *" dialect="seconds" />)
61
+ expect(html).not.toContain('Daily at 2:00')
62
+ })
63
+
64
+ it('renders nothing surprising for an empty schedule', () => {
65
+ expect(render(<CronValue cron="" />)).not.toContain('cursor-help')
66
+ })
67
+ })
68
+
69
+ describe('TimeValue', () => {
70
+ it('answers "is this recent" rather than printing a machine timestamp', () => {
71
+ const html = render(<TimeValue timestamp={new Date(Date.now() - 6 * 3600_000).toISOString()} />)
72
+ expect(html).toContain('cursor-help')
73
+ // The RFC3339 string is what the hover carries, not what the row shows.
74
+ expect(html).not.toMatch(/\dT\d\d:\d\d:\d\dZ/)
75
+ })
76
+
77
+ it('says so when the thing never happened', () => {
78
+ expect(render(<TimeValue timestamp={undefined} fallback="Never" />)).toContain('Never')
79
+ })
80
+ })
@@ -0,0 +1,85 @@
1
+ import { Tooltip } from './Tooltip'
2
+ import { cronToHuman, formatAge } from '../resources/resource-utils'
3
+
4
+ /**
5
+ * Hover affordance used across the drawer: without it a tooltip is a secret.
6
+ * Matches the Created row in drawer-components and the QoS label in PodRenderer.
7
+ */
8
+ const HOVERABLE = 'border-b border-dotted border-theme-text-tertiary cursor-help'
9
+
10
+ /** Which cron dialect the operator that owns this field parses. */
11
+ export type CronDialect =
12
+ /** Five fields, minute first. Kubernetes CronJob, Kyverno, Velero, Argo. */
13
+ | 'standard'
14
+ /** Six fields, seconds first. CloudNativePG. */
15
+ | 'seconds'
16
+
17
+ interface CronValueProps {
18
+ cron: string
19
+ /**
20
+ * Defaults to the Kubernetes dialect. Pass 'seconds' for CloudNativePG: read
21
+ * as five fields, `0 0 2 * * *` describes midnight instead of 2am, and a
22
+ * backup window stated an hour wrong is worse than one left unexplained.
23
+ */
24
+ dialect?: CronDialect
25
+ }
26
+
27
+ /**
28
+ * A cron expression, verbatim, with its meaning on hover.
29
+ *
30
+ * The expression stays the value because it is the data — operators copy it,
31
+ * diff it against Git, and paste it into `kubectl`. The plain-English reading
32
+ * is the annotation, not the replacement.
33
+ */
34
+ /**
35
+ * The plain-English reading to offer for an expression, or undefined when there
36
+ * is none worth offering.
37
+ *
38
+ * cronToHuman returns its input when it cannot describe an expression with
39
+ * confidence — a monthly schedule, an unusual step. A tooltip repeating the
40
+ * value teaches the reader that the underline is not worth hovering, which
41
+ * costs more than the one it would have explained.
42
+ *
43
+ * Separate from the component because the tooltip renders into a portal on
44
+ * hover, so the wording is not in the server-rendered markup and cannot be
45
+ * asserted there.
46
+ */
47
+ export function cronHint(cron: string, dialect: CronDialect = 'standard'): string | undefined {
48
+ if (!cron || dialect !== 'standard') return undefined
49
+ const human = cronToHuman(cron)
50
+ return human === cron || human === '-' ? undefined : human
51
+ }
52
+
53
+ export function CronValue({ cron, dialect = 'standard' }: CronValueProps) {
54
+ const hint = cronHint(cron, dialect)
55
+ if (!hint) return <span className="font-mono">{cron}</span>
56
+
57
+ return (
58
+ <Tooltip content={hint} position="right">
59
+ <span className={`font-mono ${HOVERABLE}`}>{cron}</span>
60
+ </Tooltip>
61
+ )
62
+ }
63
+
64
+ interface TimeValueProps {
65
+ /** RFC3339 from the apiserver. */
66
+ timestamp?: string
67
+ /** Shown when there is no timestamp. "Never" reads better than a dash on a
68
+ * field that records something having happened. */
69
+ fallback?: string
70
+ }
71
+
72
+ /**
73
+ * A moment in time as an age, with the exact local time on hover.
74
+ *
75
+ * "6h ago" answers the question actually being asked — is this recent? — and
76
+ * the raw RFC3339 answers it in UTC, which is nobody's wall clock.
77
+ */
78
+ export function TimeValue({ timestamp, fallback = '-' }: TimeValueProps) {
79
+ if (!timestamp) return <>{fallback}</>
80
+ return (
81
+ <Tooltip content={new Date(timestamp).toLocaleString()} position="right">
82
+ <span className={HOVERABLE}>{formatAge(timestamp)}</span>
83
+ </Tooltip>
84
+ )
85
+ }