@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.
- package/package.json +5 -5
- package/src/App.tsx +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/helm/ChartBrowser.tsx +6 -7
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- 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
|
|
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 {
|
|
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"
|