@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
package/src/api/httpErrors.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|