@skyhook-io/k8s-ui 1.10.4 → 1.11.0

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 (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,141 @@
1
+ import { useState } from 'react'
2
+ import { ConfirmDialog } from './ConfirmDialog'
3
+ import { rememberInClusterConsent } from '../../utils/inClusterConsent'
4
+
5
+ /** One request the in-cluster job will actually send. */
6
+ export interface ConsentRequest {
7
+ /** Route label, for the operator to recognise which path this is. */
8
+ route: string
9
+ /** Concrete request, e.g. "GET https://checkout.example.com/healthz". */
10
+ request: string
11
+ }
12
+
13
+ interface InClusterConsentDialogProps {
14
+ open: boolean
15
+ cluster?: string
16
+ namespace: string
17
+ /** The requests this run will send, one per declared route. */
18
+ requests?: ConsentRequest[]
19
+ /** Declared paths the run covers but for which no concrete request could be
20
+ * derived. They still count toward the scope the operator is agreeing to. */
21
+ untestedCount?: number
22
+ /** Server-side ceiling on probe Pods per call. */
23
+ maxProbes?: number
24
+ onClose: () => void
25
+ onConfirm: () => void
26
+ }
27
+
28
+ /** Rows are capped so the dialog stays a confirmation, not a report. */
29
+ const MAX_LISTED_REQUESTS = 6
30
+
31
+ /**
32
+ * The fact block the operator confirms against. It is the last screen before
33
+ * real pod-to-pod traffic, so it must name the requests being sent - it used to
34
+ * show only where the Job would land, and then only a single "GET /" that was
35
+ * wrong in the DEFAULT case: with no path override the server keeps each
36
+ * route's OWN declared path, so "/" is least accurate exactly when it is shown.
37
+ */
38
+ export function inClusterConsentDetails(o: {
39
+ cluster?: string
40
+ namespace: string
41
+ requests?: ConsentRequest[]
42
+ untestedCount?: number
43
+ /** Server-side ceiling on probe Pods per call. Routes past it do not run, and
44
+ * since ordering is deterministic they are not reached by re-running either -
45
+ * so the screen must say so rather than imply full coverage. */
46
+ maxProbes?: number
47
+ }): string {
48
+ const reqs = o.requests ?? []
49
+ const untested = o.untestedCount ?? 0
50
+ const total = reqs.length + untested
51
+ const cap = o.maxProbes ?? 0
52
+ const willRun = cap > 0 ? Math.min(reqs.length, cap) : reqs.length
53
+ const lines = [cluster(o.cluster), `Namespace: ${o.namespace}`]
54
+ if (total > 0) {
55
+ lines.push(`Declared: ${total} path${total === 1 ? '' : 's'} on this resource`)
56
+ // "0 requests, one Pod each" is nonsense, and it describes a run that would
57
+ // create nothing at all - so say that instead of counting to zero.
58
+ lines.push(
59
+ willRun === 0
60
+ ? 'Will run: nothing — no request could be derived from these paths'
61
+ : `Will run: ${willRun} request${willRun === 1 ? '' : 's'}, one Pod each`,
62
+ )
63
+ }
64
+ for (const r of reqs.slice(0, MAX_LISTED_REQUESTS)) {
65
+ lines.push(` → ${r.request}`)
66
+ }
67
+ const hidden = reqs.length - Math.min(reqs.length, MAX_LISTED_REQUESTS)
68
+ if (hidden > 0) lines.push(` → …and ${hidden} more`)
69
+ // Named, never silently dropped: they are part of the scope being agreed to
70
+ // even though no concrete request could be derived for them.
71
+ if (untested > 0) {
72
+ lines.push(` → ${untested} path${untested === 1 ? '' : 's'} with no derivable request`)
73
+ }
74
+ const beyond = reqs.length - willRun
75
+ if (beyond > 0) {
76
+ lines.push(` → ${beyond} beyond the ${cap}-Pod limit — not tested, and re-running starts from the same first ${cap}`)
77
+ }
78
+ return lines.filter(Boolean).join('\n')
79
+ }
80
+
81
+ function cluster(name?: string): string {
82
+ return name ? `Cluster: ${name}` : ''
83
+ }
84
+
85
+ // Confirms the mutating in-cluster reachability test before it spawns a Job/pod,
86
+ // naming the cluster it lands in. Permission is enforced upstream (the button only
87
+ // 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
+ const [dontAskAgain, setDontAskAgain] = useState(false)
90
+
91
+ function handleClose() {
92
+ onClose()
93
+ setDontAskAgain(false)
94
+ }
95
+
96
+ function handleConfirm() {
97
+ if (dontAskAgain && cluster) rememberInClusterConsent(cluster)
98
+ setDontAskAgain(false)
99
+ onConfirm()
100
+ }
101
+
102
+ return (
103
+ <ConfirmDialog
104
+ open={open}
105
+ onClose={handleClose}
106
+ onConfirm={handleConfirm}
107
+ // Wide enough for a real cluster identity: a GKE context name runs past
108
+ // 50 characters and wrapped mid-name at the default width.
109
+ className="w-full max-w-xl"
110
+ title="Run in-cluster reachability test?"
111
+ message="This creates one short-lived, self-deleting Job per request, sending real pod-to-pod traffic from inside the cluster. Each Pod runs under the target namespace’s default ServiceAccount with no token mounted — not as you — and your cluster may inject sidecars into it."
112
+ details={inClusterConsentDetails({ cluster, namespace, requests, untestedCount, maxProbes })}
113
+ // Nothing to send means nothing to confirm. The button was live for a run
114
+ // that would create no Pods and change no evidence.
115
+ confirmDisabled={(requests?.length ?? 0) === 0}
116
+ confirmLabel="Run test"
117
+ cancelLabel="Cancel"
118
+ variant="warning"
119
+ >
120
+ {/* "Don't ask again" is stored per cluster. Without a cluster identity
121
+ there is nothing safe to key it on (one origin can front many
122
+ clusters), so consent is never remembered - hide the checkbox rather
123
+ than promise a persistence that doesn't stick. */}
124
+ {cluster ? (
125
+ <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
126
+ <input
127
+ type="checkbox"
128
+ checked={dontAskAgain}
129
+ onChange={(e) => setDontAskAgain(e.target.checked)}
130
+ className="w-4 h-4 rounded border-theme-border bg-theme-base text-amber-600 focus:ring-amber-500 focus:ring-offset-0"
131
+ />
132
+ <span>Don&apos;t ask again for this cluster</span>
133
+ </label>
134
+ ) : (
135
+ <p className="text-xs text-theme-text-tertiary">
136
+ Cluster identity unavailable - this confirmation can&apos;t be saved, so it will be asked on every run.
137
+ </p>
138
+ )}
139
+ </ConfirmDialog>
140
+ )
141
+ }
@@ -27,6 +27,7 @@ export * from './drawer-components'
27
27
  export { Collapse, CollapseChevron } from './Collapse'
