@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.
- package/package.json +1 -1
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +6 -0
- package/src/components/trace/ReachabilityGraph.tsx +20 -6
- package/src/components/trace/ReachabilityView.tsx +253 -52
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachGraphModel.test.ts +16 -0
- package/src/components/trace/reachInspector.test.ts +233 -10
- package/src/components/trace/reachInspector.ts +166 -36
- package/src/components/trace/reachMarks.test.ts +32 -1
- package/src/components/trace/reachMarks.ts +44 -0
- package/src/components/trace/reachOrigins.ts +12 -0
- package/src/components/trace/types.ts +25 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/theme/components.css +26 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/resource-icons.test.ts +33 -0
- 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
|
-
|
|
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('
|
|
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).
|
|
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
|
|
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
|
-
|
|
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('
|
|
638
|
-
it('no
|
|
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.
|
|
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
|
+
})
|