@skyhook-io/k8s-ui 1.10.5 → 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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/components/issues/IssuesView.tsx +9 -0
  3. package/src/components/issues/diagnostic.ts +3 -0
  4. package/src/components/issues/issues.test.ts +11 -0
  5. package/src/components/issues/severity.ts +3 -0
  6. package/src/components/issues/types.ts +3 -0
  7. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  8. package/src/components/resources/ResourcesView.tsx +259 -30
  9. package/src/components/resources/index.ts +2 -0
  10. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  11. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  14. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  15. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  16. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  17. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  18. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  19. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  20. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  21. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  22. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  23. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  24. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  25. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  26. package/src/components/resources/renderers/index.ts +4 -0
  27. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  28. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  29. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  30. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  31. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  32. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  33. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  34. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  35. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  36. package/src/components/resources/resource-utils-velero.ts +214 -22
  37. package/src/components/resources/resource-utils.ts +70 -3
  38. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  39. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  40. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  41. package/src/components/topology/K8sResourceNode.tsx +6 -0
  42. package/src/components/trace/ReachabilityGraph.tsx +20 -6
  43. package/src/components/trace/ReachabilityView.tsx +253 -52
  44. package/src/components/trace/problemRows.test.ts +107 -0
  45. package/src/components/trace/reachGraphModel.test.ts +16 -0
  46. package/src/components/trace/reachInspector.test.ts +233 -10
  47. package/src/components/trace/reachInspector.ts +166 -36
  48. package/src/components/trace/reachMarks.test.ts +32 -1
  49. package/src/components/trace/reachMarks.ts +44 -0
  50. package/src/components/trace/reachOrigins.ts +12 -0
  51. package/src/components/trace/types.ts +25 -0
  52. package/src/components/ui/Badge.tsx +36 -0
  53. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  54. package/src/theme/components.css +26 -0
  55. package/src/utils/api-resources.ts +2 -0
  56. package/src/utils/resource-icons.test.ts +33 -0
  57. package/src/utils/resource-icons.ts +65 -2
@@ -73,7 +73,9 @@ describe('the diagnosis is always present', () => {
73
73
  const s = buildSidebar(undefined, ctx(t, 'incluster'))
74
74
  expect(s.path.title).toBeTruthy()
75
75
  expect(s.path.evidence.length).toBeGreaterThan(0)
76
- expect(s.path.next.header).toBeTruthy()
76
+ // The panel always answers the path question; a next-step block is offered
77
+ // only when there is genuinely a next step.
78
+ expect(s.path.body).toBeTruthy()
77
79
  expect(s.resource).toBeUndefined()
78
80
  })
79
81
 
@@ -223,10 +225,14 @@ describe('verdict band', () => {
223
225
  expect(s.path.next.header).toMatch(/run this next/i)
224
226
  })
225
227
 