28
28
  export { ResourceBar } from './ResourceBar'
29
29
  export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
30
+ export { InClusterConsentDialog } from './InClusterConsentDialog'
30
31
  export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
31
32
  export { CodeViewer } from './CodeViewer'
32
33
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'
@@ -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 findings) */
289
- renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
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.
@@ -235,3 +235,67 @@
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
+ }
276
+
277
+ /* reach-node-testing: the vantage a test is running FROM, while it runs.
278
+ The edge already shows the request travelling; the capsule it leaves from
279
+ stayed inert, so the source of the work looked as static as everything else.
280
+ A slow border breathe + a lifted surface says "this one is working" without
281
+ competing with the flowing edge. Semantic: only the running origin gets it. */
282
+ @keyframes reach-node-breathe {
283
+ 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); }
284
+ 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent); }
285
+ }
286
+ .reach-node-testing {
287
+ animation: reach-node-breathe 1.6s ease-in-out infinite;
288
+ }
289
+ /* The label itself pulses gently - "testing now" that never changes reads as
290
+ a stuck state on a long run. */
291
+ @keyframes reach-label-fade {
292
+ 0%, 100% { opacity: 1; }
293
+ 50% { opacity: 0.55; }
294
+ }
295
+ .reach-label-testing {
296
+ animation: reach-label-fade 1.6s ease-in-out infinite;
297
+ }
298
+ @media (prefers-reduced-motion: reduce) {
299
+ .reach-node-testing,
300
+ .reach-label-testing { animation: none; }
301
+ }
@@ -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 {
@@ -207,6 +207,8 @@ export function formatGroupName(group: string): string {
207
207
  'eks.amazonaws.com': 'EKS',
208
208
  'networking.k8s.aws': 'AWS Networking',
209
209
  'acid.zalan.do': 'Zalando Postgres',
210
+ 'postgresql.cnpg.io': 'CloudNativePG',
211
+ 'barmancloud.cnpg.io': 'CloudNativePG',
210
212
  'serving.kserve.io': 'KServe',
211
213
  'ray.io': 'KubeRay',
212
214
  'leaderworkerset.x-k8s.io': 'LeaderWorkerSet',
@@ -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;