@skyhook-io/radar-app 1.16.1 → 1.16.3

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 (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cnpgDetailKindFor, cnpgDetailPath, decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes'
3
+
4
+ describe('CNPG routes', () => {
5
+ it('parses workspace screens and falls back to Overview for unknown or unavailable ones', () => {
6
+ expect(parseCNPGRoute('/cnpg').screen).toBe('overview')
7
+ expect(parseCNPGRoute('/cnpg/').screen).toBe('overview')
8
+ expect(parseCNPGRoute('/cnpg/nope').screen).toBe('overview')
9
+ expect(parseCNPGRoute('/cnpg/protection').screen).toBe('protection')
10
+ expect(parseCNPGRoute('/cnpg/operator').screen).toBe('operator')
11
+ })
12
+
13
+ it('round-trips a drawer trail and keeps the API group', () => {
14
+ const trail = [
15
+ { kind: 'backups', group: 'postgresql.cnpg.io', namespace: 'payments', name: 'pg-billing-20260928020000' },
16
+ { kind: 'objectstores', group: 'barmancloud.cnpg.io', namespace: 'payments', name: 's3-billing' },
17
+ { kind: 'clusterimagecatalogs', group: 'postgresql.cnpg.io', namespace: '', name: 'postgresql-standard' },
18
+ ]
19
+ const encoded = encodeDrawerTrail(trail)
20
+ expect(decodeDrawerTrail(encoded)).toEqual(trail)
21
+ })
22
+
23
+ it('drops malformed entries instead of opening a guessed object', () => {
24
+ expect(decodeDrawerTrail('clusters:postgresql.cnpg.io:payments:pg-orders~garbage')).toEqual([
25
+ { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'payments', name: 'pg-orders' },
26
+ ])
27
+ expect(decodeDrawerTrail(null)).toEqual([])
28
+ })
29
+
30
+ it('distinguishes same-named kinds from different groups', () => {
31
+ const cnpg = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'a', name: 'x' }
32
+ const capi = { kind: 'clusters', group: 'cluster.x-k8s.io', namespace: 'a', name: 'x' }
33
+ expect(sameResource(cnpg, capi)).toBe(false)
34
+ expect(sameResource(cnpg, { ...cnpg })).toBe(true)
35
+ })
36
+
37
+ it('parses full-detail routes for every CNPG kind and the cluster-scoped placeholder', () => {
38
+ expect(parseCNPGRoute('/cnpg/clusters/payments/pg-orders')).toEqual({
39
+ screen: 'overview',
40
+ detail: { plural: 'clusters', group: 'postgresql.cnpg.io', namespace: 'payments', name: 'pg-orders' },
41
+ })
42
+ expect(parseCNPGRoute('/cnpg/objectstores/payments/s3-billing').screen).toBe('protection')
43
+ expect(parseCNPGRoute('/cnpg/objectstores/payments/s3-billing').detail?.group).toBe('barmancloud.cnpg.io')
44
+ expect(parseCNPGRoute('/cnpg/clusterimagecatalogs/_/postgresql-standard').detail?.namespace).toBe('')
45
+ expect(parseCNPGRoute('/cnpg/clusters/payments').detail).toBeUndefined()
46
+ })
47
+
48
+ it('builds detail paths carrying the context', () => {
49
+ expect(cnpgDetailPath({ plural: 'clusters', namespace: 'payments', name: 'pg-orders' }, 'prod', 'logs')).toBe(
50
+ '/cnpg/clusters/payments/pg-orders?ctx=prod&tab=logs',
51
+ )
52
+ expect(cnpgDetailPath({ plural: 'clusterimagecatalogs', namespace: '', name: 'std' })).toBe('/cnpg/clusterimagecatalogs/_/std')
53
+ })
54
+
55
+ it('only claims CNPG kinds from the CNPG groups', () => {
56
+ expect(cnpgDetailKindFor('clusters', 'postgresql.cnpg.io')).toBe('clusters')
57
+ expect(cnpgDetailKindFor('clusters', 'cluster.x-k8s.io')).toBeNull()
58
+ expect(cnpgDetailKindFor('backups', 'velero.io')).toBeNull()
59
+ })
60
+ })
@@ -0,0 +1,112 @@
1
+ import type { SelectedResource } from '../../types'
2
+
3
+ export type CNPGScreen = 'overview' | 'protection' | 'declarations' | 'pooling' | 'operator'
4
+
5
+ export const CNPG_SCREENS: { id: CNPGScreen; label: string; path: string }[] = [
6
+ { id: 'overview', label: 'Overview', path: '/cnpg' },
7
+ { id: 'protection', label: 'Protection', path: '/cnpg/protection' },
8
+ { id: 'declarations', label: 'Declarations', path: '/cnpg/declarations' },
9
+ { id: 'pooling', label: 'Pooling', path: '/cnpg/pooling' },
10
+ { id: 'operator', label: 'Operator', path: '/cnpg/operator' },
11
+ ]
12
+
13
+ export interface CNPGDetailTarget {
14
+ plural: string
15
+ group: string
16
+ namespace: string
17
+ name: string
18
+ }
19
+
20
+ export interface CNPGRoute {
21
+ screen: CNPGScreen
22
+ detail?: CNPGDetailTarget
23
+ }
24
+
25
+ // The CNPG kinds that have a CNPG-framed full detail, with the workspace
26
+ // destination each one lives under.
27
+ export const CNPG_DETAIL_KINDS: Record<string, { group: string; kind: string; home: CNPGScreen; clusterScoped?: boolean }> = {
28
+ clusters: { group: 'postgresql.cnpg.io', kind: 'Cluster', home: 'overview' },
29
+ backups: { group: 'postgresql.cnpg.io', kind: 'Backup', home: 'protection' },
30
+ scheduledbackups: { group: 'postgresql.cnpg.io', kind: 'ScheduledBackup', home: 'protection' },
31
+ objectstores: { group: 'barmancloud.cnpg.io', kind: 'ObjectStore', home: 'protection' },
32
+ databases: { group: 'postgresql.cnpg.io', kind: 'Database', home: 'declarations' },
33
+ publications: { group: 'postgresql.cnpg.io', kind: 'Publication', home: 'declarations' },
34
+ subscriptions: { group: 'postgresql.cnpg.io', kind: 'Subscription', home: 'declarations' },
35
+ poolers: { group: 'postgresql.cnpg.io', kind: 'Pooler', home: 'pooling' },
36
+ imagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ImageCatalog', home: 'operator' },
37
+ clusterimagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ClusterImageCatalog', home: 'operator', clusterScoped: true },
38
+ }
39
+
40
+ export function cnpgDetailKindFor(plural: string, group: string | undefined): string | null {
41
+ const p = plural.toLowerCase()
42
+ const spec = CNPG_DETAIL_KINDS[p]
43
+ return spec && spec.group === (group ?? '') ? p : null
44
+ }
45
+
46
+ export function parseCNPGRoute(pathname: string): CNPGRoute {
47
+ const seg = pathname.replace(/^\/+/, '').split('/').map((s) => {
48
+ try {
49
+ return decodeURIComponent(s)
50
+ } catch {
51
+ return s
52
+ }
53
+ })
54
+ if (seg[0] !== 'cnpg') return { screen: 'overview' }
55
+ const s = seg[1] ?? ''
56
+ const detailSpec = CNPG_DETAIL_KINDS[s]
57
+ if (detailSpec && seg[2] && seg[3]) {
58
+ return {
59
+ screen: detailSpec.home,
60
+ detail: { plural: s, group: detailSpec.group, namespace: seg[2] === '_' ? '' : seg[2], name: seg[3] },
61
+ }
62
+ }
63
+ const match = CNPG_SCREENS.find((x) => x.id === s)
64
+ if (match) return { screen: match.id }
65
+ return { screen: 'overview' }
66
+ }
67
+
68
+ /** Full detail path; `ctx` pins the Kubernetes context the object belongs to. */
69
+ export function cnpgDetailPath(target: Omit<CNPGDetailTarget, 'group'>, ctx?: string, tab?: string): string {
70
+ const params = new URLSearchParams()
71
+ if (ctx) params.set('ctx', ctx)
72
+ if (tab) params.set('tab', tab)
73
+ const qs = params.toString()
74
+ return `/cnpg/${target.plural}/${encodeURIComponent(target.namespace || '_')}/${encodeURIComponent(target.name)}${qs ? `?${qs}` : ''}`
75
+ }
76
+
77
+ export function cnpgScreenPath(screen: CNPGScreen): string {
78
+ return CNPG_SCREENS.find((s) => s.id === screen)?.path ?? '/cnpg'
79
+ }
80
+
81
+ // Drawer identity in the URL: kind:group:namespace:name, chained with "~" for
82
+ // the in-drawer trail (last entry is the one shown). Kubernetes names and API
83
+ // groups cannot contain ":" or "~", and the group is mandatory — CNPG's Cluster
84
+ // and Backup collide with CAPI, KubeBlocks and Velero kinds.
85
+ export function encodeDrawerRef(r: SelectedResource): string {
86
+ return [r.kind, r.group ?? '', r.namespace ?? '', r.name].join(':')
87
+ }
88
+
89
+ export function decodeDrawerRef(s: string): SelectedResource | null {
90
+ const parts = s.split(':')
91
+ if (parts.length !== 4 || !parts[0] || !parts[3]) return null
92
+ return { kind: parts[0], group: parts[1], namespace: parts[2], name: parts[3] }
93
+ }
94
+
95
+ export function decodeDrawerTrail(param: string | null): SelectedResource[] {
96
+ if (!param) return []
97
+ return param.split('~').map(decodeDrawerRef).filter((r): r is SelectedResource => r !== null)
98
+ }
99
+
100
+ export function encodeDrawerTrail(trail: SelectedResource[]): string {
101
+ return trail.map(encodeDrawerRef).join('~')
102
+ }
103
+
104
+ export function sameResource(a: SelectedResource | null | undefined, b: SelectedResource | null | undefined): boolean {
105
+ if (!a || !b) return false
106
+ return (
107
+ a.kind.toLowerCase() === b.kind.toLowerCase() &&
108
+ (a.group ?? '') === (b.group ?? '') &&
109
+ (a.namespace ?? '') === (b.namespace ?? '') &&
110
+ a.name === b.name
111
+ )
112
+ }
@@ -0,0 +1,290 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { UseQueryResult } from '@tanstack/react-query'
3
+ import { clsx } from 'clsx'
4
+ import { Database, X } from 'lucide-react'
5
+ import {
6
+ CNPG_KIND_BY_KEY,
7
+ PaneLoader,
8
+ type CNPGFleet,
9
+ type CNPGKindCoverage,
10
+ type CNPGWorkspaceResponse,
11
+ } from '@skyhook-io/k8s-ui'
12
+ import type { SelectedResource } from '../../types'
13
+ import { useConnection } from '../../context/ConnectionContext'
14
+ import { EmptyState, Notice, ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../capacity/shared'
15
+ import { sameResource } from './routes'
16
+
17
+ export interface CNPGScreenProps {
18
+ data: CNPGWorkspaceResponse
19
+ fleet: CNPGFleet
20
+ namespaces: string[]
21
+ searchParams: URLSearchParams
22
+ onSetParams: (update: Record<string, string | null>) => void
23
+ onInspect: (resource: SelectedResource) => void
24
+ inspected: SelectedResource | null
25
+ onClearNamespaces: () => void
26
+ }
27
+
28
+ const COVERAGE_LABEL: Record<string, string> = {
29
+ denied: 'no access',
30
+ partial: 'no access in some namespaces',
31
+ syncing: 'still loading',
32
+ error: 'could not be read',
33
+ }
34
+
35
+ export function CNPGWorkspaceHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {
36
+ return (
37
+ <div className="px-5 pt-4 xl:px-7">
38
+ <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
39
+ <Database className="h-3.5 w-3.5" />
40
+ <span>CloudNativePG</span>
41
+ </div>
42
+ <div className="mt-1 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
43
+ <div className="min-w-0">
44
+ <h1 className="text-lg font-semibold text-theme-text-primary">{title}</h1>
45
+ {subtitle && <div className="mt-0.5 text-sm text-theme-text-secondary">{subtitle}</div>}
46
+ </div>
47
+ {actions}
48
+ </div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export function CoverageNotice({ fleet, data }: { fleet: CNPGFleet; data: CNPGWorkspaceResponse }) {
54
+ if (fleet.incompleteKinds.length === 0) return null
55
+ const parts = fleet.incompleteKinds.map((k) => {
56
+ const cov = data.coverage[k]
57
+ const label = COVERAGE_LABEL[cov?.state ?? ''] ?? cov?.state
58
+ return `${CNPG_KIND_BY_KEY[k].kind} (${label})`
59
+ })
60
+ return (
61
+ <Notice>
62
+ Some CloudNativePG data is not readable: {parts.join(', ')}. Facts built on it read “No access” or “unknown” rather than none, and counts are lower bounds.
63
+ </Notice>
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Loading, error and not-installed states every workspace screen shares.
69
+ * Renders the screen only once there is workspace data to render.
70
+ */
71
+ export function CNPGScreenGate({
72
+ query,
73
+ fleet,
74
+ children,
75
+ }: {
76
+ query: UseQueryResult<CNPGWorkspaceResponse>
77
+ fleet: CNPGFleet | null
78
+ children: (data: CNPGWorkspaceResponse, fleet: CNPGFleet) => ReactNode
79
+ }) {
80
+ const { connection } = useConnection()
81
+ const data = query.data
82
+ if (!data && query.isLoading) return <PaneLoader label="Loading CloudNativePG…" className="flex-1" />
83
+ if (!data) {
84
+ return (
85
+ <EmptyState
86
+ icon={Database}
87
+ title="CloudNativePG workspace unavailable"
88
+ detail={query.error instanceof Error ? query.error.message : 'The workspace could not be loaded.'}
89
+ />
90
+ )
91
+ }
92
+ if (!data.installed || !fleet) {
93
+ return (
94
+ <EmptyState
95
+ icon={Database}
96
+ title="CloudNativePG is not installed"
97
+ detail={`No postgresql.cnpg.io resources are served in ${connection.context || 'this cluster'}.`}
98
+ />
99
+ )
100
+ }
101
+ return <>{children(data, fleet)}</>
102
+ }
103
+
104
+ export function ScreenBody({ children }: { children: ReactNode }) {
105
+ return (
106
+ <div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
107
+ <div className="space-y-5 px-5 pb-6 pt-3 xl:px-7">{children}</div>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) {
113
+ if (chips.length === 0) return null
114
+ return (
115
+ <div className="flex flex-wrap gap-1.5">
116
+ {chips.map((c) => (
117
+ <span key={c.label} className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs text-theme-text-secondary">
118
+ {c.label}
119
+ <button type="button" onClick={c.onClear} aria-label={`Remove ${c.label}`} className="rounded-full p-0.5 hover:bg-theme-hover">
120
+ <X className="h-3 w-3" />
121
+ </button>
122
+ </span>
123
+ ))}
124
+ </div>
125
+ )
126
+ }
127
+
128
+ export function namespaceChip(namespaces: string[], onClear: () => void) {
129
+ return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : []
130
+ }
131
+
132
+ export function Segments<T extends string>({
133
+ value,
134
+ options,
135
+ onChange,
136
+ label,
137
+ }: {
138
+ value: T
139
+ options: { id: T; label: string; count?: number }[]
140
+ onChange: (id: T) => void
141
+ label: string
142
+ }) {
143
+ return (
144
+ <div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-theme-elevated p-0.5">
145
+ {options.map((o) => {
146
+ const on = o.id === value
147
+ return (
148
+ <button
149
+ key={o.id}
150
+ type="button"
151
+ role="tab"
152
+ aria-selected={on}
153
+ onClick={() => onChange(o.id)}
154
+ className={clsx(
155
+ 'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
156
+ on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
157
+ )}
158
+ >
159
+ {o.label}
160
+ {o.count !== undefined && <span className="font-mono text-xs text-theme-text-tertiary">{o.count}</span>}
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+ )
166
+ }
167
+
168
+ export interface TableColumn<T> {
169
+ header: ReactNode
170
+ width?: string
171
+ cell: (row: T) => ReactNode
172
+ className?: string
173
+ }
174
+
175
+ /** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */
176
+ export function SectionTable<T>({
177
+ title,
178
+ subtitle,
179
+ columns,
180
+ rows,
181
+ rowKey,
182
+ rowResource,
183
+ onInspect,
184
+ inspected,
185
+ empty,
186
+ minWidth = 760,
187
+ footer,
188
+ }: {
189
+ title: ReactNode
190
+ subtitle?: ReactNode
191
+ columns: TableColumn<T>[]
192
+ rows: T[]
193
+ rowKey: (row: T) => string
194
+ rowResource?: (row: T) => SelectedResource | null
195
+ onInspect?: (resource: SelectedResource) => void
196
+ inspected?: SelectedResource | null
197
+ empty: ReactNode
198
+ minWidth?: number
199
+ footer?: ReactNode
200
+ }) {
201
+ return (
202
+ <section>
203
+ <div className="mb-2 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
204
+ <h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
205
+ {subtitle && <span className="text-xs text-theme-text-tertiary">{subtitle}</span>}
206
+ </div>
207
+ <div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
208
+ {rows.length === 0 ? (
209
+ <div className="px-4 py-5 text-sm text-theme-text-tertiary">{empty}</div>
210
+ ) : (
211
+ <div className={TABLE_WRAP}>
212
+ <table className="w-full table-fixed" style={{ minWidth }}>
213
+ <colgroup>
214
+ {columns.map((c, i) => (
215
+ <col key={i} style={c.width ? { width: c.width } : undefined} />
216
+ ))}
217
+ </colgroup>
218
+ <thead className={TABLE_HEAD}>
219
+ <tr>
220
+ {columns.map((c, i) => (
221
+ <th key={i} className={TH}>{c.header}</th>
222
+ ))}
223
+ </tr>
224
+ </thead>
225
+ <tbody className={TBODY}>
226
+ {rows.map((row) => {
227
+ const res = rowResource?.(row) ?? null
228
+ const active = !!res && sameResource(inspected, res)
229
+ return (
230
+ <tr
231
+ key={rowKey(row)}
232
+ onClick={res && onInspect ? () => onInspect(res) : undefined}
233
+ className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')}
234
+ aria-selected={res ? active : undefined}
235
+ >
236
+ {columns.map((c, i) => (
237
+ <td key={i} className={clsx(TD, c.className)}>{c.cell(row)}</td>
238
+ ))}
239
+ </tr>
240
+ )
241
+ })}
242
+ </tbody>
243
+ </table>
244
+ </div>
245
+ )}
246
+ </div>
247
+ {footer && <div className="mt-1.5 text-xs text-theme-text-tertiary">{footer}</div>}
248
+ </section>
249
+ )
250
+ }
251
+
252
+ /** Empty-state text for a collection, derived from how much of it was readable. */
253
+ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string): string {
254
+ switch (cov?.state) {
255
+ case 'full':
256
+ return `No ${noun} in this scope.`
257
+ case 'partial':
258
+ return `No ${noun} visible. Some namespaces are not readable with your access.`
259
+ case 'denied':
260
+ return `No access to ${noun}.`
261
+ case 'syncing':
262
+ return `${noun[0].toUpperCase()}${noun.slice(1)} are still loading.`
263
+ case 'error':
264
+ return `${noun[0].toUpperCase()}${noun.slice(1)} could not be read.`
265
+ default:
266
+ return `This kind is not installed.`
267
+ }
268
+ }
269
+
270
+ /** The less complete of two coverages, for collections built from several kinds. */
271
+ export function worstCoverage(...covs: (CNPGKindCoverage | undefined)[]): CNPGKindCoverage | undefined {
272
+ const rank: Record<string, number> = { error: 0, denied: 1, syncing: 2, partial: 3, full: 4, notInstalled: 5 }
273
+ return covs.filter(Boolean).sort((a, b) => (rank[a!.state] ?? 9) - (rank[b!.state] ?? 9))[0]
274
+ }
275
+
276
+ export function Mono({ children }: { children: ReactNode }) {
277
+ return <span className="font-mono text-[12.5px] break-all">{children}</span>
278
+ }
279
+
280
+ export function Sub({ children }: { children: ReactNode }) {
281
+ return <div className="mt-0.5 text-xs text-theme-text-tertiary break-words">{children}</div>
282
+ }
283
+
284
+ export function clusterResource(namespace: string, name: string): SelectedResource {
285
+ return { kind: 'clusters', group: 'postgresql.cnpg.io', namespace, name }
286
+ }
287
+
288
+ export function cnpgResource(plural: string, namespace: string, name: string, group = 'postgresql.cnpg.io'): SelectedResource {
289
+ return { kind: plural, group, namespace, name }
290
+ }
@@ -0,0 +1,88 @@
1
+ import { useMemo } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { Database, FileCheck2, Settings2, ShieldCheck, Waypoints } from 'lucide-react'
4
+ import { buildCNPGFleet, type CNPGFleet, type SidebarCategoryWorkspace } from '@skyhook-io/k8s-ui'
5
+ import type { APIResource } from '../../types'
6
+ import { useCNPGWorkspace } from '../../api/cnpg'
7
+ import { CNPG_SCREENS, type CNPGScreen } from './routes'
8
+
9
+ export const CNPG_SIDEBAR_CATEGORY = 'CloudNativePG'
10
+
11
+ const ICONS: Record<CNPGScreen, SidebarCategoryWorkspace['destinations'][number]['icon']> = {
12
+ overview: Database,
13
+ protection: ShieldCheck,
14
+ declarations: FileCheck2,
15
+ pooling: Waypoints,
16
+ operator: Settings2,
17
+ }
18
+
19
+ export function cnpgDiscovered(apiResources: APIResource[] | undefined): boolean {
20
+ return !!apiResources?.some((r) => r.group === 'postgresql.cnpg.io')
21
+ }
22
+
23
+ export function useCNPGFleet(namespaces: string[], enabled = true) {
24
+ const query = useCNPGWorkspace(namespaces, { enabled })
25
+ const fleet = useMemo<CNPGFleet | null>(() => (query.data?.installed ? buildCNPGFleet(query.data) : null), [query.data])
26
+ return { query, fleet }
27
+ }
28
+
29
+ function destinationCount(screen: CNPGScreen, fleet: CNPGFleet | null): { count?: number | null; title?: string } {
30
+ if (!fleet) return {}
31
+ const lowerBound = fleet.incompleteKinds.length > 0 ? ' Some CloudNativePG data is not readable, so this is a lower bound.' : ''
32
+ switch (screen) {
33
+ case 'overview':
34
+ return { count: fleet.attentionCount, title: `${fleet.attentionCount} clusters need attention.${lowerBound}` }
35
+ case 'protection':
36
+ return { count: fleet.categoryCounts.protection, title: `${fleet.categoryCounts.protection} clusters with failing backups or WAL archiving.${lowerBound}` }
37
+ case 'declarations':
38
+ return { count: fleet.categoryCounts.declarations, title: `${fleet.categoryCounts.declarations} clusters with declarations that are not reconciled.${lowerBound}` }
39
+ case 'pooling':
40
+ return { count: fleet.categoryCounts.pooling, title: `${fleet.categoryCounts.pooling} clusters with Pooler problems.${lowerBound}` }
41
+ default:
42
+ return {}
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The CloudNativePG workspace entries for the Resources sidebar. Returns
48
+ * undefined when CNPG is not discovered, so clusters without the operator see
49
+ * an unchanged sidebar.
50
+ */
51
+ export function useCNPGSidebarWorkspace({
52
+ apiResources,
53
+ namespaces,
54
+ active,
55
+ }: {
56
+ apiResources: APIResource[] | undefined
57
+ namespaces: string[]
58
+ active?: { screen: CNPGScreen; child?: { label: string; title?: string } }
59
+ }): Record<string, SidebarCategoryWorkspace> | undefined {
60
+ const navigate = useNavigate()
61
+ const discovered = cnpgDiscovered(apiResources)
62
+ const { fleet } = useCNPGFleet(namespaces, discovered)
63
+ const nsKey = namespaces.join(',')
64
+
65
+ return useMemo(() => {
66
+ if (!discovered) return undefined
67
+ const destinations = CNPG_SCREENS.map((s) => {
68
+ const { count, title } = destinationCount(s.id, fleet)
69
+ return {
70
+ id: s.id,
71
+ label: s.label,
72
+ icon: ICONS[s.id],
73
+ count,
74
+ countTitle: title,
75
+ active: active?.screen === s.id,
76
+ child: active?.screen === s.id ? active.child : undefined,
77
+ onSelect: () => navigate(s.path),
78
+ }
79
+ })
80
+ return {
81
+ [CNPG_SIDEBAR_CATEGORY]: {
82
+ destinations,
83
+ defaultKindsCollapsed: !!active,
84
+ scopeNote: nsKey ? `Counts for namespace ${nsKey.split(',').join(', ')}` : undefined,
85
+ },
86
+ }
87
+ }, [discovered, fleet, active?.screen, active?.child?.label, active?.child?.title, navigate, nsKey]) // eslint-disable-line react-hooks/exhaustive-deps
88
+ }
@@ -13,7 +13,7 @@ const OPENCODE_FULL_LOCAL_WARNING =
13
13
  "Radar runs OpenCode with --auto, which automatically approves actions that your configuration would normally ask about, including built-in tools and configured MCP servers. Explicit denials still apply. Radar does not enforce a CLI sandbox.";
14
14
 
15
15
  const CURSOR_FULL_LOCAL_WARNING =
16
- "Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary workspace.";
16
+ "Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary working directory.";
17
17
 
18
18
  // Segmented two-or-more-way selector — shared shape for the agent and execution
19
19
  // profile pickers.
@@ -11,7 +11,12 @@ import {
11
11
  Search,
12
12
  ListChecks,
13
13
  } from "lucide-react";
14
- import type { Diagnosis, ApplyMutationOutcome } from "../../api/diagnose";
14
+ import type {
15
+ Diagnosis,
16
+ ApplyMutationOutcome,
17
+ InvestigationRefusal,
18
+ } from "../../api/diagnose";
19
+ import { InvestigationRefusalAction } from "./InvestigationRefusalAction";
15
20
  import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
16
21
  import { Tooltip } from "../ui/Tooltip";
17
22
  import type {
@@ -669,6 +674,8 @@ export type AssessmentExplanation = {
669
674
  status: "idle" | "running" | "done" | "error";
670
675
  text?: string;
671
676
  error?: string;
677
+ /** The host's refusal behind error, for its action beside the message. */
678
+ refusal?: InvestigationRefusal | null;
672
679
  onGenerate?: () => void;
673
680
  openRequest?: number;
674
681
  };
@@ -847,6 +854,9 @@ function AssessmentDetails({
847
854
  {explanation.error ||
848
855
  "The agent did not return an explanation."}
849
856
  </span>
857
+ <InvestigationRefusalAction
858
+ refusal={explanation.refusal ?? null}
859
+ />
850
860
  {explanation.onGenerate && (
851
861
  <button
852
862
  type="button"