@skyhook-io/radar-app 1.13.2 → 1.13.4
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 +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +12 -1
- package/src/api/client.ts +24 -10
- package/src/api/diagnose.ts +2 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -34
- package/src/components/diagnose/DiagnoseSurface.tsx +167 -92
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +665 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +631 -25
- package/src/components/diagnose/InvestigationView.tsx +23 -7
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- package/src/components/diagnose/investigationEvidence.test.ts +2102 -9
- package/src/components/diagnose/investigationEvidence.ts +1357 -65
- package/src/components/diagnose/investigationExplanation.test.ts +30 -1
- package/src/components/diagnose/investigationExplanation.ts +7 -0
- package/src/components/diagnose/investigationSourceFocus.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +55 -1
- package/src/components/diagnose/parts.tsx +68 -1
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/index.ts +7 -5
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
investigationExplanation,
|
|
4
|
+
supportsAssessmentExplanation,
|
|
5
|
+
} from "./investigationExplanation";
|
|
6
|
+
import type { AgentInfo } from "../../api/diagnose";
|
|
3
7
|
import type { Turn } from "./parts";
|
|
4
8
|
|
|
5
9
|
const turn = (overrides: Partial<Turn> = {}): Turn => ({
|
|
@@ -10,6 +14,31 @@ const turn = (overrides: Partial<Turn> = {}): Turn => ({
|
|
|
10
14
|
...overrides,
|
|
11
15
|
});
|
|
12
16
|
describe("assessment-local explanation", () => {
|
|
17
|
+
it("requires explicit hosted support without enabling mutations or changing local support", () => {
|
|
18
|
+
const agent: AgentInfo = {
|
|
19
|
+
name: "hub",
|
|
20
|
+
label: "Managed agent",
|
|
21
|
+
path: "",
|
|
22
|
+
version: "",
|
|
23
|
+
supported: true,
|
|
24
|
+
present: true,
|
|
25
|
+
hosted: true,
|
|
26
|
+
};
|
|
27
|
+
expect(supportsAssessmentExplanation(undefined)).toBe(false);
|
|
28
|
+
expect(supportsAssessmentExplanation(agent)).toBe(false);
|
|
29
|
+
expect(
|
|
30
|
+
supportsAssessmentExplanation({
|
|
31
|
+
...agent,
|
|
32
|
+
assessmentExplanations: false,
|
|
33
|
+
}),
|
|
34
|
+
).toBe(false);
|
|
35
|
+
expect(
|
|
36
|
+
supportsAssessmentExplanation({ ...agent, assessmentExplanations: true }),
|
|
37
|
+
).toBe(true);
|
|
38
|
+
expect(supportsAssessmentExplanation({ ...agent, hosted: false })).toBe(
|
|
39
|
+
true,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
13
42
|
it("does not mistake an ordinary answer for an explanation", () => {
|
|
14
43
|
expect(
|
|
15
44
|
investigationExplanation([turn({ question: "Explain simply" })], 2),
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { AssessmentExplanation, Turn } from "./parts";
|
|
2
|
+
import type { AgentInfo } from "../../api/diagnose";
|
|
3
|
+
|
|
4
|
+
export function supportsAssessmentExplanation(
|
|
5
|
+
agent: AgentInfo | undefined,
|
|
6
|
+
): boolean {
|
|
7
|
+
return !!agent && (!agent.hosted || agent.assessmentExplanations === true);
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
export function investigationExplanation(
|
|
4
11
|
turns: readonly Turn[],
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Timeline,
|
|
9
9
|
TurnView,
|
|
10
10
|
appendThinking,
|
|
11
|
+
remediationCommands,
|
|
11
12
|
upsertTool,
|
|
12
13
|
type TimelineItem,
|
|
13
14
|
type Turn,
|
|
@@ -40,7 +41,9 @@ it("keeps earlier remediation copyable without suggesting it is executable", ()
|
|
|
40
41
|
/>,
|
|
41
42
|
);
|
|
42
43
|
expect(html).toContain("Restore the required Secret");
|
|
43
|
-
|
|
44
|
+
// Prose has nothing worth copying; only a command gets a copy button.
|
|
45
|
+
expect(html).not.toContain("Copy remediation step");
|
|
46
|
+
expect(html).not.toContain("Copy command");
|
|
44
47
|
expect(html).toContain("Reassess before applying");
|
|
45
48
|
expect(html).not.toContain("Apply…");
|
|
46
49
|
expect(html).not.toContain("ask the agent to continue");
|
|
@@ -49,6 +52,48 @@ it("keeps earlier remediation copyable without suggesting it is executable", ()
|
|
|
49
52
|
);
|
|
50
53
|
});
|
|
51
54
|
|
|
55
|
+
describe("remediation commands", () => {
|
|
56
|
+
it("extracts shell commands from inline code and fenced blocks, not identifiers", () => {
|
|
57
|
+
expect(
|
|
58
|
+
remediationCommands(
|
|
59
|
+
"Run `kubectl rollout undo deployment/api -n dev --to-revision=7` to revert; the `MONGO_PASSWORD` key in `Secret/api` stays.",
|
|
60
|
+
),
|
|
61
|
+
).toEqual(["kubectl rollout undo deployment/api -n dev --to-revision=7"]);
|
|
62
|
+
expect(
|
|
63
|
+
remediationCommands(
|
|
64
|
+
"Then:\n```bash\nhelm rollback prometheus 1 -n opencost\nkubectl -n opencost get pods\n```\nand check `mongosh --host nonprod-boxer.ax6bh.mongodb.net`.",
|
|
65
|
+
),
|
|
66
|
+
).toEqual([
|
|
67
|
+
"helm rollback prometheus 1 -n opencost\nkubectl -n opencost get pods",
|
|
68
|
+
"mongosh --host nonprod-boxer.ax6bh.mongodb.net",
|
|
69
|
+
]);
|
|
70
|
+
expect(
|
|
71
|
+
remediationCommands("Pin the image to a digest instead of `latest`."),
|
|
72
|
+
).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("offers one copy button per command in a step", () => {
|
|
76
|
+
const html = renderToStaticMarkup(
|
|
77
|
+
<ResultCard
|
|
78
|
+
diagnosis={
|
|
79
|
+
{
|
|
80
|
+
rootCause: "Stale credential",
|
|
81
|
+
report: "Assessment",
|
|
82
|
+
remediation: [
|
|
83
|
+
"Roll back with `kubectl rollout undo deployment/api -n dev` and then verify with `kubectl -n dev get pods`.",
|
|
84
|
+
],
|
|
85
|
+
} as Diagnosis
|
|
86
|
+
}
|
|
87
|
+
section="actions"
|
|
88
|
+
compactActions
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
expect(html).toContain("Copy command 1 of step 1");
|
|
92
|
+
expect(html).toContain("Copy command 2 of step 1");
|
|
93
|
+
expect(html).not.toContain("Copy remediation step");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
52
97
|
describe("explanation placement", () => {
|
|
53
98
|
it("gives analysis and explanation separate animated disclosure regions", () => {
|
|
54
99
|
const html = renderToStaticMarkup(
|
|
@@ -581,6 +626,15 @@ describe("ResultCard conclusion states", () => {
|
|
|
581
626
|
expect(html).not.toContain("border-emerald-500/30");
|
|
582
627
|
});
|
|
583
628
|
|
|
629
|
+
it("lists a step's commands in reading order across inline and fenced code", () => {
|
|
630
|
+
const step =
|
|
631
|
+
"Roll it with `kubectl -n dev rollout restart deploy/api`, then verify:\n```bash\nkubectl -n dev rollout status deploy/api\n```";
|
|
632
|
+
expect(remediationCommands(step)).toEqual([
|
|
633
|
+
"kubectl -n dev rollout restart deploy/api",
|
|
634
|
+
"kubectl -n dev rollout status deploy/api",
|
|
635
|
+
]);
|
|
636
|
+
});
|
|
637
|
+
|
|
584
638
|
it("keeps a follow-up framed as an answer rather than a new conclusion", () => {
|
|
585
639
|
const html = renderToStaticMarkup(
|
|
586
640
|
<ResultCard
|
|
@@ -2045,7 +2045,17 @@ function DiagnosisResult({
|
|
|
2045
2045
|
Apply…
|
|
2046
2046
|
</button>
|
|
2047
2047
|
)}
|
|
2048
|
-
|
|
2048
|
+
{remediationCommands(r).map((command, c, all) => (
|
|
2049
|
+
<CopyButton
|
|
2050
|
+
key={c}
|
|
2051
|
+
text={command}
|
|
2052
|
+
label={
|
|
2053
|
+
all.length > 1
|
|
2054
|
+
? `Copy command ${c + 1} of step ${i + 1}`
|
|
2055
|
+
: `Copy command from step ${i + 1}`
|
|
2056
|
+
}
|
|
2057
|
+
/>
|
|
2058
|
+
))}
|
|
2049
2059
|
</div>
|
|
2050
2060
|
</div>
|
|
2051
2061
|
</div>
|
|
@@ -2603,6 +2613,63 @@ function ApplyOutcomeCard({
|
|
|
2603
2613
|
);
|
|
2604
2614
|
}
|
|
2605
2615
|
|
|
2616
|
+
const COMMAND_BINARIES = new Set([
|
|
2617
|
+
"kubectl",
|
|
2618
|
+
"helm",
|
|
2619
|
+
"argocd",
|
|
2620
|
+
"flux",
|
|
2621
|
+
"kustomize",
|
|
2622
|
+
"docker",
|
|
2623
|
+
"gcloud",
|
|
2624
|
+
"aws",
|
|
2625
|
+
"az",
|
|
2626
|
+
"mongosh",
|
|
2627
|
+
"psql",
|
|
2628
|
+
"redis-cli",
|
|
2629
|
+
"curl",
|
|
2630
|
+
"git",
|
|
2631
|
+
"istioctl",
|
|
2632
|
+
"velero",
|
|
2633
|
+
"cilium",
|
|
2634
|
+
"calicoctl",
|
|
2635
|
+
"terraform",
|
|
2636
|
+
"kn",
|
|
2637
|
+
"oc",
|
|
2638
|
+
"k9s",
|
|
2639
|
+
"skyhook",
|
|
2640
|
+
]);
|
|
2641
|
+
|
|
2642
|
+
/**
|
|
2643
|
+
* The commands inside a remediation step, in order: every fenced block and
|
|
2644
|
+
* every inline code span that reads as a shell invocation. A step's prose is
|
|
2645
|
+
* never worth copying; a command is. The prompt asks the agent to wrap
|
|
2646
|
+
* commands in backticks, so this is the seam to read them from.
|
|
2647
|
+
*/
|
|
2648
|
+
export function remediationCommands(step: string): string[] {
|
|
2649
|
+
const commands: string[] = [];
|
|
2650
|
+
// One pass in reading order, so button N is the Nth command in the text.
|
|
2651
|
+
const code = /```[a-zA-Z]*\n([\s\S]*?)```|`([^`\n]+)`/g;
|
|
2652
|
+
let match: RegExpExecArray | null;
|
|
2653
|
+
while ((match = code.exec(step))) {
|
|
2654
|
+
if (match[1] !== undefined) {
|
|
2655
|
+
const body = match[1].trim();
|
|
2656
|
+
if (body) commands.push(body);
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
const span = match[2].trim();
|
|
2660
|
+
if (looksLikeCommand(span)) commands.push(span);
|
|
2661
|
+
}
|
|
2662
|
+
return commands;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function looksLikeCommand(span: string): boolean {
|
|
2666
|
+
if (!/\s/.test(span)) return false;
|
|
2667
|
+
const first = span.split(/\s+/)[0];
|
|
2668
|
+
if (COMMAND_BINARIES.has(first)) return true;
|
|
2669
|
+
// An unknown binary still reads as a command when it takes flags.
|
|
2670
|
+
return /^[a-z][a-z0-9._-]*$/.test(first) && /(^|\s)--?[a-zA-Z]/.test(span);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2606
2673
|
function CopyButton({ text, label }: { text: string; label: string }) {
|
|
2607
2674
|
const [copied, setCopied] = useState(false);
|
|
2608
2675
|
return (
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
// The access state is what is under test, so the fetch is stubbed rather than
|
|
5
|
+
// run. isForbiddenError is deliberately NOT stubbed: a looser stand-in would
|
|
6
|
+
// pass while the real predicate sent a denial down the silent-hide path, which
|
|
7
|
+
// is the failure this component exists to prevent.
|
|
8
|
+
import { ApiError } from '../../api/client'
|
|
9
|
+
|
|
10
|
+
const forbidden = new ApiError('forbidden', 403)
|
|
11
|
+
let metricsResult: { data?: unknown; error?: unknown } = {}
|
|
12
|
+
let connected = true
|
|
13
|
+
|
|
14
|
+
vi.mock('../../api/client', async (importActual) => ({
|
|
15
|
+
...(await importActual<typeof import('../../api/client')>()),
|
|
16
|
+
usePrometheusHPAMetrics: () => metricsResult,
|
|
17
|
+
usePrometheusStatus: () => ({ data: { connected } }),
|
|
18
|
+
useAutoPromConnect: () => undefined,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const { HPACharts } = await import('./HPACharts')
|
|
22
|
+
|
|
23
|
+
const hpa = {
|
|
24
|
+
kind: 'HorizontalPodAutoscaler',
|
|
25
|
+
metadata: { namespace: 'shop', name: 'web' },
|
|
26
|
+
spec: { minReplicas: 2, maxReplicas: 10 },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const series = (values: number[]) => ({
|
|
30
|
+
resultType: 'matrix',
|
|
31
|
+
series: [{ labels: {}, dataPoints: values.map((value, i) => ({ timestamp: 1700000000 + i * 60, value })) }],
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A denied chart must not look like an HPA that never scaled: an empty
|
|
36
|
+
* "no history" and "you may not see the history" are different facts.
|
|
37
|
+
*/
|
|
38
|
+
describe('HPACharts — access state', () => {
|
|
39
|
+
it('says the caller has no access when the curated endpoint returns 403', () => {
|
|
40
|
+
connected = true
|
|
41
|
+
metricsResult = { error: forbidden }
|
|
42
|
+
const html = renderToString(<HPACharts data={hpa} />)
|
|
43
|
+
expect(html).toContain('have access to metrics for this resource')
|
|
44
|
+
expect(html).toContain('Activity (last 1h)')
|
|
45
|
+
expect(html).not.toContain('Replicas')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('plots the replica history when the endpoint answers', () => {
|
|
49
|
+
connected = true
|
|
50
|
+
metricsResult = { data: { current: series([2, 3, 4]), desired: series([4, 4, 4]) } }
|
|
51
|
+
const html = renderToString(<HPACharts data={hpa} />)
|
|
52
|
+
expect(html).toContain('Replicas')
|
|
53
|
+
expect(html).toContain('min 2')
|
|
54
|
+
expect(html).toContain('max 10')
|
|
55
|
+
expect(html).not.toContain('access to metrics')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stays hidden when KSM reports nothing for the HPA', () => {
|
|
59
|
+
connected = true
|
|
60
|
+
metricsResult = { data: { current: { resultType: 'matrix', series: [] }, desired: { resultType: 'matrix', series: [] } } }
|
|
61
|
+
expect(renderToString(<HPACharts data={hpa} />)).toBe('')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// A fault is not a denial: the chart keeps its silent-hide behaviour and
|
|
65
|
+
// leaves the breadcrumb to the console.
|
|
66
|
+
it('stays hidden on a non-authorization failure', () => {
|
|
67
|
+
connected = true
|
|
68
|
+
metricsResult = { error: new Error('Prometheus query failed') }
|
|
69
|
+
expect(renderToString(<HPACharts data={hpa} />)).toBe('')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('stays hidden when Prometheus is not connected, even on a denial', () => {
|
|
73
|
+
connected = false
|
|
74
|
+
metricsResult = { error: forbidden }
|
|
75
|
+
expect(renderToString(<HPACharts data={hpa} />)).toBe('')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { useEffect, useMemo } from 'react'
|
|
2
|
-
import { LineChart } from 'lucide-react'
|
|
3
|
-
import {
|
|
2
|
+
import { LineChart, Lock } from 'lucide-react'
|
|
3
|
+
import { AlertBanner } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { usePrometheusHPAMetrics, usePrometheusStatus, useAutoPromConnect, isForbiddenError, type PrometheusSeries } from '../../api/client'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* HPACharts — replicas-over-time chart for an HPA.
|
|
7
8
|
*
|
|
8
|
-
* Sources from KSM `kube_horizontalpodautoscaler_status_{current,desired}_replicas
|
|
9
|
-
*
|
|
9
|
+
* Sources from KSM `kube_horizontalpodautoscaler_status_{current,desired}_replicas`
|
|
10
|
+
* through the curated HPA metrics endpoint, which is gated on reading the HPA
|
|
11
|
+
* itself. Hidden silently when Prom isn't connected or KSM isn't reporting the
|
|
12
|
+
* series; a denial is shown, because an empty chart would read as "no history".
|
|
10
13
|
*
|
|
11
14
|
* Only the replicas series is plotted — KSM doesn't expose the observed metric
|
|
12
15
|
* the HPA target compares against, so an "observed vs target" chart would need
|
|
@@ -25,35 +28,43 @@ export function HPACharts({ data }: { data: any }) {
|
|
|
25
28
|
const min = spec.minReplicas ?? 1
|
|
26
29
|
const max = spec.maxReplicas
|
|
27
30
|
|
|
28
|
-
const currentQuery = useMemo(
|
|
29
|
-
() => `kube_horizontalpodautoscaler_status_current_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
|
|
30
|
-
[namespace, name],
|
|
31
|
-
)
|
|
32
|
-
const desiredQuery = useMemo(
|
|
33
|
-
() => `kube_horizontalpodautoscaler_status_desired_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
|
|
34
|
-
[namespace, name],
|
|
35
|
-
)
|
|
36
|
-
|
|
37
31
|
const enabled = isConnected && Boolean(namespace && name)
|
|
38
|
-
const { data:
|
|
39
|
-
const { data: desiredRes, error: desiredErr } = usePromQLRange(desiredQuery, '1h', enabled)
|
|
32
|
+
const { data: metrics, error } = usePrometheusHPAMetrics(namespace, name, '1h', enabled)
|
|
40
33
|
|
|
41
34
|
const replicasPoints = useMemo(() => combineSeries({
|
|
42
|
-
current:
|
|
43
|
-
desired:
|
|
44
|
-
}), [
|
|
35
|
+
current: metrics?.current?.series,
|
|
36
|
+
desired: metrics?.desired?.series,
|
|
37
|
+
}), [metrics])
|
|
45
38
|
|
|
46
39
|
// Surface Prom-side failures in the console so an operator debugging a
|
|
47
40
|
// missing HPA chart has a breadcrumb; the chart still hides silently when
|
|
48
41
|
// KSM isn't reporting (the common no-data case). Effect-gated so we log
|
|
49
42
|
// once per error change, not on every re-render.
|
|
50
43
|
useEffect(() => {
|
|
51
|
-
if (
|
|
52
|
-
console.warn('[HPACharts]
|
|
44
|
+
if (error && !isForbiddenError(error)) {
|
|
45
|
+
console.warn('[HPACharts] HPA metrics query failed', { error })
|
|
53
46
|
}
|
|
54
|
-
}, [
|
|
47
|
+
}, [error])
|
|
55
48
|
|
|
56
49
|
if (!isConnected) return null
|
|
50
|
+
|
|
51
|
+
if (isForbiddenError(error)) {
|
|
52
|
+
return (
|
|
53
|
+
<section className="mt-4 rounded-lg border border-theme-border bg-theme-surface/30 p-3">
|
|
54
|
+
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-theme-text-secondary">
|
|
55
|
+
<LineChart className="w-4 h-4 text-theme-text-tertiary" />
|
|
56
|
+
Activity (last 1h)
|
|
57
|
+
</div>
|
|
58
|
+
<AlertBanner
|
|
59
|
+
variant="info"
|
|
60
|
+
icon={Lock}
|
|
61
|
+
title="You don't have access to metrics for this resource"
|
|
62
|
+
message="Replica history is available to users who can read this HorizontalPodAutoscaler."
|
|
63
|
+
/>
|
|
64
|
+
</section>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
if (!replicasPoints) return null
|
|
58
69
|
|
|
59
70
|
return (
|
|
@@ -111,10 +122,6 @@ function combineSeries(args: { current?: PrometheusSeries[]; desired?: Prometheu
|
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
|
|
114
|
-
function escapeLabel(s: string): string {
|
|
115
|
-
return s.replace(/[\\"]/g, '\\$&')
|
|
116
|
-
}
|
|
117
|
-
|
|
118
125
|
// ============================================================================
|
|
119
126
|
// DualLineChart — minimal two-line chart for HPA-style time series.
|
|
120
127
|
// Deliberately separate from PrometheusCharts.AreaChart: the chart shapes are
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { useQuery } from '@tanstack/react-query'
|
|
4
|
-
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
|
|
4
|
+
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useResources, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
|
|
5
5
|
import { isBadgeWorthy } from '../../utils/auditBadges'
|
|
6
6
|
import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
|
|
7
7
|
import { apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from '../../api/config'
|
|
8
8
|
import { useAPIResources } from '../../api/apiResources'
|
|
9
9
|
import { useConnection } from '../../context/ConnectionContext'
|
|
10
|
-
import { initNavigationMap } from '@skyhook-io/k8s-ui'
|
|
10
|
+
import { initNavigationMap, getSecretStoreProviderType } from '@skyhook-io/k8s-ui'
|
|
11
11
|
import { usePinnedKinds } from '../../hooks/useFavorites'
|
|
12
12
|
import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
|
|
13
13
|
import {
|
|
@@ -311,6 +311,30 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
311
311
|
// Certificate expiry
|
|
312
312
|
const { data: certExpiry, isError: certExpiryError } = useSecretCertExpiry()
|
|
313
313
|
|
|
314
|
+
// An ExternalSecret names a store; only the store says which backend it reads
|
|
315
|
+
// from. Two list calls per page beat one lookup per row, and the column shows
|
|
316
|
+
// nothing rather than guessing when a store is unreadable or absent.
|
|
317
|
+
const viewingExternalSecrets = selectedKind?.name === 'externalsecrets'
|
|
318
|
+
const { data: secretStores, isPending: secretStoresPending } = useResources<any>('secretstores', undefined, undefined, { enabled: viewingExternalSecrets })
|
|
319
|
+
const { data: clusterSecretStores, isPending: clusterSecretStoresPending } = useResources<any>('clustersecretstores', undefined, undefined, { enabled: viewingExternalSecrets })
|
|
320
|
+
const storeProviders = useMemo(() => {
|
|
321
|
+
// Undefined until both lists have settled. An empty map would be
|
|
322
|
+
// indistinguishable from "every store is unreadable", and the column says
|
|
323
|
+
// that out loud.
|
|
324
|
+
if (!viewingExternalSecrets || secretStoresPending || clusterSecretStoresPending) return undefined
|
|
325
|
+
const map: Record<string, string> = {}
|
|
326
|
+
for (const store of secretStores ?? []) {
|
|
327
|
+
const ns = store?.metadata?.namespace
|
|
328
|
+
const name = store?.metadata?.name
|
|
329
|
+
if (ns && name) map[`${ns}/${name}`] = getSecretStoreProviderType(store)
|
|
330
|
+
}
|
|
331
|
+
for (const store of clusterSecretStores ?? []) {
|
|
332
|
+
const name = store?.metadata?.name
|
|
333
|
+
if (name) map[name] = getSecretStoreProviderType(store)
|
|
334
|
+
}
|
|
335
|
+
return map
|
|
336
|
+
}, [viewingExternalSecrets, secretStoresPending, clusterSecretStoresPending, secretStores, clusterSecretStores])
|
|
337
|
+
|
|
314
338
|
// Pinned kinds
|
|
315
339
|
const { pinned, togglePin, isPinned } = usePinnedKinds()
|
|
316
340
|
|
|
@@ -380,6 +404,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
380
404
|
topPodMetrics={topPodMetrics}
|
|
381
405
|
topNodeMetrics={topNodeMetrics}
|
|
382
406
|
certExpiry={certExpiry}
|
|
407
|
+
storeProviders={storeProviders}
|
|
383
408
|
certExpiryError={certExpiryError}
|
|
384
409
|
auditBadges={auditBadges}
|
|
385
410
|
// Pinned kinds
|
package/src/index.ts
CHANGED
|
@@ -32,13 +32,15 @@ export type {
|
|
|
32
32
|
} from './context/DiagnoseCustomization';
|
|
33
33
|
|
|
34
34
|
// Standalone AI investigation surface — mount the investigation panel outside a
|
|
35
|
-
// full <RadarApp>.
|
|
36
|
-
// backend set via setApiBase()
|
|
37
|
-
// <DiagnoseProvider key={cluster}> to switch.
|
|
38
|
-
//
|
|
39
|
-
// @tanstack/react-query QueryClientProvider.
|
|
35
|
+
// full <RadarApp>. It uses the nearest React Router for durable drawer/workspace
|
|
36
|
+
// URLs, or supplies one when mounted standalone. The backend set via setApiBase()
|
|
37
|
+
// picks the cluster, so hosts remount <DiagnoseProvider key={cluster}> to switch.
|
|
38
|
+
// Mount order: ThemeProvider > DiagnoseCustomizationProvider > DiagnoseProvider
|
|
39
|
+
// > DiagnoseSurface, under a @tanstack/react-query QueryClientProvider.
|
|
40
40
|
export {
|
|
41
41
|
DiagnoseProvider,
|
|
42
|
+
investigationWorkspaceNavigationState,
|
|
43
|
+
investigationWorkspaceSearch,
|
|
42
44
|
useDiagnose,
|
|
43
45
|
useDiagnoseLayout,
|
|
44
46
|
} from './components/diagnose/DiagnoseContext';
|