@skyhook-io/radar-app 1.16.2 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. 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
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { AgentSetupNotice } from "./AgentSetupNotice";
4
+ import type { DiagnoseSetup } from "./DiagnoseContext";
5
+
6
+ const render = (
7
+ setupState: DiagnoseSetup,
8
+ { checkingAgents = false, cliOverride = false } = {},
9
+ ) =>
10
+ renderToStaticMarkup(
11
+ <AgentSetupNotice
12
+ setupState={setupState}
13
+ cliOverride={cliOverride}
14
+ checkingAgents={checkingAgents}
15
+ agentsCheckFailed={false}
16
+ recheckAgents={async () => {}}
17
+ />,
18
+ );
19
+
20
+ describe("AgentSetupNotice", () => {
21
+ it("offers installs and a re-check, never a restart, when no CLI is found", () => {
22
+ const html = render("needs-install");
23
+ expect(html).toContain("Claude Code");
24
+ expect(html).toContain("OpenCode");
25
+ expect(html).toContain("Check again");
26
+ expect(html).not.toMatch(/restart/i);
27
+ // A desktop user can't set an environment variable for a Dock launch.
28
+ expect(html).not.toContain("RADAR_AI_CLI_BIN");
29
+ });
30
+
31
+ it("shows the check in progress", () => {
32
+ const html = render("needs-install", { checkingAgents: true });
33
+ expect(html).toContain("Checking…");
34
+ expect(html).toContain("disabled");
35
+ });
36
+
37
+ it("names the override only when the server says it is set", () => {
38
+ for (const state of ["needs-restart", "needs-install"] as const) {
39
+ const html = render(state, { cliOverride: true });
40
+ expect(html).toContain("RADAR_AI_CLI_BIN");
41
+ expect(html).toContain("restart Radar");
42
+ // Detection is off while the variable is set, so an install can't help.
43
+ expect(html).not.toContain("Install one of these");
44
+ // A working file put back at the pinned path is picked up without one.
45
+ expect(html).toContain("Check again");
46
+ }
47
+ });
48
+
49
+ it("says nothing it can't know when an agent is reported but off", () => {
50
+ // Radar Hub reports a hosted agent that is supported but not enabled when
51
+ // its own runner is down: no CLI, no startup output, no override.
52
+ const html = render("needs-restart");
53
+ expect(html).toContain("aren&#x27;t available right now");
54
+ expect(html).not.toMatch(/RADAR_AI_CLI_BIN|agent CLI|startup output/);
55
+ expect(html).not.toContain("Install one of these");
56
+ expect(html).toContain("Check again");
57
+ });
58
+ });