@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
@@ -99,3 +99,15 @@ export function nonJsonErrorMessage(response: Response, text: string): string {
99
99
  export async function readErrorBody(response: Response): Promise<ErrorBody> {
100
100
  return (await readErrorResponse(response)).body
101
101
  }
102
+
103
+ /**
104
+ * Marks a mutation error whose surface already shows it, so the global
105
+ * mutation handler doesn't toast it a second time.
106
+ */
107
+ export function markShownInline(error: Error): void {
108
+ ;(error as Error & { shownInline?: boolean }).shownInline = true
109
+ }
110
+
111
+ export function isShownInline(error: unknown): boolean {
112
+ return error instanceof Error && (error as Error & { shownInline?: boolean }).shownInline === true
113
+ }
@@ -423,7 +423,7 @@ export async function fetchRetainedDelta(
423
423
  // Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
424
424
  // a resource managed by another — owned, or one of the churn kinds. Keep the
425
425
  // two predicates in lockstep.
426
- function isManagedTimelineEvent(e: TimelineEvent): boolean {
426
+ export function isManagedTimelineEvent(e: TimelineEvent): boolean {
427
427
  return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
428
428
  }
429
429
 
@@ -12,6 +12,8 @@ const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
12
12
 
13
13
  vi.mock('@skyhook-io/k8s-ui', () => ({
14
14
  ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
15
+ }))
16
+ vi.mock('./dock/DockContext', () => ({
15
17
  useOpenLocalTerminal: () => vi.fn(),
16
18
  }))
17
19
  vi.mock('../api/client', () => ({
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'
3
3
  import type { ConnectionState } from '../context/ConnectionContext'
4
4
  import { ContextSwitcher } from './ContextSwitcher'
5
5
  import { parseContextName } from '../utils/context-name'
6
- import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
6
+ import { ClusterName } from '@skyhook-io/k8s-ui'
7
+ import { useOpenLocalTerminal } from './dock/DockContext'
7
8
  import { useAuthMe, useContexts } from '../api/client'
8
9
  import { Tooltip } from './ui/Tooltip'
9
10
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ShieldAlert } from 'lucide-react'
3
+ import { AlertBanner, Badge, Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui'
4
+ import { useAuthMe, useNamespaceAccess } from '../api/client'
5
+
6
+ // Every read is filtered to the user's namespaces, so someone bound to none
7
+ // sees empty lists in every view and reads it as an empty cluster. Stated in
8
+ // terms of the user's own access, not how the cluster is meant to be set up:
9
+ // Radar can't know whether an org intends hub roles or IdP groups here.
10
+ export function NoClusterAccessBanner() {
11
+ const { data: auth } = useAuthMe()
12
+ // Mounted once content is ready (and again after a context switch), so the
13
+ // cluster is connected by the time this asks.
14
+ const { data } = useNamespaceAccess(Boolean(auth?.authEnabled))
15
+ // Keep the last definite answer through a check that couldn't decide, so a
16
+ // transient discovery failure doesn't hide the banner from a user without access.
17
+ const [me, setMe] = useState<typeof data>()
18
+ useEffect(() => {
19
+ if (data && typeof data.noNamespaceAccess === 'boolean') setMe(data)
20
+ }, [data])
21
+ const [groupsOpen, setGroupsOpen] = useState(false)
22
+ const groupsDisclosure = useDisclosure(groupsOpen)
23
+
24
+ // The remembered answer only counts for the person signed in now.
25
+ if (!me?.noNamespaceAccess || me.username !== auth?.username) return null
26
+
27
+ const groups = me.groups ?? []
28
+ return (
29
+ <div className="px-4 pt-3">
30
+ <AlertBanner
31
+ variant="warning"
32
+ icon={ShieldAlert}
33
+ title="You can't read any namespace in this cluster"
34
+ message={
35
+ <>
36
+ Radar is connected, but your Kubernetes permissions don't allow reading any namespace, so
37
+ every view looks empty. Signed in as <span className="font-mono">{me.username}</span>.
38
+ </>
39
+ }
40
+ >
41
+ {groups.length > 0 && (
42
+ // ph-no-capture: group IDs name the customer's directory structure.
43
+ // Radar Cloud embeds this view with session replay on, and its text
44
+ // mask only catches credential-shaped values. A no-op without PostHog.
45
+ <div className="ph-no-capture mt-2">
46
+ <button
47
+ {...groupsDisclosure.buttonProps}
48
+ onClick={() => setGroupsOpen((v) => !v)}
49
+ className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors"
50
+ >
51
+ <CollapseChevron open={groupsOpen} className="w-3.5 h-3.5" />
52
+ Your groups ({groups.length})
53
+ </button>
54
+ <Collapse open={groupsOpen} id={groupsDisclosure.panelId}>
55
+ <div className="mt-2 flex max-h-32 max-w-4xl flex-wrap gap-1.5 overflow-y-auto">
56
+ {groups.map((g) => (
57
+ <Badge key={g} tone="structural" size="sm" className="font-mono">
58
+ {g}
59
+ </Badge>
60
+ ))}
61
+ </div>
62
+ </Collapse>
63
+ </div>
64
+ )}
65
+ </AlertBanner>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react'
2
+ import { PaneLoader, TimelineList, formatAge, type NavigateToResource } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGClusterActivity } from '../../api/cnpg'
4
+ import { Notice } from '../capacity/shared'
5
+ import { Segments } from './shared'
6
+
7
+ const RANGES = [
8
+ { id: '6', label: '6 h' },
9
+ { id: '24', label: '24 h' },
10
+ { id: '168', label: '7 d' },
11
+ ] as const
12
+
13
+ /**
14
+ * Kubernetes events and changes for the Cluster, its instance Pods and the
15
+ * CNPG objects attributed to it — including Backups and declarations deleted
16
+ * since. Attribution of child objects relies on a label Radar records at
17
+ * ingestion, so history older than that is marked incomplete.
18
+ */
19
+ export function CNPGClusterActivity({ namespace, name, onNavigate }: { namespace: string; name: string; onNavigate?: NavigateToResource }) {
20
+ const [hours, setHours] = useState<string>('24')
21
+ const q = useCNPGClusterActivity(namespace, name, Number(hours))
22
+
23
+ return (
24
+ <div className="flex h-full min-h-0 flex-col gap-3 p-4">
25
+ <div className="flex flex-wrap items-center gap-3">
26
+ <Segments label="Range" value={hours} onChange={setHours} options={RANGES.map((r) => ({ id: r.id, label: r.label }))} />
27
+ <span className="text-xs text-theme-text-tertiary">
28
+ Kubernetes events and changes for the Cluster, its instances, Backups, Poolers and declarations.
29
+ </span>
30
+ </div>
31
+ <div className="text-xs text-theme-text-tertiary">
32
+ {q.data?.attributionSince
33
+ ? `The earliest recorded event linking a Backup, Pooler or declaration to this cluster is ${formatAge(q.data.attributionSince)} old. Deleted child objects from before Radar recorded that link are not shown.`
34
+ : 'Radar has not recorded any Backup, Pooler or declaration events linked to this cluster yet, so deleted child objects may be missing.'}
35
+ {' '}Events for kinds you cannot list are omitted.
36
+ </div>
37
+ {q.data?.truncated && <Notice>Showing the most recent events only; narrow the range to see all of them.</Notice>}
38
+ {q.error && !q.data ? (
39
+ <Notice>Activity could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}</Notice>
40
+ ) : !q.data ? (
41
+ <PaneLoader label="Loading activity…" className="h-40" />
42
+ ) : (
43
+ <div className="min-h-0 flex-1">
44
+ <TimelineList events={q.data.events} isLoading={q.isFetching && !q.data} onResourceClick={onNavigate} compact hideRangeSelector />
45
+ </div>
46
+ )}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,62 @@
1
+ import { useCallback } from 'react'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { WorkloadLogsViewer, type WorkloadLogsFetchParams, type WorkloadLogsResult } from '@skyhook-io/k8s-ui'
4
+ import { fetchJSON } from '../../api/client'
5
+ import { getApiBase, getCredentialsMode } from '../../api/config'
6
+ import { useDesktopDownload } from '../../hooks/useDesktopDownload'
7
+ import { useTheme } from '../../context/ThemeContext'
8
+
9
+ function logsPath(namespace: string, name: string) {
10
+ return `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/logs`
11
+ }
12
+
13
+ function query(params: WorkloadLogsFetchParams, tailDefault?: number) {
14
+ const q = new URLSearchParams()
15
+ if (params.container) q.set('container', params.container)
16
+ const tail = params.tailLines ?? tailDefault
17
+ if (tail) q.set('tailLines', String(tail))
18
+ if (params.sinceSeconds) q.set('sinceSeconds', String(params.sinceSeconds))
19
+ const s = q.toString()
20
+ return s ? `?${s}` : ''
21
+ }
22
+
23
+ /**
24
+ * Logs merged from every instance Pod of a CloudNativePG Cluster. `?pod=`
25
+ * preselects one instance (the fleet's "Logs" action and "Open instance logs").
26
+ */
27
+ export function CNPGClusterLogs({ namespace, name }: { namespace: string; name: string }) {
28
+ const [searchParams] = useSearchParams()
29
+ const pod = searchParams.get('pod')
30
+ const desktopDownload = useDesktopDownload()
31
+ const { theme } = useTheme()
32
+
33
+ const fetchAll = useCallback(
34
+ (params: WorkloadLogsFetchParams) =>
35
+ fetchJSON<WorkloadLogsResult>(`${logsPath(namespace, name)}${query(params)}`, { signal: params.signal }),
36
+ [namespace, name],
37
+ )
38
+ const createStream = useCallback(
39
+ (params: WorkloadLogsFetchParams) =>
40
+ new EventSource(`${getApiBase()}${logsPath(namespace, name)}/stream${query(params, 50)}`, {
41
+ withCredentials: getCredentialsMode() === 'include',
42
+ }),
43
+ [namespace, name],
44
+ )
45
+
46
+ return (
47
+ <div className="h-full">
48
+ <WorkloadLogsViewer
49
+ key={pod ?? ''}
50
+ name={name}
51
+ fetchAll={fetchAll}
52
+ createStream={createStream}
53
+ overrideDownload={desktopDownload}
54
+ forceDark={theme === 'dark' ? true : undefined}
55
+ // Light logs on a dark app are never wanted: dark theme pins the palette,
56
+ // light theme only sets where it starts and leaves the toggle available.
57
+ defaultDark={false}
58
+ initialPods={pod ? [pod] : undefined}
59
+ />
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,311 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { AlertTriangle } from 'lucide-react'
4
+ import { Badge, cnpgGitOpsSource, isApiGroup, toneTextClass, type CNPGFleetRow } from '@skyhook-io/k8s-ui'
5
+ import type { SelectedResource } from '../../types'
6
+ import {
7
+ CNPGWorkspaceHeader,
8
+ CoverageNotice,
9
+ FilterChips,
10
+ ScreenBody,
11
+ Segments,
12
+ Sub,
13
+ clusterResource,
14
+ cnpgResource,
15
+ coverageEmpty,
16
+ worstCoverage,
17
+ namespaceChip,
18
+ type CNPGScreenProps,
19
+ } from './shared'
20
+ import { sameResource } from './routes'
21
+
22
+ type State = 'applied' | 'failed' | 'pending'
23
+
24
+ interface DeclItem {
25
+ key: string
26
+ kind: 'Database' | 'Publication' | 'Subscription' | 'Managed role'
27
+ pgName: string
28
+ indent: boolean
29
+ state: State
30
+ meta?: string
31
+ error?: string
32
+ source?: string
33
+ resource: SelectedResource
34
+ isField: boolean
35
+ }
36
+
37
+ interface DeclGroup {
38
+ namespace: string
39
+ cluster: string
40
+ row: CNPGFleetRow | null
41
+ items: DeclItem[]
42
+ }
43
+
44
+ function stateOf(obj: any): State {
45
+ const applied = obj?.status?.applied
46
+ if (applied === true) return 'applied'
47
+ if (applied === false) return 'failed'
48
+ return 'pending'
49
+ }
50
+
51
+ function gitopsSource(obj: any): string | undefined {
52
+ const src = cnpgGitOpsSource(obj)
53
+ if (!src) return undefined
54
+ return `${src.tool === 'argocd' ? 'Argo CD' : 'Flux'} ${src.name}`
55
+ }
56
+
57
+ const STATE_BADGE: Record<State, { severity: 'success' | 'warning' | 'neutral'; text: string }> = {
58
+ applied: { severity: 'success', text: 'Applied' },
59
+ failed: { severity: 'warning', text: 'Not applied' },
60
+ pending: { severity: 'neutral', text: 'Pending' },
61
+ }
62
+
63
+ function roleState(cluster: any, role: string): { state: State; error?: string } {
64
+ const status = cluster?.status?.managedRolesStatus
65
+ const errs = status?.cannotReconcile?.[role]
66
+ if (Array.isArray(errs) && errs.length > 0) return { state: 'failed', error: errs.join('; ') }
67
+ const by = status?.byStatus ?? {}
68
+ if ((by.reconciled ?? []).includes(role)) return { state: 'applied' }
69
+ return { state: 'pending' }
70
+ }
71
+
72
+ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetParams, onInspect, inspected, onClearNamespaces }: CNPGScreenProps) {
73
+ const clusterFilter = searchParams.get('cluster')
74
+ const show = (searchParams.get('show') as 'failed' | 'pending' | null) ?? null
75
+
76
+ const groups = useMemo<DeclGroup[]>(() => {
77
+ const byCluster = new Map<string, DeclGroup>()
78
+ const group = (ns: string, cluster: string) => {
79
+ const k = `${ns}/${cluster}`
80
+ let g = byCluster.get(k)
81
+ if (!g) {
82
+ g = { namespace: ns, cluster, row: fleet.rows.find((r) => r.namespace === ns && r.name === cluster) ?? null, items: [] }
83
+ byCluster.set(k, g)
84
+ }
85
+ return g
86
+ }
87
+ const valid = (o: any) => isApiGroup(o?.apiVersion, 'postgresql.cnpg.io')
88
+ const dbs = (data.objects.databases ?? []).filter(valid)
89
+ const pubs = (data.objects.publications ?? []).filter(valid)
90
+ const subs = (data.objects.subscriptions ?? []).filter(valid)
91
+ const placed = new Set<any>()
92
+ for (const d of dbs) {
93
+ const ns = d.metadata?.namespace ?? ''
94
+ const cluster = d.spec?.cluster?.name ?? '(no cluster)'
95
+ const g = group(ns, cluster)
96
+ const st = stateOf(d)
97
+ g.items.push({
98
+ key: `db/${ns}/${d.metadata?.name}`,
99
+ kind: 'Database',
100
+ pgName: d.spec?.name ?? d.metadata?.name,
101
+ indent: false,
102
+ state: st,
103
+ meta: d.spec?.owner ? `owner ${d.spec.owner}` : undefined,
104
+ error: st === 'failed' ? d.status?.message : undefined,
105
+ source: gitopsSource(d),
106
+ resource: cnpgResource('databases', ns, d.metadata?.name),
107
+ isField: false,
108
+ })
109
+ for (const [list, kind] of [[pubs, 'Publication'], [subs, 'Subscription']] as const) {
110
+ for (const p of list) {
111
+ if (p.metadata?.namespace !== ns || p.spec?.cluster?.name !== d.spec?.cluster?.name || p.spec?.dbname !== d.spec?.name) continue
112
+ placed.add(p)
113
+ const pst = stateOf(p)
114
+ g.items.push({
115
+ key: `${kind}/${ns}/${p.metadata?.name}`,
116
+ kind,
117
+ pgName: p.spec?.name ?? p.metadata?.name,
118
+ indent: true,
119
+ state: pst,
120
+ meta:
121
+ kind === 'Publication'
122
+ ? p.spec?.target?.allTables ? 'all tables' : 'selected objects'
123
+ : `from ${p.spec?.publicationName ?? '?'} on ${p.spec?.externalClusterName ?? '?'}`,
124
+ error: pst === 'failed' ? p.status?.message : undefined,
125
+ source: gitopsSource(p),
126
+ resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name),
127
+ isField: false,
128
+ })
129
+ }
130
+ }
131
+ }
132
+ for (const [list, kind] of [[pubs, 'Publication'], [subs, 'Subscription']] as const) {
133
+ for (const p of list) {
134
+ if (placed.has(p)) continue
135
+ const ns = p.metadata?.namespace ?? ''
136
+ const g = group(ns, p.spec?.cluster?.name ?? '(no cluster)')
137
+ const pst = stateOf(p)
138
+ g.items.push({
139
+ key: `${kind}/${ns}/${p.metadata?.name}`,
140
+ kind,
141
+ pgName: p.spec?.name ?? p.metadata?.name,
142
+ indent: false,
143
+ state: pst,
144
+ meta: p.spec?.dbname ? `database ${p.spec.dbname}` : undefined,
145
+ error: pst === 'failed' ? p.status?.message : undefined,
146
+ source: gitopsSource(p),
147
+ resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name),
148
+ isField: false,
149
+ })
150
+ }
151
+ }
152
+ for (const row of fleet.rows) {
153
+ const roles: any[] = Array.isArray(row.cluster?.spec?.managed?.roles) ? row.cluster.spec.managed.roles : []
154
+ if (roles.length === 0) continue
155
+ const g = group(row.namespace, row.name)
156
+ for (const r of roles) {
157
+ if (!r?.name) continue
158
+ const rs = roleState(row.cluster, r.name)
159
+ g.items.push({
160
+ key: `role/${row.namespace}/${row.name}/${r.name}`,
161
+ kind: 'Managed role',
162
+ pgName: r.name,
163
+ indent: false,
164
+ state: rs.state,
165
+ meta: r.ensure === 'absent' ? 'ensure absent' : 'spec.managed.roles',
166
+ error: rs.error,
167
+ source: `Cluster ${row.name} spec`,
168
+ resource: clusterResource(row.namespace, row.name),
169
+ isField: true,
170
+ })
171
+ }
172
+ }
173
+ return [...byCluster.values()]
174
+ .filter((g) => !clusterFilter || `${g.namespace}/${g.cluster}` === clusterFilter)
175
+ .map((g) => ({ ...g, items: show ? g.items.filter((i) => i.state === show) : g.items }))
176
+ .filter((g) => g.items.length > 0)
177
+ .sort((a, b) => {
178
+ const fa = a.items.some((i) => i.state === 'failed') ? 0 : 1
179
+ const fb = b.items.some((i) => i.state === 'failed') ? 0 : 1
180
+ return fa - fb || a.namespace.localeCompare(b.namespace) || a.cluster.localeCompare(b.cluster)
181
+ })
182
+ }, [data.objects.databases, data.objects.publications, data.objects.subscriptions, fleet.rows, clusterFilter, show])
183
+
184
+ const totals = useMemo(() => {
185
+ let failed = 0
186
+ let pending = 0
187
+ for (const r of fleet.rows) {
188
+ failed += r.declarations.failed
189
+ pending += r.declarations.pending
190
+ }
191
+ return { failed, pending }
192
+ }, [fleet.rows])
193
+ const declCoverage = worstCoverage(data.coverage.databases, data.coverage.publications, data.coverage.subscriptions)
194
+
195
+ const chips = [
196
+ ...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
197
+ ...namespaceChip(namespaces, onClearNamespaces),
198
+ ]
199
+
200
+ return (
201
+ <div className="flex min-h-0 flex-1 flex-col">
202
+ <CNPGWorkspaceHeader
203
+ title="Declarations"
204
+ subtitle="Databases, managed roles, publications and subscriptions, by PostgreSQL cluster. Declared is not the same as reconciled."
205
+ />
206
+ <ScreenBody>
207
+ <CoverageNotice fleet={fleet} data={data} />
208
+ <div className="flex flex-wrap items-center gap-2">
209
+ <Segments
210
+ label="Declarations"
211
+ value={show ?? 'all'}
212
+ onChange={(id) => onSetParams({ show: id === 'all' ? null : id })}
213
+ options={[
214
+ { id: 'all', label: 'All declarations' },
215
+ { id: 'failed', label: 'Not applied', count: totals.failed },
216
+ { id: 'pending', label: 'Pending', count: totals.pending },
217
+ ]}
218
+ />
219
+ </div>
220
+ <FilterChips chips={chips} />
221
+
222
+ {groups.length === 0 ? (
223
+ <div className="rounded-xl border border-theme-border bg-theme-surface px-4 py-5 text-sm text-theme-text-tertiary">
224
+ {show === 'failed'
225
+ ? 'No declaration in this scope is reported as not applied.'
226
+ : show === 'pending'
227
+ ? 'No declaration in this scope is waiting for the operator.'
228
+ : coverageEmpty(declCoverage, 'declarations')}
229
+ {show && declCoverage?.state !== 'full' ? ' Some declarations are not readable with your access.' : ''}
230
+ </div>
231
+ ) : (
232
+ groups.map((g) => {
233
+ const failed = g.items.filter((i) => i.state === 'failed').length
234
+ return (
235
+ <section key={`${g.namespace}/${g.cluster}`} className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
236
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-theme-border px-4 py-2.5">
237
+ {g.row ? (
238
+ <button type="button" onClick={() => onInspect(clusterResource(g.namespace, g.cluster))} className="text-sm font-semibold text-accent-text hover:underline">
239
+ {g.cluster}
240
+ </button>
241
+ ) : (
242
+ <span className="text-sm font-semibold text-theme-text-primary">
243
+ {g.cluster}
244
+ </span>
245
+ )}
246
+ <span className="text-xs text-theme-text-tertiary">{g.namespace}</span>
247
+ <span className={clsx('text-xs', failed > 0 ? toneTextClass('degraded') : 'text-theme-text-tertiary')}>
248
+ {g.items.length} {g.items.length === 1 ? 'declaration' : 'declarations'}
249
+ {failed > 0 ? ` · ${failed} not reconciled` : ''}
250
+ {!g.row ? ' · target cluster not visible' : ''}
251
+ </span>
252
+ </div>
253
+ <div className="table-divide-subtle">
254
+ {g.items.map((i) => (
255
+ <DeclarationRow
256
+ key={i.key}
257
+ item={i}
258
+ active={!i.isField && sameResource(inspected, i.resource)}
259
+ onInspect={() => onInspect(i.resource)}
260
+ />
261
+ ))}
262
+ </div>
263
+ </section>
264
+ )
265
+ })
266
+ )}
267
+ </ScreenBody>
268
+ </div>
269
+ )
270
+ }
271
+
272
+ function DeclarationRow({ item, active, onInspect }: { item: DeclItem; active: boolean; onInspect: () => void }) {
273
+ const badge = STATE_BADGE[item.state]
274
+ let detail: ReactNode = null
275
+ if (item.error) {
276
+ detail = (
277
+ <div className={clsx('mt-1 flex items-start gap-1.5 text-xs', toneTextClass('degraded'))}>
278
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
279
+ <span className="break-words">Controller error: {item.error}</span>
280
+ </div>
281
+ )
282
+ }
283
+ return (
284
+ <div
285
+ role="button"
286
+ tabIndex={0}
287
+ onClick={onInspect}
288
+ onKeyDown={(e) => { if (e.key === 'Enter') onInspect() }}
289
+ className={clsx(
290
+ 'grid cursor-pointer grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-x-4 px-4 py-2.5 text-sm transition-colors hover:bg-theme-hover/50',
291
+ active && 'selection',
292
+ )}
293
+ >
294
+ <div className={clsx('min-w-0', item.indent && 'pl-6')}>
295
+ <div className="flex flex-wrap items-center gap-2">
296
+ <span className="text-xs text-theme-text-tertiary">{item.kind}</span>
297
+ <span className="font-mono font-medium text-theme-text-primary">{item.pgName}</span>
298
+ {item.meta && <span className="text-xs text-theme-text-tertiary">{item.meta}</span>}
299
+ </div>
300
+ {detail}
301
+ </div>
302
+ <div>
303
+ <Badge severity={badge.severity} size="sm">{badge.text}</Badge>
304
+ {item.isField && <Sub>field of the Cluster</Sub>}
305
+ </div>
306
+ <div className="min-w-0 text-xs text-theme-text-secondary break-words">
307
+ {item.source ?? <span className="text-theme-text-tertiary">GitOps source not recorded</span>}
308
+ </div>
309
+ </div>
310
+ )
311
+ }