226
- it('says so plainly when nothing stronger can be run', () => {
228
+ it('offers nothing when nothing stronger can be run', () => {
229
+ // A resource-level "there is no more to learn" repeated on every vantage was
230
+ // scope mixing; the ceiling is stated with specifics in the caveats and the
231
+ // footer's coverage ledger instead.
227
232
  const t = mk([pod('a', true, '10.0.0.1')], [p({}), p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
228
233
  const s = buildSidebar(undefined, ctx(t, 'incluster'))
229
- expect(s.path.next.header).toMatch(/no stronger test/i)
234
+ expect(s.path.next.header).toBe('')
235
+ expect(s.path.next.ctas).toHaveLength(0)
230
236
  })
231
237
 
232
238
  it('falls back to the backend verdict when there is no route to derive a tone from', () => {
@@ -270,9 +276,13 @@ describe('an in-cluster test that could not run says so first', () => {
270
276
 
271
277
  // The button moved to the vantage capsule in the graph, so the panel must not
272
278
  // offer a third copy of it - it keeps the reasoning instead.
273
- it('offers no run button of its own', () => {
279
+ it('offers the run, disabled, with the reason attached', () => {
280
+ // The action lives WITH its explanation now, so it is present even when it
281
+ // cannot be taken - but never silently clickable into a guaranteed no-op.
274
282
  const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
275
- expect(s.path.next.ctas.some((c) => c.action === 'run-in-cluster')).toBe(false)
283
+ const run = s.path.next.ctas.find((c) => c.action === 'run-in-cluster')
284
+ expect(run).toBeTruthy()
285
+ expect(run!.disabledReason).toMatch(/nothing to run|no path/i)
276
286
  })
277
287
 
278
288
  it('explains it in the body rather than promising stronger evidence', () => {
@@ -634,14 +644,13 @@ describe('a proxy-only failure never claims the target answered', () => {
634
644
  })
635
645
  })
636
646
 
637
- describe('the terminal "nothing to do" state is quiet', () => {
638
- it('no call-to-action styling, no duplicate Re-run, no repeated ceiling caveat', () => {
647
+ describe('nothing to suggest means nothing shown', () => {
648
+ it('renders no next-step block at all when there is no stronger test', () => {
639
649
  const t = mk([pod('a', true, '10.0.0.1')], [p({})])
640
650
  const s = buildSidebar(undefined, ctx(t, 'incluster'))
641
- expect(s.path.next.quiet).toBe(true)
651
+ expect(s.path.next.header).toBe('')
652
+ expect(s.path.next.body).toBe('')
642
653
  expect(s.path.next.ctas).toHaveLength(0)
643
- expect(s.path.next.body).toMatch(/strongest evidence/i)
644
- expect(s.path.next.blocked).toBeUndefined()
645
654
  })
646
655
  })
647
656
 
@@ -667,3 +676,217 @@ describe('a fresh failed attempt outranks leftover skip rows', () => {
667
676
  expect(texts).not.toContain('stale skip')
668
677
  })
669
678
  })
679
+
680
+ describe('a result with no evidence string still reads as a result', () => {
681
+ it('never says "no test has been run" beside an outcome that ran', () => {
682
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', path: 'data', ok: true })])
683
+ const r = route({ route: 'redis', target: 'redis:6379', outcome: 'reached', confidence: 'real', evidence: undefined })
684
+ t.routes = [r]
685
+ const texts = buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence.map((e) => e.text).join('\n')
686
+ expect(texts).not.toContain('no test has been run from here')
687
+ expect(texts.length).toBeGreaterThan(0)
688
+ })
689
+ })
690
+
691
+ describe('selecting a context entry opens it', () => {
692
+ // The entry-problem row exists to answer "show me the cause". Landing on a
693
+ // still-collapsed section makes the reader spend a second click on the very
694
+ // thing they asked for - and it must hold for EVERY declared entry kind.
695
+ const withEntries = (): Trace => {
696
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
697
+ t.upstreams = [
698
+ { resource: { kind: 'Ingress', name: 'shop', namespace: 'store' }, edge: 'ingress->service', findings: [], config: { addresses: ['34.0.0.1'] } },
699
+ { resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, edge: 'httproute->service', findings: [] },
700
+ ]
701
+ return t
702
+ }
703
+ for (const kind of ['Ingress', 'HTTPRoute']) {
704
+ it(`expands the selected ${kind}, and leaves its sibling collapsed`, () => {
705
+ const t = withEntries()
706
+ const c = ctx(t, 'apiserver')
707
+ const id = `n:${kind}/store/shop`
708
+ const s = buildSidebar(id, c)
709
+ const hops = s.context?.hops ?? []
710
+ expect(hops.length).toBeGreaterThan(0)
711
+ const picked = hops.find((h) => h.id === id)
712
+ expect(picked, `${kind} should be a context hop`).toBeTruthy()
713
+ expect(picked!.expanded).toBe(true)
714
+ expect(hops.filter((h) => h.id !== id).every((h) => !h.expanded)).toBe(true)
715
+ })
716
+ }
717
+ it('leaves every context hop collapsed when nothing is selected', () => {
718
+ const s = buildSidebar(undefined, ctx(withEntries(), 'apiserver'))
719
+ expect((s.context?.hops ?? []).every((h) => !h.expanded)).toBe(true)
720
+ })
721
+ })
722
+
723
+ describe('a coverage statement is not a fault', () => {
724
+ it('the verdict keeps it on the wire but marks it coverage-class', () => {
725
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
726
+ t.diagnosis = { class: 'coverage', summary: "reachable via API server - the real-traffic path wasn't confirmed from here" } as never
727
+ // buildVerdict still surfaces it for consumers that want the sentence...
728
+ const v = buildVerdict(t, route({ outcome: 'reached', confidence: 'indirect' }), { originId: 'apiserver', originName: 'API-server proxy' })
729
+ expect(v.problem).toBeTruthy()
730
+ // ...and the class is what the header uses to keep it OUT of the problem list.
731
+ expect(t.diagnosis!.class).toBe('coverage')
732
+ })
733
+ })
734
+
735
+ describe('one observation is stated once', () => {
736
+ it('a localization fact does not repeat the route evidence it came from', () => {
737
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', tone: 'reached', detail: 'HTTP 404 · reached' })])
738
+ const r = route({
739
+ outcome: 'reached', confidence: 'indirect', evidence: 'HTTP 404 · reached',
740
+ localization: [{ layer: 'http', ok: true, detail: 'HTTP 404 · reached' }],
741
+ } as never)
742
+ t.routes = [r]
743
+ const texts = buildSidebar(undefined, ctx(t, 'apiserver', r)).path.evidence.map((e) => e.text)
744
+ const plain = texts.filter((x) => x.toLowerCase().startsWith('http 404 · reached'))
745
+ expect(plain, `saw: ${JSON.stringify(texts)}`).toHaveLength(1)
746
+ // and the surviving line is the MORE specific one
747
+ expect(plain[0]).toContain('checked directly')
748
+ })
749
+ })
750
+
751
+ describe('an answer is interpretable', () => {
752
+ const reached = (evidence: string): RouteResult =>
753
+ route({ outcome: 'reached', confidence: 'real', evidence, inClusterRequest: { protocol: 'http', scheme: 'http', path: '/' } } as never)
754
+ const body = (evidence: string) => {
755
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ tone: 'reached', detail: evidence })])
756
+ const r = reached(evidence)
757
+ t.routes = [r]
758
+ return buildSidebar(undefined, ctx(t, 'incluster', r)).path
759
+ }
760
+
761
+ it('a 404 says the PATH works and names what would verify a route', () => {
762
+ const b = body('HTTP 404 · reached').body
763
+ expect(b).toContain('path works')
764
+ expect(b).toContain('serves no route')
765
+ expect(b).toContain('re-run with a path your app serves')
766
+ })
767
+ it('auth, redirect and app-error answers are each read correctly', () => {
768
+ expect(body('HTTP 401 · reached').body).toContain('credentials')
769
+ expect(body('HTTP 308 · reached, redirect').body).toMatch(/redirect .*Radar does not follow/)
770
+ expect(body('HTTP 503 · reached, server error').body).toContain('application health')
771
+ })
772
+ it('a transport-only reach never claims something was asked', () => {
773
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', ok: true })])
774
+ const r = route({ outcome: 'reached', confidence: 'real', evidence: '', inClusterRequest: { protocol: 'tcp' } } as never)
775
+ t.routes = [r]
776
+ expect(buildSidebar(undefined, ctx(t, 'incluster', r)).path.body).toContain('nothing was asked of the application')
777
+ })
778
+ it('shows WHAT was requested, so a status code can be read at all', () => {
779
+ expect(body('HTTP 404 · reached').scope.some((s) => s.k === 'REQUEST' && s.v === 'GET /')).toBe(true)
780
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', ok: true })])
781
+ const r = route({ outcome: 'reached', confidence: 'real', inClusterRequest: { protocol: 'tcp' } } as never)
782
+ t.routes = [r]
783
+ expect(buildSidebar(undefined, ctx(t, 'incluster', r)).path.scope.some((s) => s.k === 'REQUEST' && s.v === 'TCP connect')).toBe(true)
784
+ })
785
+ })
786
+
787
+ describe('a relayed answer explains the relay AND the answer', () => {
788
+ it('keeps the relay caveat and still reads the status', () => {
789
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', tone: 'reached', detail: 'HTTP 404 · reached' })])
790
+ const r = route({ outcome: 'reached', confidence: 'indirect', evidence: 'HTTP 404 · reached', inClusterRequest: { protocol: 'http', path: '/' } } as never)
791
+ t.routes = [r]
792
+ const b = buildSidebar(undefined, ctx(t, 'apiserver', r)).path.body
793
+ expect(b).toContain('relayed')
794
+ expect(b).toContain('serves no route')
795
+ // ...but never claims the real path works, because a relay cannot show that
796
+ expect(b).not.toContain('The path works')
797
+ })
798
+ })
799
+
800
+ // An answer is not the same as an answer that got through. A gateway saying it
801
+ // could not reach its upstream, and a handshake that never verified, are both
802
+ // answers - and both mean the opposite of "the path works".
803
+ describe('an answer that reports a break never reads as a success', () => {
804
+ const bodyFor = (r: Partial<RouteResult>) => {
805
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ tone: 'reached' } as never)])
806
+ const full = route({ outcome: 'server-error', confidence: 'real', inClusterRequest: { protocol: 'http', path: '/' }, ...r } as never)
807
+ t.routes = [full]
808
+ return buildSidebar(undefined, ctx(t, 'incluster', full)).path.body
809
+ }
810
+
811
+ for (const code of [502, 504]) {
812
+ it(`does not claim the path works on HTTP ${code}`, () => {
813
+ const b = bodyFor({ evidence: `HTTP ${code}`, failedLayer: 'upstream' } as never)
814
+ expect(b).not.toContain('The path works')
815
+ expect(b).toContain('could not reach the backend')
816
+ })
817
+ }
818
+
819
+ it('explains a certificate failure, which carries no status code at all', () => {
820
+ const b = bodyFor({ evidence: 'x509: certificate has expired', failedLayer: 'tls' } as never)
821
+ expect(b).not.toContain('The path works')
822
+ expect(b).toContain('certificate problem')
823
+ // The generic fallthrough would have said this instead, explaining nothing.
824
+ expect(b).not.toContain('not with what was asked for')
825
+ })
826
+
827
+ it('still credits the path when the APP itself answered', () => {
828
+ expect(bodyFor({ evidence: 'HTTP 404' } as never)).toContain('The path works')
829
+ expect(bodyFor({ evidence: 'HTTP 500' } as never)).toContain('The path works')
830
+ })
831
+ })
832
+
833
+ describe('every state explains itself, not the nearest generic sentence', () => {
834
+ const withRoute = (r: RouteResult, probes = [p({})]) => {
835
+ const t = mk([pod('a', true, '10.0.0.1')], probes)
836
+ t.routes = [r]
837
+ return buildSidebar(undefined, ctx(t, 'incluster', r)).path.body
838
+ }
839
+
840
+ it('a deliberately dormant path is not described as an answer', () => {
841
+ const b = withRoute(route({ outcome: 'unreachable', benign: true, evidence: 'no running backends (scaled to 0)' } as never))
842
+ expect(b).toContain('deliberate')
843
+ expect(b).not.toContain('not with what was asked for')
844
+ })
845
+
846
+ it('a kept-informational run says it ran AND why it does not decide', () => {
847
+ const t = mk([pod('a', true, '10.0.0.1')], [
848
+ p({ target: 'shop:80', port: 80, skipped: true, skipClass: 'informational', reason: 'the throwaway Pod was denied by mTLS', ok: false, source: 'probe-job' }),
849
+ ])
850
+ const r = route({ target: 'shop:80', outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)
851
+ t.routes = [r]
852
+ const b = buildSidebar(undefined, ctx(t, 'incluster', r)).path.body
853
+ expect(b + JSON.stringify(buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence)).toContain('denied by mTLS')
854
+ })
855
+
856
+ it('a verified answer is not described as a partial one', () => {
857
+ const b = withRoute(route({ outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · verified' } as never))
858
+ expect(b).toContain('A real request went through')
859
+ })
860
+ })
861
+
862
+
863
+ describe('the action sits with its explanation', () => {
864
+ it('an untested vantage offers the run, and does not repeat the sentence above it', () => {
865
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
866
+ const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined, inClusterRequest: { protocol: 'http', path: '/' } } as never)
867
+ t.routes = [r]
868
+ const s = buildSidebar(undefined, ctx(t, 'incluster', r)).path
869
+ expect(s.body).toContain('Nothing has been tested from here')
870
+ // the action is present...
871
+ expect(s.next.ctas.some((c) => c.action === 'run-in-cluster')).toBe(true)
872
+ // ...and the block does not say "nothing has been tested" a second time
873
+ expect(s.next.body).toBe('')
874
+ })
875
+
876
+ it('WHAT WE SAW disappears rather than echoing "no test has been run"', () => {
877
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
878
+ const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)
879
+ t.routes = [r]
880
+ const ev = buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence
881
+ expect(ev).toHaveLength(0)
882
+ })
883
+
884
+ it('a vantage that CANNOT be used still says why, instead of going silent', () => {
885
+ const t = mk([pod('a', true, '10.0.0.1')], [])
886
+ t.verdict = 'unknown'
887
+ t.routes = [route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)]
888
+ t.downstream![0].probes = [p({ target: 'shop:80', port: 80, vantage: 'local', path: 'apiserver', skipped: true, reason: 'proxy said no', ok: false })]
889
+ const ev = buildSidebar(undefined, ctx(t, 'local', t.routes[0])).path.evidence
890
+ expect(ev.map((e) => e.text).join(' ')).toMatch(/no entry point|Ingress/i)
891
+ })
892
+ })