@skyhook-io/radar-app 1.13.3 → 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.
@@ -29,6 +29,10 @@ export function evidenceSourceExcerpt(
29
29
  ? keys[0]
30
30
  : undefined;
31
31
  }
32
+ case "helm":
33
+ return data.release.healthIssue;
34
+ case "permissions":
35
+ return data.accessCheck?.reason;
32
36
  default:
33
37
  return undefined;
34
38
  }
@@ -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
- expect(html).toContain("Copy remediation step 1");
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
- <CopyButton text={r} label={`Copy remediation step ${i + 1}`} />
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 { usePromQLRange, usePrometheusStatus, useAutoPromConnect, type PrometheusSeries } from '../../api/client'
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
- * Hidden silently when Prom isn't connected or KSM isn't reporting the series.
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: currentRes, error: currentErr } = usePromQLRange(currentQuery, '1h', enabled)
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: currentRes?.series,
43
- desired: desiredRes?.series,
44
- }), [currentRes, desiredRes])
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 (currentErr || desiredErr) {
52
- console.warn('[HPACharts] PromQL query failed', { currentErr, desiredErr })
44
+ if (error && !isForbiddenError(error)) {
45
+ console.warn('[HPACharts] HPA metrics query failed', { error })
53
46
  }
54
- }, [currentErr, desiredErr])
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>. No router dependency, no client-side cluster state: the
36
- // backend set via setApiBase() picks the cluster, so hosts remount
37
- // <DiagnoseProvider key={cluster}> to switch. Mount order: ThemeProvider >
38
- // DiagnoseCustomizationProvider > DiagnoseProvider > DiagnoseSurface, under a
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';