From 0bc522a8c5400e9d2626130b982a26060c288ea5 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 4 Oct 2026 00:55:12 +0300 Subject: [PATCH 01/26] Move the workspace building blocks out of CloudNativePG Facts, sections and the problem list become components/workspace in k8s-ui (Fact, FactGrid/Row/Value/Source, SectionHeading, FoldSection, FoldSummary, WorkspaceProblem with a rootKind prop, OpenIssueContext), RefLink moves to ui/ on the existing ResourceRef type, and toneTextClass and worseTone join toneFillClass in ui/status-tone. CloudNativePG keeps its categories, ordering, wording and builders, and binds WorkspaceProblem to its own categories. Badges read health through healthToSeverity like the rest of the app, and secondary buttons use a new .btn-secondary class instead of hand-rolled class strings. --- DESIGN.md | 1 + .../src/components/cnpg/CNPGBackupSummary.tsx | 14 +- .../components/cnpg/CNPGClusterHASection.tsx | 11 +- .../cnpg/CNPGClusterSummary.test.tsx | 6 +- .../components/cnpg/CNPGClusterSummary.tsx | 40 +-- .../components/cnpg/CNPGConnectSection.tsx | 9 +- .../cnpg/CNPGDeclarativeSummary.tsx | 29 +- .../cnpg/CNPGImageCatalogSummary.tsx | 10 +- .../src/components/cnpg/CNPGLogicalPath.tsx | 12 +- .../cnpg/CNPGObjectStoreSummary.tsx | 14 +- .../src/components/cnpg/CNPGPoolerSummary.tsx | 16 +- .../src/components/cnpg/CNPGSharedSummary.tsx | 13 +- packages/k8s-ui/src/components/cnpg/ha.ts | 21 +- .../src/components/cnpg/logicalReplication.ts | 8 +- .../k8s-ui/src/components/cnpg/primitives.tsx | 258 +----------------- .../k8s-ui/src/components/cnpg/relations.ts | 38 +-- .../k8s-ui/src/components/cnpg/workspace.ts | 101 ++----- packages/k8s-ui/src/components/ui/RefLink.tsx | 20 ++ packages/k8s-ui/src/components/ui/index.ts | 4 +- .../src/components/ui/status-tone.test.ts | 11 +- .../k8s-ui/src/components/ui/status-tone.tsx | 25 ++ .../facts.test.tsx} | 2 +- .../k8s-ui/src/components/workspace/facts.tsx | 60 ++++ .../k8s-ui/src/components/workspace/index.ts | 5 + .../components/workspace/problems.test.tsx | 26 ++ .../src/components/workspace/problems.tsx | 175 ++++++++++++ .../src/components/workspace/sections.tsx | 63 +++++ packages/k8s-ui/src/index.ts | 5 +- packages/k8s-ui/src/theme/components.css | 15 + web/src/components/cnpg/CNPGDetailPage.tsx | 2 +- web/src/components/cnpg/CNPGOverview.tsx | 6 +- web/src/components/cnpg/CNPGStorage.tsx | 2 +- web/src/components/cnpg/CNPGSummaryHost.tsx | 24 +- .../cnpg/actions/CNPGClusterActions.tsx | 6 +- .../cnpg/actions/CNPGConnectButton.tsx | 6 +- .../cnpg/actions/CNPGPoolerActions.tsx | 2 +- .../cnpg/actions/CNPGScheduleActions.tsx | 2 +- web/src/components/cnpg/baseBackup.ts | 4 +- .../cnpg/operations/CNPGOperationTracker.tsx | 2 +- .../cnpg/recovery/CNPGRestoreButton.tsx | 2 +- web/src/components/cnpg/runtimeModel.ts | 6 +- 41 files changed, 576 insertions(+), 500 deletions(-) create mode 100644 packages/k8s-ui/src/components/ui/RefLink.tsx rename packages/k8s-ui/src/components/{cnpg/primitives.test.tsx => workspace/facts.test.tsx} (94%) create mode 100644 packages/k8s-ui/src/components/workspace/facts.tsx create mode 100644 packages/k8s-ui/src/components/workspace/index.ts create mode 100644 packages/k8s-ui/src/components/workspace/problems.test.tsx create mode 100644 packages/k8s-ui/src/components/workspace/problems.tsx create mode 100644 packages/k8s-ui/src/components/workspace/sections.tsx diff --git a/DESIGN.md b/DESIGN.md index 0241ad5e62..d33301fa08 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -100,6 +100,7 @@ Standard Tailwind type scale. No custom sizes or tracking. Use Tailwind utilitie | `.btn-brand` | Primary CTAs — brand-colored bg, white text, 10px radius | | `.btn-brand-muted` | Secondary brand actions — dimmed brand bg, white text | | `.btn-brand-toggle` | Toggle buttons — 50% brand bg, primary text | +| `.btn-secondary` | Secondary actions beside a `.btn-brand` — bordered surface bg, primary text, 10px radius | Hover/disabled states are built into the classes. For non-brand buttons, use shadcn/ui ` @@ -71,7 +60,7 @@ function DimensionChips({ dimensions, onSelect }: { dimensions: CNPGDimension[]; {dimensions.map((d) => { const body = ( <> - + {d.label} {d.text} @@ -127,7 +116,7 @@ export function CNPGClusterSummary({ stateFacts, }: { row: CNPGFleetRow - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef actions?: CNPGSummaryAction[] /** Link to the complete list of this cluster's findings, shown when more than one exists. */ problemsLink?: (count: number) => ReactNode @@ -159,6 +148,7 @@ export function CNPGClusterSummary({ {dimensions && dimensions.length > 0 && } {top && ( 0 && !problemsLink && (
- +
)} @@ -181,18 +171,18 @@ export function CNPGClusterSummary({ {actions && actions.length > 0 && (
{actions.map((a) => ( - ))}
)} - State + State - + {row.controllerStatus.text} @@ -271,7 +261,7 @@ export function CNPGClusterSummary({ {stateFacts} - Protection + Protection diff --git a/packages/k8s-ui/src/components/cnpg/CNPGConnectSection.tsx b/packages/k8s-ui/src/components/cnpg/CNPGConnectSection.tsx index 8c40184de5..04174a58fa 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGConnectSection.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGConnectSection.tsx @@ -3,7 +3,8 @@ import { Check, Copy } from 'lucide-react' import { Tooltip } from '../ui/Tooltip' import { CNPG_GROUP } from '../resources/resource-utils-cnpg' import { cnpgConnectionURI, cnpgConnectInfo, cnpgPsqlCommand, type CNPGConnectEndpoint } from './connect' -import { FactGrid, FactRow, RefLink, SummaryHeading, type CNPGNavigate } from './primitives' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { FactGrid, FactRow, SectionHeading } from '../workspace' function CopyButton({ text, label }: { text: string; label: string }) { const [copied, setCopied] = useState(false) @@ -57,8 +58,6 @@ const ROLE_LABEL: Record = { * id: a drawer summary can sit over a page summary of the same kind, so a host * scrolls to it within its own summary's element. */ -export const CNPG_CONNECT_SELECTOR = '[data-cnpg-anchor="connect"]' - export function CNPGConnectSection({ cluster, poolers, @@ -70,7 +69,7 @@ export function CNPGConnectSection({ poolers?: any[] /** False when Poolers could not be listed, so a Pooler may exist that is not shown. */ poolersKnown?: boolean - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef /** False where the host already titles it (e.g. the Connect dialog). */ showHeading?: boolean }) { @@ -79,7 +78,7 @@ export function CNPGConnectSection({ const primary = info.endpoints[0] return ( <> - {showHeading && Connect} + {showHeading && Connect}
    diff --git a/packages/k8s-ui/src/components/cnpg/CNPGDeclarativeSummary.tsx b/packages/k8s-ui/src/components/cnpg/CNPGDeclarativeSummary.tsx index fd40dc35dd..4d775d27d6 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGDeclarativeSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGDeclarativeSummary.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import { getCNPGDeclarativeMessage, getCNPGReclaimPolicy } from '../resources/resource-utils-cnpg' -import type { CNPGFact, CNPGWorkspaceResponse } from './workspace' +import type { CNPGWorkspaceResponse } from './workspace' +import type { Fact } from '../workspace' import { cnpgLogicalPaths, type CNPGLogicalPath } from './logicalReplication' import { CNPGLogicalPathView } from './CNPGLogicalPath' import { cnpgDatabaseRoleFacts } from './databaseRole' -import { FactGrid, FactRow, FactValue, RefLink, SummaryHeading, toneTextClass, type CNPGNavigate } from './primitives' import { ClusterLink, NotReported, ObjectProblems, SummaryShell } from './CNPGSharedSummary' import { appliedFact, @@ -19,11 +19,14 @@ import { targetCluster, workspaceList, } from './relations' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, FactValue, SectionHeading } from '../workspace' interface SummaryProps { resource: any workspace: CNPGWorkspaceResponse | null - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef } function ReclaimRow({ resource }: { resource: any }) { @@ -44,7 +47,7 @@ function Reconciled({ resource, extra }: { resource: any; extra?: ReactNode }) { const applied = appliedFact(resource) return ( <> - Reconciled + Reconciled @@ -95,7 +98,7 @@ function DatabaseRef({ resource, workspace, onNavigate }: SummaryProps) { ) } -function LinkList({ items, kind, onNavigate }: { items: any[]; kind: string; onNavigate?: CNPGNavigate }) { +function LinkList({ items, kind, onNavigate }: { items: any[]; kind: string; onNavigate?: NavigateToRef }) { return ( {items.map((o) => ( @@ -117,7 +120,7 @@ export function CNPGDatabaseSummary({ resource, workspace, onNavigate }: Summary - Declared + Declared {resource?.spec?.name ? {resource.spec.name} : } @@ -140,7 +143,7 @@ export function CNPGDatabaseSummary({ resource, workspace, onNavigate }: Summary } /> - Source and target + Source and target @@ -203,7 +206,7 @@ function workspacePaths(workspace: CNPGWorkspaceResponse | null | undefined, sub export interface CNPGLogicalPathReading { path: CNPGLogicalPath /** The publisher primary's report of the slot; absent when not read. */ - slot?: CNPGFact + slot?: Fact notice?: ReactNode } @@ -226,7 +229,7 @@ export function CNPGPublicationSummary({ - Declared + Declared {resource?.spec?.name ? {resource.spec.name} : } @@ -246,7 +249,7 @@ export function CNPGPublicationSummary({ - Subscribers + Subscribers {readings.length === 0 ? (
    {subsUnavailable ?? 'No visible Subscription object reads this publication. Subscribers outside Radar\'s view, or created in SQL, are not listed.'} @@ -271,7 +274,7 @@ export function CNPGDatabaseRoleSummary({ resource, workspace, onNavigate }: Sum - Declared + Declared {f.pgName ? {f.pgName} : } @@ -350,7 +353,7 @@ export function CNPGSubscriptionSummary({ - Declared + Declared {resource?.spec?.name ? {resource.spec.name} : } @@ -386,7 +389,7 @@ export function CNPGSubscriptionSummary({ {reading.path && ( <> - Replication path + Replication path )} diff --git a/packages/k8s-ui/src/components/cnpg/CNPGImageCatalogSummary.tsx b/packages/k8s-ui/src/components/cnpg/CNPGImageCatalogSummary.tsx index c7e05904ee..ccff25fde4 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGImageCatalogSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGImageCatalogSummary.tsx @@ -1,8 +1,10 @@ import { CNPG_GROUP, getCNPGImageCatalogEntries } from '../resources/resource-utils-cnpg' import type { CNPGWorkspaceResponse } from './workspace' -import { FactGrid, FactRow, RefLink, SummaryHeading, toneTextClass, type CNPGNavigate } from './primitives' import { NotReported, Note, ObjectProblems, SummaryShell } from './CNPGSharedSummary' import { clustersIn, clustersUsingCatalog, refOf, relationUnavailable } from './relations' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, SectionHeading } from '../workspace' export function CNPGImageCatalogSummary({ resource, @@ -11,7 +13,7 @@ export function CNPGImageCatalogSummary({ }: { resource: any workspace: CNPGWorkspaceResponse | null - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef }) { const clusterScoped = resource?.kind === 'ClusterImageCatalog' const ns = resource?.metadata?.namespace ?? '' @@ -27,7 +29,7 @@ export function CNPGImageCatalogSummary({ - Images + Images {entries.length === 0 ? (
    @@ -42,7 +44,7 @@ export function CNPGImageCatalogSummary({ )} - Used by + Used by {unavailable ? (
    diff --git a/packages/k8s-ui/src/components/cnpg/CNPGLogicalPath.tsx b/packages/k8s-ui/src/components/cnpg/CNPGLogicalPath.tsx index f7e575acbb..b09e5ef2fd 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGLogicalPath.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGLogicalPath.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from 'react' import { ArrowRight } from 'lucide-react' import { CNPG_GROUP } from '../resources/resource-utils-cnpg' -import type { CNPGFact } from './workspace' +import type { Fact } from '../workspace' import { cnpgLogicalLocation, type CNPGLogicalPath } from './logicalReplication' -import { FactGrid, FactRow, FactSource, FactValue, RefLink, toneTextClass, type CNPGNavigate } from './primitives' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, FactSource, FactValue } from '../workspace' // A hop after the first carries its arrow, so a wrapped line never ends on an // arrow pointing at nothing. @@ -33,8 +35,8 @@ export function CNPGLogicalPathView({ notice, }: { path: CNPGLogicalPath - slot?: CNPGFact - onNavigate?: CNPGNavigate + slot?: Fact + onNavigate?: NavigateToRef compact?: boolean /** The host's word on the slot reading, e.g. that its latest refresh failed. */ notice?: ReactNode @@ -42,7 +44,7 @@ export function CNPGLogicalPathView({ const s = path.subscription const pub = path.publication const publisher = path.publisher - const slotFact: CNPGFact = slot ?? { text: path.slot.name ? `Slot ${path.slot.name}: not read` : path.slot.reason ?? 'No slot', tone: 'unknown' } + const slotFact: Fact = slot ?? { text: path.slot.name ? `Slot ${path.slot.name}: not read` : path.slot.reason ?? 'No slot', tone: 'unknown' } const chain = (
    diff --git a/packages/k8s-ui/src/components/cnpg/CNPGObjectStoreSummary.tsx b/packages/k8s-ui/src/components/cnpg/CNPGObjectStoreSummary.tsx index 39e1556ea9..1a5f18309f 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGObjectStoreSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGObjectStoreSummary.tsx @@ -8,9 +8,11 @@ import { getCNPGObjectStoreRetention, } from '../resources/resource-utils-cnpg' import type { CNPGWorkspaceResponse } from './workspace' -import { FactGrid, FactRow, FactValue, RefLink, SummaryHeading, toneTextClass, type CNPGNavigate } from './primitives' import { NotReported, Note, ObjectProblems, SummaryShell, TimeAgo } from './CNPGSharedSummary' import { clustersIn, inferredObjectStoreHealth, refOf, relationUnavailable, usersOfObjectStore } from './relations' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, FactValue, SectionHeading } from '../workspace' function utc(at: string | undefined): string { if (!at || !Number.isFinite(Date.parse(at))) return 'unknown' @@ -24,7 +26,7 @@ export function CNPGObjectStoreSummary({ }: { resource: any workspace: CNPGWorkspaceResponse | null - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef }) { const ns = resource?.metadata?.namespace ?? '' const clustersUnavailable = relationUnavailable(workspace, 'clusters', ns, 'Clusters') @@ -46,7 +48,7 @@ export function CNPGObjectStoreSummary({ onNavigate={onNavigate} /> - Upload health + Upload health {clustersUnavailable ? (
    @@ -89,7 +91,7 @@ export function CNPGObjectStoreSummary({ )} - Recovery window + Recovery window {windows.length === 0 ? (
    @@ -136,7 +138,7 @@ export function CNPGObjectStoreSummary({ )} - Destination + Destination {destination !== '-' ? {destination} : } {provider ?? } @@ -152,7 +154,7 @@ export function CNPGObjectStoreSummary({ {retention ?? } - Used by + Used by {clustersUnavailable ? (
    diff --git a/packages/k8s-ui/src/components/cnpg/CNPGPoolerSummary.tsx b/packages/k8s-ui/src/components/cnpg/CNPGPoolerSummary.tsx index a6ebddb8c6..7a2f6e5d72 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGPoolerSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGPoolerSummary.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react' import { getCNPGPoolerDeploymentName, getCNPGPoolerMode, getCNPGPoolerStatus, isCNPGPoolerPaused } from '../resources/resource-utils-cnpg' import type { CNPGWorkspaceResponse } from './workspace' -import { FactGrid, FactRow, FactValue, RefLink, SummaryHeading, toneTextClass, type CNPGNavigate } from './primitives' import { ClusterLink, NotReported, Note, ObjectProblems, PhaseBadge, SummaryShell } from './CNPGSharedSummary' import { refOf } from './relations' import { @@ -14,6 +13,9 @@ import { type CNPGPoolerLive, type CNPGPoolerPoolRow, } from './pooler' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, FactValue, SectionHeading } from '../workspace' const TYPE_LABEL: Record = { rw: 'rw · routes to the primary', @@ -31,7 +33,7 @@ export function CNPGPoolerSummary({ }: { resource: any workspace: CNPGWorkspaceResponse | null - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef /** Live reads a host adds (Deployment readiness, PgBouncer metrics and state). */ live?: CNPGPoolerLive /** Operations rendered beside the paused state (pause / resume). */ @@ -53,7 +55,7 @@ export function CNPGPoolerSummary({ {lead} - State + State {readiness ? ( @@ -102,7 +104,7 @@ export function CNPGPoolerSummary({ - Connections + Connections {live?.pressure ? ( ) : ( @@ -113,7 +115,7 @@ export function CNPGPoolerSummary({ )} - Limits + Limits {resource?.spec?.pgbouncer?.poolMode ? getCNPGPoolerMode(resource) : session (default)} @@ -137,7 +139,7 @@ export function CNPGPoolerSummary({ })} - Routing + Routing @@ -153,7 +155,7 @@ export function CNPGPoolerSummary({ ) } -function PoolerPath({ resource, live, onNavigate }: { resource: any; live: CNPGPoolerLive; onNavigate?: CNPGNavigate }) { +function PoolerPath({ resource, live, onNavigate }: { resource: any; live: CNPGPoolerLive; onNavigate?: NavigateToRef }) { const ns = resource?.metadata?.namespace ?? '' const svc = live.service! const backend = poolerBackendService(resource?.spec?.cluster?.name, resource?.spec?.type) diff --git a/packages/k8s-ui/src/components/cnpg/CNPGSharedSummary.tsx b/packages/k8s-ui/src/components/cnpg/CNPGSharedSummary.tsx index 301ee5ad23..625f02c2e9 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGSharedSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGSharedSummary.tsx @@ -3,8 +3,10 @@ import { Badge } from '../ui/Badge' import type { StatusBadge as StatusBadgeValue } from '../resources/resource-utils' import { CNPG_GROUP } from '../resources/resource-utils-cnpg' import type { CNPGWorkspaceIssue, CNPGWorkspaceResponse } from './workspace' -import { FactValue, ProblemCallout, RefLink, type CNPGNavigate } from './primitives' -import { clustersIn, healthSeverity, problemsForObject, relationUnavailable, targetCluster, type CNPGObjectRef } from './relations' +import { healthToSeverity } from '../../utils/badge-colors' +import { clustersIn, problemsForObject, relationUnavailable, targetCluster, type CNPGObjectRef } from './relations' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { FactValue, ProblemCallout } from '../workspace' const MAX_PROBLEMS = 3 @@ -20,7 +22,7 @@ export function ObjectProblems({ }: { issues: CNPGWorkspaceIssue[] | undefined subject: CNPGObjectRef - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef }) { const problems = problemsForObject(issues, subject) if (problems.length === 0) return null @@ -30,6 +32,7 @@ export function ObjectProblems({
    {shown.map((p, i) => ( + {status.text} ) @@ -71,7 +74,7 @@ export function ClusterLink({ }: { resource: any workspace: CNPGWorkspaceResponse | null - onNavigate?: CNPGNavigate + onNavigate?: NavigateToRef }) { const name = resource?.spec?.cluster?.name if (!name) return diff --git a/packages/k8s-ui/src/components/cnpg/ha.ts b/packages/k8s-ui/src/components/cnpg/ha.ts index 4e905655b8..75587c5367 100644 --- a/packages/k8s-ui/src/components/cnpg/ha.ts +++ b/packages/k8s-ui/src/components/cnpg/ha.ts @@ -4,7 +4,9 @@ // unavailable source is "unknown", never none or healthy. import type { HealthLevel } from '../resources/resource-utils' -import { cnpgFormatLag, cnpgLagTone, cnpgReplicationTone, cnpgSustainedLagProblemId, cnpgWorseTone, type CNPGFact, type CNPGFleetRow } from './workspace' +import { cnpgFormatLag, cnpgLagTone, cnpgReplicationTone, cnpgSustainedLagProblemId, type CNPGFleetRow } from './workspace' +import type { Fact, FoldSummary } from '../workspace' +import { worseTone } from '../ui/status-tone' export type CNPGHASourceState = 'ok' | 'denied' | 'notFound' | 'notInstalled' | 'unavailable' | 'error' @@ -209,7 +211,7 @@ export function cnpgZoneSpread(ha: CNPGClusterHA | undefined): CNPGZoneSpread { // --------------------------------------------------------------------------- // Quorum, PDB, images, certificates -export function cnpgQuorumFact(q: CNPGHAQuorum | undefined): CNPGFact { +export function cnpgQuorumFact(q: CNPGHAQuorum | undefined): Fact { if (!q) return { text: 'Unknown', tone: 'unknown' } if (!q.enabled) { if (q.number !== undefined || q.method) { @@ -237,7 +239,7 @@ export function cnpgQuorumFact(q: CNPGHAQuorum | undefined): CNPGFact { } } -export function cnpgPDBFact(pdbs: CNPGClusterHA['pdbs'] | undefined): CNPGFact { +export function cnpgPDBFact(pdbs: CNPGClusterHA['pdbs'] | undefined): Fact { if (!pdbs) return { text: 'Unknown', tone: 'unknown' } if (pdbs.state !== 'ok') return { text: cnpgHASourceText(pdbs, 'PodDisruptionBudgets'), tone: 'unknown' } if (pdbs.items.length === 0) { @@ -284,13 +286,6 @@ export function cnpgCertificateViews(certs: CNPGHACertificate[] | undefined, now }) } -/** A folded section's one-line summary, and whether it opens on its own. */ -export interface CNPGFoldSummary { - text: string - /** Something in the section needs a look: it opens itself. */ - attention: boolean -} - /** * "HA and instances" in one line: what is wrong when something is, otherwise * the readiness and placement facts that are known. Unknown facts never read @@ -300,7 +295,7 @@ export function cnpgHASummary( ha: CNPGClusterHA | undefined, live: CNPGInstanceLive[] | undefined, primaryConflict?: { status: string; labelled: string }, -): CNPGFoldSummary { +): FoldSummary { if (!ha) return { text: 'Not read', attention: false } const issues: string[] = [] const calm: string[] = [] @@ -350,7 +345,7 @@ export function cnpgHASummary( } /** Certificates in one line: the nearest expiry and who renews them. */ -export function cnpgCertificatesSummary(certs: CNPGHACertificate[] | undefined, now = Date.now()): CNPGFoldSummary { +export function cnpgCertificatesSummary(certs: CNPGHACertificate[] | undefined, now = Date.now()): FoldSummary { const views = cnpgCertificateViews(certs, now) if (views.length === 0) return { text: 'No expiry reported by the operator', attention: false } const dated = views.filter((c) => Number.isFinite(c.daysLeft)).sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)) @@ -483,7 +478,7 @@ function replicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap const tone: HealthLevel = sustained.severity === 'critical' ? 'unhealthy' : 'degraded' return { ...dim, - tone: cnpgWorseTone(dim.tone, tone), + tone: worseTone(dim.tone, tone), text: dim.tone === 'unknown' ? 'sustained lag' : `${dim.text} · sustained lag`, source: sustained.title, } diff --git a/packages/k8s-ui/src/components/cnpg/logicalReplication.ts b/packages/k8s-ui/src/components/cnpg/logicalReplication.ts index d7dbf59155..f5755b8fc3 100644 --- a/packages/k8s-ui/src/components/cnpg/logicalReplication.ts +++ b/packages/k8s-ui/src/components/cnpg/logicalReplication.ts @@ -1,4 +1,4 @@ -import type { CNPGFact } from './workspace' +import type { Fact } from '../workspace' /** Where a Subscription's publisher lives, as far as the subscriber's spec shows. */ export type CNPGPublisher = @@ -19,7 +19,7 @@ export interface CNPGLogicalPath { } /** The slot PostgreSQL creates for the subscription: `slot_name`, else the subscription's name. */ slot: { name?: string; reason?: string } - failover: CNPGFact + failover: Fact } const SERVICE_SUFFIXES = ['-rw', '-ro', '-r'] @@ -91,7 +91,7 @@ const FAILOVER_SOURCE = "Publisher's spec.replicationSlots.highAvailability (ena * so a failover of the publisher does not lose it. Declared configuration * only: CloudNativePG does not report which slots were actually synchronized. */ -export function cnpgSlotFailover(publisher: CNPGPublisher, subscription: any): CNPGFact { +export function cnpgSlotFailover(publisher: CNPGPublisher, subscription: any): Fact { if (publisher.kind !== 'cluster') { return { text: 'Unknown: the publisher is not a CloudNativePG Cluster Radar can see', tone: 'unknown', source: FAILOVER_SOURCE } } @@ -228,7 +228,7 @@ function bytesText(n: number): string { const SLOT_SOURCE = "Publisher primary's instance manager (/pg/status replicationSlotsInfo) and exporter (retained WAL)" -export function cnpgLogicalSlotFact(path: CNPGLogicalPath, observed: CNPGPublisherSlots): CNPGFact { +export function cnpgLogicalSlotFact(path: CNPGLogicalPath, observed: CNPGPublisherSlots): Fact { if (!path.slot.name) return { text: path.slot.reason ?? 'No slot', tone: 'neutral' } if (path.publisher.kind !== 'cluster') return { text: `Slot ${path.slot.name}: not observable (publisher outside this cluster's view)`, tone: 'unknown' } if (observed.state === 'denied') return { text: `Slot ${path.slot.name}: no access (needs get pods/proxy on the publisher)`, tone: 'unknown', source: SLOT_SOURCE } diff --git a/packages/k8s-ui/src/components/cnpg/primitives.tsx b/packages/k8s-ui/src/components/cnpg/primitives.tsx index 180ac4d366..2c753cb1f6 100644 --- a/packages/k8s-ui/src/components/cnpg/primitives.tsx +++ b/packages/k8s-ui/src/components/cnpg/primitives.tsx @@ -1,261 +1,5 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import { clsx } from 'clsx' -import type { HealthLevel } from '../resources/resource-utils' -import { formatAge } from '../resources/resource-utils' -import { StatusDot } from '../ui/status-tone' -import { Tooltip } from '../ui/Tooltip' -import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse' -import { AlertBanner } from '../ui/drawer-components' -import { TONE_TEXT_CLASS } from '../ui/severity-tone' -import type { CNPGFact, CNPGProblem } from './workspace' - -export interface CNPGRef { - kind: string - group?: string - namespace: string - name: string -} - -export type CNPGNavigate = (ref: CNPGRef) => void - -const TONE_TEXT: Record = { - healthy: 'text-theme-text-primary', - degraded: TONE_TEXT_CLASS.amber, - alert: TONE_TEXT_CLASS.orange, - unhealthy: TONE_TEXT_CLASS.red, - unknown: 'text-theme-text-tertiary', - neutral: 'text-theme-text-secondary', -} - -export const CNPG_PRIMARY_BUTTON = 'btn-brand inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-3 py-1.5 text-sm font-medium' -export const CNPG_SECONDARY_BUTTON = - 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-3 py-1.5 text-sm text-theme-text-primary transition-colors hover:bg-theme-hover' - -export function toneTextClass(tone: HealthLevel): string { - return TONE_TEXT[tone] -} - -export function FactValue({ fact, className }: { fact: CNPGFact; className?: string }) { - const age = fact.at ? formatAge(fact.at) : null - const body = ( - - {fact.text} - {age && fact.atMeaning === 'since' && for {age}} - {age && fact.atMeaning !== 'since' && {fact.text ? ' · ' : ''}{age} ago} - - ) - if (!fact.source && !fact.at && !fact.detail) return body - return ( - - {body} - - ) -} - -export function FactSource({ fact }: { fact: CNPGFact }) { - if (!fact.source) return null - return
    {fact.source}
    -} - -export function FactGrid({ children }: { children: ReactNode }) { - return
    {children}
    -} - -export function FactRow({ label, children }: { label: ReactNode; children: ReactNode }) { - return ( - <> -
    {label}
    -
    {children}
    - - ) -} - -export function SummaryHeading({ children, hint, anchor }: { children: ReactNode; hint?: ReactNode; anchor?: string }) { - return ( -
    -

    {children}

    - {hint && {hint}} -
    - ) -} - -/** - * A section folded to one summary line. It opens itself when `attention` - * turns true (data arriving after the first render included), and stays as - * the reader left it otherwise. - */ -export function FoldSection({ - title, - hint, - summary, - attention, - anchor, - children, -}: { - title: ReactNode - hint?: ReactNode - summary: ReactNode - attention: boolean - anchor?: string - children: ReactNode -}) { - const [open, setOpen] = useState(attention) - useEffect(() => { - if (attention) setOpen(true) - }, [attention]) - const d = useDisclosure(open) - return ( -
    - - -
    {children}
    -
    -
    - ) -} - -export function RefLink({ refTo, onNavigate, children, mono }: { refTo: CNPGRef; onNavigate?: CNPGNavigate; children?: ReactNode; mono?: boolean }) { - const label = children ?? refTo.name - if (!onNavigate) return {label} - return ( - - ) -} - -const PROBLEM_VARIANT: Record = { - critical: 'error', - warning: 'warning', - posture: 'info', -} - -/** Where a problem's evidence is and what produced it, shared by the callout and the full list. */ -/** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */ -export function cnpgProblemOriginLabel(problem: CNPGProblem): { label: string; detail?: string } { - switch (problem.source) { - case 'audit': - return { label: 'Best-practice check', detail: problem.sourceDetail } - case 'measurement': - return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail } - } - return problem.origin ?? { label: 'Detected by Radar' } -} - -/** - * How a host opens a problem on its Issues page. Supplied by context so every - * CNPG summary and drawer gets the link without threading a prop through each. - */ -export const CNPGOpenIssueContext = createContext<((problem: CNPGProblem) => void) | undefined>(undefined) - -export function ProblemMeta({ problem, onNavigate, subjectIsSelf, children }: { problem: CNPGProblem; onNavigate?: CNPGNavigate; subjectIsSelf?: boolean; children?: ReactNode }) { - const openIssue = useContext(CNPGOpenIssueContext) - const origin = cnpgProblemOriginLabel(problem) - const aboutChild = !subjectIsSelf && problem.subject.kind !== 'Cluster' - return ( -
    - {aboutChild && ( - - {problem.subject.kind}{' '} - - {problem.alsoAbout && problem.alsoAbout.length > 0 && ' '} - {problem.alsoAbout && problem.alsoAbout.length > 0 && ( - - {problem.alsoAbout.map((o) => ( -
  • - {o.kind} {o.name} -
  • - ))} -
- } - > - and {problem.alsoAbout.length} more - - )} - - )} - - {origin.label} - - {openIssue && problem.source === 'issue' && ( - - )} - {children} - - ) -} - -export const CNPG_PROBLEM_TONE: Record = { - critical: 'unhealthy', - warning: 'degraded', - posture: 'neutral', -} - -export function ProblemCallout({ - problem, - more, - onNavigate, - action, - subjectIsSelf, -}: { - problem: CNPGProblem - more?: ReactNode - onNavigate?: CNPGNavigate - action?: ReactNode - /** The callout sits on the subject's own page, so linking to it would loop. */ - subjectIsSelf?: boolean -}) { - return ( - - - {action} - {more} - - - ) -} - -/** The problems a callout does not show, as a compact list with the callout's tone, title and source. */ -export function ProblemList({ problems, onNavigate }: { problems: CNPGProblem[]; onNavigate?: CNPGNavigate }) { - return ( -
    - {problems.map((p) => ( -
  • - - - -
    -
    {p.title}
    - {p.detail &&
    {p.detail}
    } - -
    -
  • - ))} -
- ) -} - -export function ToneDot({ tone }: { tone: HealthLevel }) { - return -} +import { toneTextClass } from '../ui/status-tone' /** status.currentPrimary and the primary role label disagree: both are named rather than one silently winning. */ export function PrimaryConflictNote({ conflict }: { conflict: { status: string; labelled: string } }) { diff --git a/packages/k8s-ui/src/components/cnpg/relations.ts b/packages/k8s-ui/src/components/cnpg/relations.ts index 3543c1b198..78f4324843 100644 --- a/packages/k8s-ui/src/components/cnpg/relations.ts +++ b/packages/k8s-ui/src/components/cnpg/relations.ts @@ -2,8 +2,6 @@ // payload. Each helper answers only from what the objects record; a relation // that cannot be established returns null or an empty list, never a guess. -import type { BadgeSeverity } from '../ui/Badge' -import type { HealthLevel } from '../resources/resource-utils' import { CNPG_BARMAN_PLUGIN_NAME, CNPG_GROUP, @@ -12,15 +10,8 @@ import { isApiGroup, type CNPGObjectStoreRecoveryWindow, } from '../resources/resource-utils-cnpg' -import { - cnpgIssueCategory, cnpgIssueOrigin, cnpgIssueText, - coverageReadable, - type CNPGFact, - type CNPGProblem, - type CNPGWorkspaceIssue, - type CNPGWorkspaceKey, - type CNPGWorkspaceResponse, -} from './workspace' +import { cnpgIssueCategory, cnpgIssueOrigin, cnpgIssueText, coverageReadable, type CNPGProblem, type CNPGWorkspaceIssue, type CNPGWorkspaceKey, type CNPGWorkspaceResponse } from './workspace' +import type { Fact } from '../workspace' export interface CNPGObjectRef { kind: string @@ -58,21 +49,6 @@ export function refOf(obj: any, kind: string, group: string = CNPG_GROUP): CNPGO return { kind, group, namespace: nsOf(obj), name: nameOf(obj) } } -export function healthSeverity(level: HealthLevel): BadgeSeverity { - switch (level) { - case 'healthy': - return 'success' - case 'unhealthy': - return 'error' - case 'alert': - return 'alert' - case 'degraded': - return 'warning' - default: - return 'neutral' - } -} - // --------------------------------------------------------------------------- // Workspace access // --------------------------------------------------------------------------- @@ -256,16 +232,16 @@ export function usersOfObjectStore(store: any, clusters: any[]): CNPGObjectStore export interface CNPGObjectStoreEvidence { cluster: CNPGObjectRef serverName: string - archiving: CNPGFact + archiving: Fact window: CNPGObjectStoreRecoveryWindow | null } export interface CNPGObjectStoreHealth { - summary: CNPGFact + summary: Fact evidence: CNPGObjectStoreEvidence[] } -function archivingFact(cluster: any): CNPGFact { +function archivingFact(cluster: any): Fact { const conds = cluster?.status?.conditions const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null if (!c) return { text: 'WAL archiving not reported', tone: 'unknown' } @@ -319,14 +295,14 @@ export function inferredObjectStoreHealth(store: any, users: CNPGObjectStoreUser // Declarative objects // --------------------------------------------------------------------------- -export function appliedFact(obj: any): CNPGFact { +export function appliedFact(obj: any): Fact { const applied = obj?.status?.applied if (applied === true) return { text: 'Applied', tone: 'healthy' } if (applied === false) return { text: 'Not applied', tone: 'unhealthy' } return { text: 'Pending · the operator has not reported a result yet', tone: 'unknown' } } -export function observedGenerationFact(obj: any): CNPGFact { +export function observedGenerationFact(obj: any): Fact { const observed = obj?.status?.observedGeneration const generation = obj?.metadata?.generation if (typeof observed !== 'number') return { text: 'Not reported', tone: 'unknown' } diff --git a/packages/k8s-ui/src/components/cnpg/workspace.ts b/packages/k8s-ui/src/components/cnpg/workspace.ts index 176e986299..0cef2dd842 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace.ts @@ -3,6 +3,8 @@ // does not report something the value is "unknown", never zero or healthy. import { formatAge, type HealthLevel } from '../resources/resource-utils' +import { worseTone } from '../ui/status-tone' +import type { Fact, ProblemOrigin, WorkspaceProblem } from '../workspace' import { formatBytes } from '../../utils/format' import { CNPG_BARMAN_PLUGIN_NAME, @@ -99,22 +101,6 @@ export function isCNPGWorkspaceKind(kind: string, group: string | undefined): bo return Object.values(CNPG_KIND_BY_KEY).some((k) => k.group !== '' && k.group === (group ?? '') && k.kind === kind) } -/** The value is observed, derived, or not available from the cluster. */ -export type CNPGFactTone = HealthLevel - -export interface CNPGFact { - text: string - tone: CNPGFactTone - /** Where the value comes from, shown next to it so claims carry their source. */ - source?: string - /** A timestamp the text refers to; the UI renders it as an age. */ - at?: string - /** `since`: `at` is when a still-current state began, rendered "Failing for 2d" rather than "· 2d ago". */ - atMeaning?: 'since' - /** The full explanation behind a short `source`, shown on hover only. */ - detail?: string -} - export type CNPGProblemCategory = 'availability' | 'protection' | 'declarations' | 'pooling' export const CNPG_PROBLEM_CATEGORIES: { id: CNPGProblemCategory; label: string }[] = [ @@ -124,33 +110,8 @@ export const CNPG_PROBLEM_CATEGORIES: { id: CNPGProblemCategory; label: string } { id: 'pooling', label: 'Pooling' }, ] -export interface CNPGProblem { - /** Stable identity for keys. */ - id: string - severity: 'critical' | 'warning' | 'posture' - category: CNPGProblemCategory - title: string - detail?: string - /** The object the evidence is about (may be the Cluster or a child object). */ - subject: { kind: string; group: string; namespace: string; name: string } - /** - * measurement: derived here from a reading only callers holding its grants - * receive (disk use, instance Pod readiness). - */ - source: 'issue' | 'audit' | 'measurement' - /** What took the measurement, e.g. "Prometheus" (shown as "Measured by Prometheus"). */ - measuredBy?: string - /** The measurement's series were matched to this cluster by name only (see `measuredBy`). */ - unverifiedMatch?: boolean - /** How it was measured (queries, metric names), shown on hover over the source. */ - sourceDetail?: string - /** A shorter headline for tight places (the fleet cell); `title` stays the precise one. */ - shortTitle?: string - /** Where an issue's evidence comes from, in user terms (see cnpgIssueOrigin). */ - origin?: CNPGProblemOrigin - /** Other objects the same problem is about, e.g. earlier Backups that failed the same way. */ - alsoAbout?: { kind: string; name: string }[] -} +/** A problem in the CloudNativePG workspace, categorised by the workspace's four screens. */ +export type CNPGProblem = WorkspaceProblem export interface CNPGInstance { name: string @@ -161,15 +122,15 @@ export interface CNPGInstance { } export interface CNPGProtectionFacts { - schedule: CNPGFact & { names: string[] } - destination: CNPGFact & { + schedule: Fact & { names: string[] } + destination: Fact & { method: 'plugin' | 'barmanObjectStore' | 'volumeSnapshot' | 'none' objectStore?: string } - lastSuccessfulBackup: CNPGFact - walArchiving: CNPGFact - recoveryWindow: CNPGFact & { from?: string } - restoreValidation: CNPGFact & { restoredInto?: { namespace: string; name: string } } + lastSuccessfulBackup: Fact + walArchiving: Fact + recoveryWindow: Fact & { from?: string } + restoreValidation: Fact & { restoredInto?: { namespace: string; name: string } } } export interface CNPGFleetRow { @@ -193,9 +154,9 @@ export interface CNPGFleetRow { hibernated: boolean pgVersion: string | null catalog: { kind: string; name: string } | null - replication: CNPGFact - protection: CNPGProtectionFacts & { summary: CNPGFact } - declarations: { summary: CNPGFact; total: number; failed: number; pending: number } + replication: Fact + protection: CNPGProtectionFacts & { summary: Fact } + declarations: { summary: Fact; total: number; failed: number; pending: number } poolers: string[] /** The Pooler objects behind `poolers`, for their type and Service port. */ poolerObjects?: any[] @@ -208,9 +169,9 @@ export interface CNPGFleetRow { /** GitOps owner recorded on the Cluster, when it carries the standard labels. */ gitops: CNPGGitOpsSource | null /** Fullest measured volume, set by applyCNPGDisk; absent when no disk reading was requested. */ - disk?: CNPGFact + disk?: Fact /** Growth of the fastest-growing volume, set by applyCNPGFleetMetrics when measured. */ - diskGrowth?: CNPGFact + diskGrowth?: Fact } export interface CNPGFleet { @@ -353,11 +314,6 @@ function backupTimesOf(cluster: any, backups: any[]): Map { return out } -export interface CNPGProblemOrigin { - label: string - /** The exact field or condition, shown on hover. */ - detail?: string -} // Each entry names what the Go detector (internal/issues/source_cnpg*.go and // the Pod detector) actually reads. "Reported by CNPG" only where the operator @@ -373,7 +329,7 @@ const CNPG_CONDITION_ORIGINS: Record = { CNPGDeclarativeNotApplied: 'status.applied and status.message', } -const POD_ORIGINS: Record = { +const POD_ORIGINS: Record = { ReadinessProbeFailed: { label: 'Pod readiness probe', detail: 'Kubelet probe-failure events and the Pod\'s Ready condition' }, LivenessProbeFailed: { label: 'Pod liveness probe', detail: 'Kubelet probe-failure events and container restarts' }, ReadinessProbeInvalid: { label: 'Radar check of the probe', detail: 'The readiness probe names a port the container does not declare' }, @@ -387,7 +343,7 @@ const POD_ORIGINS: Record = { * reported, a Backup's or Pod's own status, or Radar's own check. A reason this * does not know reads "Detected by Radar" rather than a guessed source. */ -export function cnpgIssueOrigin(issue: Pick): CNPGProblemOrigin { +export function cnpgIssueOrigin(issue: Pick): ProblemOrigin { const condition = CNPG_CONDITION_ORIGINS[issue.reason] if (condition) return { label: 'Reported by CNPG', detail: condition } switch (issue.reason) { @@ -624,7 +580,7 @@ function lastBackupFact( const ARCHIVING_RECENT_MS = 24 * 3_600_000 const ARCHIVING_SETTLE_MS = 10 * 60_000 -function walFact(cluster: any, now = Date.now()): CNPGFact { +function walFact(cluster: any, now = Date.now()): Fact { const conds = cluster?.status?.conditions const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null if (!c) return { text: 'Not reported', tone: 'unknown', source: 'Cluster status' } @@ -749,7 +705,7 @@ function restoreValidationFact( } } -function protectionSummary(p: CNPGProtectionFacts): CNPGFact { +function protectionSummary(p: CNPGProtectionFacts): Fact { if (p.walArchiving.tone === 'unhealthy') return { text: 'WAL archiving failing', tone: 'unhealthy' } if (p.destination.method === 'none' && p.schedule.names.length === 0 && p.schedule.tone !== 'unknown') { return { text: 'No backup destination or schedule', tone: 'neutral' } @@ -775,7 +731,7 @@ function pgVersion(cluster: any): string | null { return typeof major === 'number' ? String(major) : null } -function replicationFact(cluster: any, pods: CNPGInstance[], hibernated: boolean, podsCov: CNPGKindCoverage): CNPGFact { +function replicationFact(cluster: any, pods: CNPGInstance[], hibernated: boolean, podsCov: CNPGKindCoverage): Fact { if (hibernated) return { text: 'Hibernated', tone: 'neutral' } const desired = cluster?.spec?.instances if (desired === 1) return { text: 'Single instance', tone: 'neutral' } @@ -981,7 +937,7 @@ function declarationsFor(cluster: any, resp: CNPGWorkspaceResponse): CNPGFleetRo if (failedRoles.has(r.name)) failed++ else if (!reconciledRoles.has(r.name)) pending++ } - let summary: CNPGFact + let summary: Fact if (total === 0) { summary = unreadable ? { text: 'No access to some declarations', tone: 'unknown' } : { text: 'None declared', tone: 'neutral' } } else if (failed > 0) { @@ -1182,7 +1138,7 @@ export function cnpgDiskTone(ratio: number): HealthLevel { } /** The fleet and summary "Storage" fact: the fullest measured volume, or why there is none. */ -export function cnpgDiskFact(r: CNPGDiskReading | undefined): CNPGFact { +export function cnpgDiskFact(r: CNPGDiskReading | undefined): Fact { if (!r) return { text: 'Not read', tone: 'unknown' } if (r.max && (r.state === 'ok' || r.state === 'partial')) { const partial = r.state === 'partial' ? ` · ${r.measured} of ${r.claims} volumes measured` : '' @@ -1289,13 +1245,6 @@ export function cnpgLagTone(seconds: number): HealthLevel { return 'healthy' } -const CNPG_TONE_SEVERITY: Record = { healthy: 0, neutral: 0, unknown: 1, degraded: 2, alert: 3, unhealthy: 4 } - -/** The more severe of two tones. */ -export function cnpgWorseTone(a: HealthLevel, b: HealthLevel): HealthLevel { - return CNPG_TONE_SEVERITY[b] > CNPG_TONE_SEVERITY[a] ? b : a -} - /** * Replication's tone from the primary's pg_stat_replication: a missing * standby is degraded, and the lag of the ones that do stream can make it @@ -1303,7 +1252,7 @@ export function cnpgWorseTone(a: HealthLevel, b: HealthLevel): HealthLevel { */ export function cnpgReplicationTone(streaming: number, expected: number, maxLagSeconds: number | undefined): HealthLevel { const missing: HealthLevel = streaming < expected ? 'degraded' : 'healthy' - return maxLagSeconds === undefined ? missing : cnpgWorseTone(missing, cnpgLagTone(maxLagSeconds)) + return maxLagSeconds === undefined ? missing : worseTone(missing, cnpgLagTone(maxLagSeconds)) } /** @@ -1322,7 +1271,7 @@ export function cnpgFormatLag(s: number): string { return m === 0 ? `${Math.floor(minutes / 60)} h` : `${Math.floor(minutes / 60)} h ${m} min` } -function measuredReplication(base: CNPGFact, reading: CNPGFleetMetricsReading | undefined, src: CNPGFleetMetricsSources): CNPGFact { +function measuredReplication(base: Fact, reading: CNPGFleetMetricsReading | undefined, src: CNPGFleetMetricsSources): Fact { const prefix = base.text.replace(/ · lag unknown$/, '') if (src.source === 'none') { return { text: `${prefix} · lag unknown`, tone: 'unknown', source: CNPG_PROMETHEUS_NOT_CONNECTED, detail: src.reason } @@ -1345,7 +1294,7 @@ function measuredReplication(base: CNPGFact, reading: CNPGFleetMetricsReading | } /** Volume growth of the fastest-growing claim, as a fact. */ -export function cnpgDiskGrowthFact(reading: CNPGFleetMetricsReading | undefined, src: CNPGFleetMetricsSources): CNPGFact | undefined { +export function cnpgDiskGrowthFact(reading: CNPGFleetMetricsReading | undefined, src: CNPGFleetMetricsSources): Fact | undefined { const g = reading?.growth if (src.source === 'none' || !g || g.state !== 'ok' || g.bytesPerHour === undefined) return undefined const perDay = g.bytesPerHour * 24 diff --git a/packages/k8s-ui/src/components/ui/RefLink.tsx b/packages/k8s-ui/src/components/ui/RefLink.tsx new file mode 100644 index 0000000000..ccdd63ac9d --- /dev/null +++ b/packages/k8s-ui/src/components/ui/RefLink.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { clsx } from 'clsx' +import type { ResourceRef } from '../../types/core' + +export type NavigateToRef = (ref: ResourceRef) => void + +/** A reference to another object: a link when the host can navigate, plain text otherwise. */ +export function RefLink({ refTo, onNavigate, children, mono }: { refTo: ResourceRef; onNavigate?: NavigateToRef; children?: ReactNode; mono?: boolean }) { + const label = children ?? refTo.name + if (!onNavigate) return {label} + return ( + + ) +} diff --git a/packages/k8s-ui/src/components/ui/index.ts b/packages/k8s-ui/src/components/ui/index.ts index ac7da1b923..2a57acd04a 100644 --- a/packages/k8s-ui/src/components/ui/index.ts +++ b/packages/k8s-ui/src/components/ui/index.ts @@ -19,7 +19,9 @@ export type { SelectMenuOption } from './SelectMenu' export { Input } from './Input' export { FilterPill } from './FilterPill' export type { FilterPillTone } from './FilterPill' -export { StatusDot, mapHealthToTone, toneFillClass } from './status-tone' +export { StatusDot, mapHealthToTone, toneFillClass, toneTextClass, worseTone } from './status-tone' +export { RefLink } from './RefLink' +export type { NavigateToRef } from './RefLink' export type { StatusTone, StatusDotProps } from './status-tone' export { DialogPortal } from './DialogPortal' export { ConfirmDialog } from './ConfirmDialog' diff --git a/packages/k8s-ui/src/components/ui/status-tone.test.ts b/packages/k8s-ui/src/components/ui/status-tone.test.ts index 2caff63154..e5dbb25a5e 100644 --- a/packages/k8s-ui/src/components/ui/status-tone.test.ts +++ b/packages/k8s-ui/src/components/ui/status-tone.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { mapHealthToTone } from './status-tone' +import { mapHealthToTone, worseTone } from './status-tone' // Inputs flow in from heterogeneous sources (Problems API, Audit findings, // multi-cluster aggregation). A regression here is a silent visual @@ -51,3 +51,12 @@ describe('mapHealthToTone', () => { expect(mapHealthToTone('HIGH')).toBe('alert') }) }) + +describe('worseTone', () => { + it('never reads calmer than a part that could not be read', () => { + expect(worseTone('healthy', 'unknown')).toBe('unknown') + expect(worseTone('unknown', 'degraded')).toBe('degraded') + expect(worseTone('alert', 'unhealthy')).toBe('unhealthy') + expect(worseTone('neutral', 'healthy')).toBe('neutral') + }) +}) diff --git a/packages/k8s-ui/src/components/ui/status-tone.tsx b/packages/k8s-ui/src/components/ui/status-tone.tsx index 5ce08501ab..5c3274d892 100644 --- a/packages/k8s-ui/src/components/ui/status-tone.tsx +++ b/packages/k8s-ui/src/components/ui/status-tone.tsx @@ -1,4 +1,5 @@ import { type HealthLevel } from '../resources/resource-utils'; +import { TONE_TEXT_CLASS } from './severity-tone'; // StatusDot + mapHealthToTone are typed helpers over the canonical OSS // status vocabulary defined in `packages/k8s-ui/src/theme/components.css`. @@ -53,6 +54,30 @@ export function toneFillClass(tone: StatusTone): string { return DOT_CLASS[tone] } +// Text for a value read in a tone. Healthy reads as plain primary text: only +// what needs a look is coloured. +const TEXT_CLASS: Record = { + healthy: 'text-theme-text-primary', + degraded: TONE_TEXT_CLASS.amber, + alert: TONE_TEXT_CLASS.orange, + unhealthy: TONE_TEXT_CLASS.red, + unknown: 'text-theme-text-tertiary', + neutral: 'text-theme-text-secondary', +} + +export function toneTextClass(tone: StatusTone): string { + return TEXT_CLASS[tone] +} + +// Unknown ranks above healthy: a combined reading never looks calmer than a +// part that could not be read. +const TONE_RANK: Record = { healthy: 0, neutral: 0, unknown: 1, degraded: 2, alert: 3, unhealthy: 4 } + +/** The more severe of two tones. */ +export function worseTone(a: StatusTone, b: StatusTone): StatusTone { + return TONE_RANK[b] > TONE_RANK[a] ? b : a +} + // Normalize the variety of severity / health vocabularies that flow in // from APIs (Problems, Audit, multi-cluster aggregation endpoints) onto // a single tone. Inputs are case-insensitive. Returns 'unknown' for diff --git a/packages/k8s-ui/src/components/cnpg/primitives.test.tsx b/packages/k8s-ui/src/components/workspace/facts.test.tsx similarity index 94% rename from packages/k8s-ui/src/components/cnpg/primitives.test.tsx rename to packages/k8s-ui/src/components/workspace/facts.test.tsx index 87ed2a49aa..a560d293f2 100644 --- a/packages/k8s-ui/src/components/cnpg/primitives.test.tsx +++ b/packages/k8s-ui/src/components/workspace/facts.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' -import { FactValue } from './primitives' +import { FactValue } from './facts' describe('FactValue', () => { const twoDaysAgo = new Date(Date.now() - 2 * 24 * 3600 * 1000 - 60_000).toISOString() diff --git a/packages/k8s-ui/src/components/workspace/facts.tsx b/packages/k8s-ui/src/components/workspace/facts.tsx new file mode 100644 index 0000000000..30c7bfcf94 --- /dev/null +++ b/packages/k8s-ui/src/components/workspace/facts.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { clsx } from 'clsx' +import type { HealthLevel } from '../resources/resource-utils' +import { formatAge } from '../resources/resource-utils' +import { toneTextClass } from '../ui/status-tone' +import { Tooltip } from '../ui/Tooltip' + +/** + * One observed value and where it came from. A value the cluster does not + * report is a fact too: its text says so and its tone is `unknown`, never a + * zero or a calm default. + */ +export interface Fact { + text: string + tone: HealthLevel + /** Where the value comes from, shown next to it so claims carry their source. */ + source?: string + /** A timestamp the text refers to; the UI renders it as an age. */ + at?: string + /** `since`: `at` is when a still-current state began, rendered "Failing for 2d" rather than "· 2d ago". */ + atMeaning?: 'since' + /** The full explanation behind a short `source`, shown on hover only. */ + detail?: string +} + +export function FactValue({ fact, className }: { fact: Fact; className?: string }) { + const age = fact.at ? formatAge(fact.at) : null + const body = ( + + {fact.text} + {age && fact.atMeaning === 'since' && for {age}} + {age && fact.atMeaning !== 'since' && {fact.text ? ' · ' : ''}{age} ago} + + ) + if (!fact.source && !fact.at && !fact.detail) return body + return ( + + {body} + + ) +} + +export function FactSource({ fact }: { fact: Fact }) { + if (!fact.source) return null + return
{fact.source}
+} + +/** Label/value rows. Empty values stay visible: an unread value is shown as unread, not hidden. */ +export function FactGrid({ children }: { children: ReactNode }) { + return
{children}
+} + +export function FactRow({ label, children }: { label: ReactNode; children: ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ) +} diff --git a/packages/k8s-ui/src/components/workspace/index.ts b/packages/k8s-ui/src/components/workspace/index.ts new file mode 100644 index 0000000000..344b64a688 --- /dev/null +++ b/packages/k8s-ui/src/components/workspace/index.ts @@ -0,0 +1,5 @@ +// Building blocks for workspace integrations: several related CRDs with a +// composed UI (see docs/INTEGRATION_GUIDE.md, "Workspace integrations"). +export * from './facts' +export * from './sections' +export * from './problems' diff --git a/packages/k8s-ui/src/components/workspace/problems.test.tsx b/packages/k8s-ui/src/components/workspace/problems.test.tsx new file mode 100644 index 0000000000..3ee59efc6c --- /dev/null +++ b/packages/k8s-ui/src/components/workspace/problems.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { ProblemMeta, problemOriginLabel, type WorkspaceProblem } from './problems' + +const problem = (kind: string): WorkspaceProblem => ({ + id: 'p', + severity: 'warning', + category: 'availability', + title: 'Something needs a look', + subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' }, + source: 'issue', +}) + +describe('ProblemMeta', () => { + it('names the subject only when it is not the workspace root kind', () => { + expect(renderToStaticMarkup()).toContain('Backup') + expect(renderToStaticMarkup()).not.toContain('child-1') + }) +}) + +describe('problemOriginLabel', () => { + it('says who measured a measurement, and never calls an issue generic', () => { + expect(problemOriginLabel({ ...problem('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus') + expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar') + }) +}) diff --git a/packages/k8s-ui/src/components/workspace/problems.tsx b/packages/k8s-ui/src/components/workspace/problems.tsx new file mode 100644 index 0000000000..21ca4cdb1a --- /dev/null +++ b/packages/k8s-ui/src/components/workspace/problems.tsx @@ -0,0 +1,175 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { clsx } from 'clsx' +import type { HealthLevel } from '../resources/resource-utils' +import { StatusDot, toneTextClass } from '../ui/status-tone' +import { Tooltip } from '../ui/Tooltip' +import { AlertBanner } from '../ui/drawer-components' +import { RefLink, type NavigateToRef } from '../ui/RefLink' + +/** Where a problem's evidence comes from, in user terms. */ +export interface ProblemOrigin { + label: string + /** The exact field or condition, shown on hover. */ + detail?: string +} + +/** + * Something about a workspace object that needs a look. `C` is the + * integration's own category set. + */ +export interface WorkspaceProblem { + /** Stable identity for keys. */ + id: string + severity: 'critical' | 'warning' | 'posture' + category: C + title: string + detail?: string + /** The object the evidence is about: the workspace's root object or one of its children. */ + subject: { kind: string; group: string; namespace: string; name: string } + /** + * issue: the Issues engine. audit: a best-practice check. measurement: + * derived from a reading only callers holding its grants receive. + */ + source: 'issue' | 'audit' | 'measurement' + /** What took the measurement, e.g. "Prometheus" (shown as "Measured by Prometheus"). */ + measuredBy?: string + /** The measurement's series were matched to the subject by name only (see `measuredBy`). */ + unverifiedMatch?: boolean + /** How it was measured (queries, metric names), shown on hover over the source. */ + sourceDetail?: string + /** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */ + shortTitle?: string + /** Where an issue's evidence comes from. */ + origin?: ProblemOrigin + /** Other objects the same problem is about, e.g. earlier runs that failed the same way. */ + alsoAbout?: { kind: string; name: string }[] +} + +export const PROBLEM_TONE: Record = { + critical: 'unhealthy', + warning: 'degraded', + posture: 'neutral', +} + +const PROBLEM_VARIANT: Record = { + critical: 'error', + warning: 'warning', + posture: 'info', +} + +/** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */ +export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin { + switch (problem.source) { + case 'audit': + return { label: 'Best-practice check', detail: problem.sourceDetail } + case 'measurement': + return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail } + } + return problem.origin ?? { label: 'Detected by Radar' } +} + +/** + * How a host opens a problem on its Issues page. Supplied by context so every + * summary and drawer gets the link without threading a prop through each. + */ +export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined) + +export function ProblemMeta({ + problem, + rootKind, + onNavigate, + subjectIsSelf, + children, +}: { + problem: WorkspaceProblem + /** The workspace's root kind: a problem about another kind names its subject. */ + rootKind: string + onNavigate?: NavigateToRef + subjectIsSelf?: boolean + children?: ReactNode +}) { + const openIssue = useContext(OpenIssueContext) + const origin = problemOriginLabel(problem) + const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind + return ( +
+ {aboutChild && ( + + {problem.subject.kind}{' '} + + {problem.alsoAbout && problem.alsoAbout.length > 0 && ' '} + {problem.alsoAbout && problem.alsoAbout.length > 0 && ( + + {problem.alsoAbout.map((o) => ( +
  • + {o.kind} {o.name} +
  • + ))} + + } + > + and {problem.alsoAbout.length} more +
    + )} +
    + )} + + {origin.label} + + {openIssue && problem.source === 'issue' && ( + + )} + {children} +
    + ) +} + +export function ProblemCallout({ + problem, + rootKind, + more, + onNavigate, + action, + subjectIsSelf, +}: { + problem: WorkspaceProblem + rootKind: string + more?: ReactNode + onNavigate?: NavigateToRef + action?: ReactNode + /** The callout sits on the subject's own page, so linking to it would loop. */ + subjectIsSelf?: boolean +}) { + return ( + + + {action} + {more} + + + ) +} + +/** The problems a callout does not show, as a compact list with the callout's tone, title and source. */ +export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) { + return ( +
      + {problems.map((p) => ( +
    • + + + +
      +
      {p.title}
      + {p.detail &&
      {p.detail}
      } + +
      +
    • + ))} +
    + ) +} diff --git a/packages/k8s-ui/src/components/workspace/sections.tsx b/packages/k8s-ui/src/components/workspace/sections.tsx new file mode 100644 index 0000000000..eb2539ee3c --- /dev/null +++ b/packages/k8s-ui/src/components/workspace/sections.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { clsx } from 'clsx' +import { toneTextClass } from '../ui/status-tone' +import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse' + +/** A folded section's one-line summary, and whether it opens on its own. */ +export interface FoldSummary { + text: string + /** Something in the section needs a look: it opens itself. */ + attention: boolean +} + +export function SectionHeading({ children, hint }: { children: ReactNode; hint?: ReactNode }) { + return ( +
    +

    {children}

    + {hint && {hint}} +
    + ) +} + +/** + * A section folded to one summary line. It opens itself when `attention` + * turns true (data arriving after the first render included), and stays as + * the reader left it otherwise. + */ +export function FoldSection({ + title, + hint, + summary, + attention, + children, +}: { + title: ReactNode + hint?: ReactNode + summary: ReactNode + attention: boolean + children: ReactNode +}) { + const [open, setOpen] = useState(attention) + useEffect(() => { + if (attention) setOpen(true) + }, [attention]) + const d = useDisclosure(open) + return ( +
    + + +
    {children}
    +
    +
    + ) +} diff --git a/packages/k8s-ui/src/index.ts b/packages/k8s-ui/src/index.ts index 7354c75027..156b4453af 100644 --- a/packages/k8s-ui/src/index.ts +++ b/packages/k8s-ui/src/index.ts @@ -55,7 +55,10 @@ export * from './components/checks' // queue) export * from './components/issues' -// CloudNativePG workspace model (fleet derivation over /api/cnpg/workspace) +// Workspace building blocks: facts, sections and problems shared by multi-CRD integrations +export * from './components/workspace' + +// CloudNativePG workspace model (fleet derivation over /api/cnpg/workspace) and summaries export * from './components/cnpg' // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub) diff --git a/packages/k8s-ui/src/theme/components.css b/packages/k8s-ui/src/theme/components.css index da31dc1ff6..4468656c94 100644 --- a/packages/k8s-ui/src/theme/components.css +++ b/packages/k8s-ui/src/theme/components.css @@ -35,6 +35,21 @@ opacity: 0.5; } + /* Bordered surface button for secondary actions next to a .btn-brand. */ + .btn-secondary { + border: 1px solid var(--border-default); + background-color: var(--bg-surface); + color: var(--text-primary); + border-radius: 0.625rem; + transition: background-color 0.15s ease; + } + .btn-secondary:hover { + background-color: var(--bg-hover); + } + .btn-secondary:disabled { + opacity: 0.5; + } + .btn-brand-toggle { background-color: color-mix(in srgb, var(--color-brand) 50%, transparent); color: var(--text-primary); diff --git a/web/src/components/cnpg/CNPGDetailPage.tsx b/web/src/components/cnpg/CNPGDetailPage.tsx index 29748aff55..f04dcb907d 100644 --- a/web/src/components/cnpg/CNPGDetailPage.tsx +++ b/web/src/components/cnpg/CNPGDetailPage.tsx @@ -242,7 +242,7 @@ function NotInContext({ diff --git a/web/src/components/cnpg/CNPGOverview.tsx b/web/src/components/cnpg/CNPGOverview.tsx index 25dfcda4b2..d0f04ea6a7 100644 --- a/web/src/components/cnpg/CNPGOverview.tsx +++ b/web/src/components/cnpg/CNPGOverview.tsx @@ -4,7 +4,7 @@ import { clsx } from 'clsx' import { ArrowRight, Database, FileText, Search } from 'lucide-react' import { CNPG_PROBLEM_CATEGORIES, - CNPG_PROBLEM_TONE, + PROBLEM_TONE, cnpgReadyInstances, FactValue, StatusDot, @@ -112,7 +112,7 @@ function AttentionCell({ row, onOpenAll }: { row: CNPGFleetRow; onOpenAll: () => return (
    -
    {headline}
    +
    {headline}
    {top.unverifiedMatch && (
    measured by {top.measuredBy}
    @@ -124,7 +124,7 @@ function AttentionCell({ row, onOpenAll }: { row: CNPGFleetRow; onOpenAll: () => {others.map((p) => (
  • - + {problemTip(p)}
  • diff --git a/web/src/components/cnpg/CNPGStorage.tsx b/web/src/components/cnpg/CNPGStorage.tsx index 9f7be11cec..9c39e1c3d5 100644 --- a/web/src/components/cnpg/CNPGStorage.tsx +++ b/web/src/components/cnpg/CNPGStorage.tsx @@ -463,7 +463,7 @@ function ExpansionCard({ data, volumes, onResize }: { data: CNPGClusterStorageRe diff --git a/web/src/components/cnpg/CNPGSummaryHost.tsx b/web/src/components/cnpg/CNPGSummaryHost.tsx index 6f817362ba..2c41135364 100644 --- a/web/src/components/cnpg/CNPGSummaryHost.tsx +++ b/web/src/components/cnpg/CNPGSummaryHost.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { CNPG_BARMAN_OBJECTSTORE_GROUP, - CNPGOpenIssueContext, + OpenIssueContext, CNPG_GROUP, CNPGBackupSummary, CNPGClusterSummary, @@ -26,8 +26,8 @@ import { PaneLoader, isApiGroup, refToSelectedResource, - type CNPGNavigate, - type CNPGRef, + type NavigateToRef, + type ResourceRef, type CNPGLogicalPath, type CNPGWorkspaceResponse, type NavigateToResource, @@ -101,7 +101,7 @@ function ClusterSummaryHost({ namespace, name, context, onNavigate }: SummaryCon
    ) } - const goRef = onNavigate ? (ref: CNPGRef) => onNavigate(refToSelectedResource(ref)) : undefined + const goRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined // On the Cluster's own page this is a tab change, applied like a tab click; // anywhere else (the drawer, another page) it is a push to the full page // with a return label. @@ -189,9 +189,9 @@ function ClusterSummaryHost({ namespace, name, context, onNavigate }: SummaryCon ) } -type ObjectSummary = (props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: CNPGNavigate }) => ReactNode +type ObjectSummary = (props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: NavigateToRef }) => ReactNode -function ScheduledBackupSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: CNPGNavigate }) { +function ScheduledBackupSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: NavigateToRef }) { const ns = props.resource?.metadata?.namespace ?? '' const name = props.resource?.metadata?.name ?? '' const caps = useCNPGScheduleCapabilities(ns, name) @@ -217,14 +217,14 @@ function LogicalPathSlot({ path, children }: { path: CNPGLogicalPath; children: return <>{children(cnpgLogicalSlotFact(path, observed), )} } -function SubscriptionSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: CNPGNavigate }) { +function SubscriptionSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: NavigateToRef }) { const lw = useLogicalWorkspace(props.workspace, [props.resource]) const path = props.workspace ? cnpgLogicalPaths([props.resource], lw.clusters, lw.publications, lw.poolers, lw.publicationsUnavailable)[0] : undefined if (!path) return return {(slot, notice) => } } -function PublicationSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: CNPGNavigate }) { +function PublicationSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: NavigateToRef }) { // Subscribers are the Subscriptions in view; the publisher's own runtime // answers for every slot. const pubCluster = props.resource?.spec?.cluster?.name @@ -261,7 +261,7 @@ function ObjectSummaryHost({ ctx, Summary }: { ctx: SummaryContext; Summary: Obj const { query } = useCNPGFleet(clusterScoped ? [] : [ctx.namespace]) if (query.isLoading) return const workspace = query.data?.installed ? query.data : null - const go = ctx.onNavigate ? (ref: CNPGRef) => ctx.onNavigate?.(refToSelectedResource(ref)) : undefined + const go = ctx.onNavigate ? (ref: ResourceRef) => ctx.onNavigate?.(refToSelectedResource(ref)) : undefined return } @@ -270,7 +270,7 @@ function PoolerSummaryHost({ ctx }: { ctx: SummaryContext }) { const { live, queries } = useCNPGPoolerLive(ctx.namespace, ctx.name) if (query.isLoading) return const workspace = query.data?.installed ? query.data : null - const go = ctx.onNavigate ? (ref: CNPGRef) => ctx.onNavigate?.(refToSelectedResource(ref)) : undefined + const go = ctx.onNavigate ? (ref: ResourceRef) => ctx.onNavigate?.(refToSelectedResource(ref)) : undefined return } /> } @@ -313,12 +313,12 @@ function IssueLinks({ children }: { children: ReactNode }) { const [searchParams] = useSearchParams() const issuesTakenOver = !!useNavCustomization().fleetTakeoverHref?.('issues') return ( - navigate(cnpgIssuesPath(p.subject, searchParams.get('namespaces'))) } > {children} - + ) } diff --git a/web/src/components/cnpg/actions/CNPGClusterActions.tsx b/web/src/components/cnpg/actions/CNPGClusterActions.tsx index 4b39cbe7f4..96e3dc84c0 100644 --- a/web/src/components/cnpg/actions/CNPGClusterActions.tsx +++ b/web/src/components/cnpg/actions/CNPGClusterActions.tsx @@ -108,7 +108,7 @@ export function CNPGClusterActions({ namespace, name, compact = false }: { names type="button" disabled={!actions?.backup.allowed} onClick={() => setOpen('backup')} - className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50" + className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed" > {!compact && 'Back up now'} @@ -120,7 +120,7 @@ export function CNPGClusterActions({ namespace, name, compact = false }: { names type="button" disabled={!actions?.switchover.allowed} onClick={() => setOpen('switchover')} - className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50" + className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed" > Switchover @@ -133,7 +133,7 @@ export function CNPGClusterActions({ namespace, name, compact = false }: { names aria-expanded={menu} aria-label="More cluster actions" onClick={() => setMenu((v) => !v)} - className="inline-flex items-center gap-0.5 rounded-lg border border-theme-border bg-theme-surface px-2 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover" + className="btn-secondary inline-flex items-center gap-0.5 px-2 py-1.5 text-xs" > diff --git a/web/src/components/cnpg/actions/CNPGConnectButton.tsx b/web/src/components/cnpg/actions/CNPGConnectButton.tsx index 2dca892d35..4eb7c0346a 100644 --- a/web/src/components/cnpg/actions/CNPGConnectButton.tsx +++ b/web/src/components/cnpg/actions/CNPGConnectButton.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useMemo, useState } from 'react' import { useLocation, useSearchParams } from 'react-router-dom' import { Plug, X } from 'lucide-react' -import { CNPGConnectSection, DialogPortal, Tooltip, type CNPGRef, type NavigateToResource } from '@skyhook-io/k8s-ui' +import { CNPGConnectSection, DialogPortal, Tooltip, type ResourceRef, type NavigateToResource } from '@skyhook-io/k8s-ui' import { refToSelectedResource } from '../../../utils/navigation' import { useCNPGFleet } from '../useCNPGSidebarWorkspace' @@ -70,7 +70,7 @@ export function CNPGConnectButton({ const { fleet } = useCNPGFleet([namespace], open) const row = fleet?.rows.find((r) => r.namespace === namespace && r.name === name) const go = onNavigate - ? (ref: CNPGRef) => { + ? (ref: ResourceRef) => { setOpen(false) onNavigate(refToSelectedResource(ref)) } @@ -83,7 +83,7 @@ export function CNPGConnectButton({ type="button" onClick={() => setOpen(true)} aria-label={compact ? 'Connect' : undefined} - className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover" + className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium" > {!compact && 'Connect'} diff --git a/web/src/components/cnpg/actions/CNPGPoolerActions.tsx b/web/src/components/cnpg/actions/CNPGPoolerActions.tsx index f14eed0b11..9c4a235ed4 100644 --- a/web/src/components/cnpg/actions/CNPGPoolerActions.tsx +++ b/web/src/components/cnpg/actions/CNPGPoolerActions.tsx @@ -7,7 +7,7 @@ import { useToast } from '../../ui/Toast' import { useCNPGWriteGuard } from './useCNPGWriteGuard' const BUTTON = - 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50' + 'btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed' /** Pause or resume a Pooler's PgBouncers (spec.pgbouncer.paused). */ export function CNPGPoolerActions({ namespace, name }: { namespace: string; name: string }) { diff --git a/web/src/components/cnpg/actions/CNPGScheduleActions.tsx b/web/src/components/cnpg/actions/CNPGScheduleActions.tsx index 2c23d72a83..bd1a8058dc 100644 --- a/web/src/components/cnpg/actions/CNPGScheduleActions.tsx +++ b/web/src/components/cnpg/actions/CNPGScheduleActions.tsx @@ -13,7 +13,7 @@ import { cnpgOperatorActionNote } from '../operatorStatus' import { trackCNPGOperation } from '../operations/store' const BUTTON = - 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50' + 'btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed' /** Suspend, resume, run a ScheduledBackup's settings once, or change its schedule. */ export function CNPGScheduleActions({ namespace, name }: { namespace: string; name: string }) { diff --git a/web/src/components/cnpg/baseBackup.ts b/web/src/components/cnpg/baseBackup.ts index 682d50ae09..a039019c64 100644 --- a/web/src/components/cnpg/baseBackup.ts +++ b/web/src/components/cnpg/baseBackup.ts @@ -1,4 +1,4 @@ -import type { CNPGFact } from '@skyhook-io/k8s-ui' +import type { Fact } from '@skyhook-io/k8s-ui' import type { CNPGRuntimeBaseBackup, CNPGRuntimeResponse } from '../../api/cnpg' import { formatBytes } from './lsn' @@ -18,7 +18,7 @@ export function describeCNPGBaseBackup(bb: CNPGRuntimeBaseBackup): string { * The primary's running base backups. Undefined when the primary's report was * not read, so the Overview omits the fact rather than claiming none. */ -export function cnpgBaseBackupFacts(rt: CNPGRuntimeResponse | undefined): { fact: CNPGFact; rows: CNPGRuntimeBaseBackup[] } | undefined { +export function cnpgBaseBackupFacts(rt: CNPGRuntimeResponse | undefined): { fact: Fact; rows: CNPGRuntimeBaseBackup[] } | undefined { if (!rt || rt.permission.proxy === 'denied') return undefined const primary = rt.instances.find((i) => i.role === 'primary') if (!primary || (primary.status.state !== 'ok' && primary.status.state !== 'partial')) return undefined diff --git a/web/src/components/cnpg/operations/CNPGOperationTracker.tsx b/web/src/components/cnpg/operations/CNPGOperationTracker.tsx index f1d87ea655..2ebc7c7e6f 100644 --- a/web/src/components/cnpg/operations/CNPGOperationTracker.tsx +++ b/web/src/components/cnpg/operations/CNPGOperationTracker.tsx @@ -155,7 +155,7 @@ export function CNPGOperationTracker({ namespace, name }: { namespace: string; n aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen((v) => !v)} - className="inline-flex max-w-[22rem] items-center gap-1.5 rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover" + className="btn-secondary inline-flex max-w-[22rem] items-center gap-1.5 px-2.5 py-1.5 text-xs" > {lead.label} diff --git a/web/src/components/cnpg/recovery/CNPGRestoreButton.tsx b/web/src/components/cnpg/recovery/CNPGRestoreButton.tsx index ba9ba9b987..c788254a31 100644 --- a/web/src/components/cnpg/recovery/CNPGRestoreButton.tsx +++ b/web/src/components/cnpg/recovery/CNPGRestoreButton.tsx @@ -14,7 +14,7 @@ export function CNPGRestoreButton({ namespace, entry, disabledReason, compact }: type="button" disabled={!!disabledReason} onClick={() => setOpen(true)} - className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-theme-border bg-theme-surface px-2.5 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50" + className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed" > {!compact && label} diff --git a/web/src/components/cnpg/runtimeModel.ts b/web/src/components/cnpg/runtimeModel.ts index e3b06cab94..ecd8e11006 100644 --- a/web/src/components/cnpg/runtimeModel.ts +++ b/web/src/components/cnpg/runtimeModel.ts @@ -1,4 +1,4 @@ -import { cnpgLagTone, cnpgWorseTone, type HealthLevel } from '@skyhook-io/k8s-ui' +import { cnpgLagTone, worseTone, type HealthLevel } from '@skyhook-io/k8s-ui' import type { CNPGRuntimeInstance, CNPGRuntimeReplication } from '../../api/cnpg' import type { CNPGSessionsResponse } from '../../api/cnpg-sessions' import { cnpgConnectionFigure } from './blocking' @@ -70,7 +70,7 @@ const CNPG_BACKLOG_UNHEALTHY = 1024 * 1024 * 1024 export function cnpgStandbyBacklogTone(bytes: number | undefined, replayLag: number | undefined): HealthLevel { if (bytes === undefined) return 'unknown' const byBytes: HealthLevel = bytes >= CNPG_BACKLOG_UNHEALTHY ? 'unhealthy' : bytes >= CNPG_BACKLOG_DEGRADED ? 'degraded' : 'healthy' - return replayLag === undefined ? byBytes : cnpgWorseTone(byBytes, cnpgLagTone(replayLag)) + return replayLag === undefined ? byBytes : worseTone(byBytes, cnpgLagTone(replayLag)) } export interface CNPGStandbyHeadline { @@ -94,7 +94,7 @@ export function cnpgStandbyHeadline( const streaming = rep ? [rep.state, rep.syncState].filter(Boolean).join(' · ') : undefined if (inst.status.roleDetail === 'replayPaused' || inst.status.replayPaused) { const secondary = streaming ?? (ctx.primaryRead && !ctx.fenced ? 'not connected to the primary' : undefined) - return { text: 'replay paused', tone: cnpgWorseTone('degraded', backlogTone), secondary } + return { text: 'replay paused', tone: worseTone('degraded', backlogTone), secondary } } if (streaming !== undefined) return { text: streaming, tone: backlogTone } // The fence asks the operator to stop PostgreSQL; only the instance manager From 78d35ba9efc314390f880fcc256e86130805bd12 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 4 Oct 2026 00:52:08 +0300 Subject: [PATCH 02/26] Move Prometheus series-scope isolation out of the CNPG history file The cluster-identity scope that keeps a query to this cluster's series already serves PVC usage and the single-claim chart, so it moves to series_scope.go under neutral names: SeriesIsolation, ErrScopeAmbiguous, ErrScopeMismatch, ClaimSelectors. JSON tags and decisions are unchanged. --- internal/prometheus/cnpg_history.go | 174 +--------------------- internal/prometheus/cnpg_history_test.go | 10 +- internal/prometheus/pvc_usage.go | 12 +- internal/prometheus/series_scope.go | 181 +++++++++++++++++++++++ internal/server/cnpg_history.go | 48 +++--- internal/server/cnpg_history_test.go | 4 +- internal/server/cnpg_storage.go | 30 ++-- internal/server/cnpg_storage_test.go | 2 +- 8 files changed, 240 insertions(+), 221 deletions(-) create mode 100644 internal/prometheus/series_scope.go diff --git a/internal/prometheus/cnpg_history.go b/internal/prometheus/cnpg_history.go index 1604dc6e18..c17b3375fa 100644 --- a/internal/prometheus/cnpg_history.go +++ b/internal/prometheus/cnpg_history.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/skyhook-io/radar/internal/k8s" "github.com/skyhook-io/radar/pkg/prom" ) @@ -32,27 +31,10 @@ const ( CNPGHistoryStateNotRead = "notRead" CNPGHistoryStateAmbiguous = "ambiguous" - CNPGIsolationConfigured = "configured" - CNPGIsolationVerified = "verified" - CNPGIsolationUnverified = "unverified" - cnpgHistoryMaxSeries = 12 cnpgHistoryConcurrency = 4 ) -// ErrCNPGScopeAmbiguous means the selected series exist under more than one -// cluster identity in this Prometheus, so any answer could mix clusters. -var ErrCNPGScopeAmbiguous = errors.New("cnpg metrics: the selected series appear under more than one cluster identity") - -// ErrCNPGScopeMismatch means the verified cluster identity labels select none -// of the probed series although unscoped ones exist. -var ErrCNPGScopeMismatch = errors.New("cnpg metrics: cluster identity labels do not appear on the selected series") - -type cnpgQuerier interface { - Query(ctx context.Context, query string) (*prom.QueryResult, error) - QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*prom.QueryResult, error) -} - // CNPGHistoryRange is one of the ranges the history endpoint accepts. Steps // keep every chart at 60–144 points. type CNPGHistoryRange struct { @@ -81,13 +63,6 @@ func ParseCNPGHistoryRange(raw string) (CNPGHistoryRange, bool) { return CNPGHistoryRange{}, false } -// CNPGIsolation says how the queries keep to this Kubernetes cluster's series. -type CNPGIsolation struct { - Mode string `json:"mode"` - Labels map[string]string `json:"labels,omitempty"` - Note string `json:"note"` -} - // CNPGInstanceSelector is the matcher set for a Cluster's instance series. func CNPGInstanceSelector(namespace, cluster string) string { return CNPGInstancesSelector(namespace, []string{cluster}) @@ -120,149 +95,12 @@ func CNPGClusterOfPod(pod string, clusters map[string]bool) string { return m[1] } -func withScope(selector, matchers string) string { - if matchers == "" { - return selector - } - return selector + "," + matchers -} - -// scopeProbe names the series whose identity labels decide a scope: one -// selector per batch and, for a chart over a range, the window every identity -// is collected over (an instant check would miss one that stopped reporting -// minutes ago but still fills the chart). -type scopeProbe struct { - metric string - // key is the label one Kubernetes object's series share (pod, claim). - key string - selectors []string - window time.Duration -} - -func (p scopeProbe) over(sel string) string { - if p.window <= 0 { - return p.metric + "{" + sel + "}" - } - return "count_over_time(" + p.metric + "{" + sel + "}[" + p.window.String() + "])" -} - // ResolveCNPGScope decides the cluster-identity matchers for one namespace's // CNPG exporter series over window (0 for an instant read). -func ResolveCNPGScope(ctx context.Context, namespace, selector string, anchors []prom.WorkloadPodIdentity, window time.Duration) (string, CNPGIsolation, error) { +func ResolveCNPGScope(ctx context.Context, namespace, selector string, anchors []prom.WorkloadPodIdentity, window time.Duration) (string, SeriesIsolation, error) { return resolveScope(ctx, namespace, scopeProbe{metric: "cnpg_collector_up", key: "pod", selectors: []string{selector}, window: window}, anchors, nil) } -// ResolvePVCScope decides the cluster-identity matchers for the named claims' -// kubelet volume stats over window (0 for an instant read). -func ResolvePVCScope(ctx context.Context, namespace string, claims []string, anchors []prom.WorkloadPodIdentity, window time.Duration) (string, CNPGIsolation, error) { - m, iso, err := resolveScope(ctx, namespace, pvcScopeProbe(namespace, claims, window), anchors, nil) - return m, iso.forClaims(), err -} - -// forClaims words an unverified match for volume stats, which are matched by -// claim name rather than Pod name. -func (iso CNPGIsolation) forClaims() CNPGIsolation { - if iso.Mode == CNPGIsolationUnverified { - iso.Note = "Matched by namespace and claim names. Radar couldn't confirm these volume stats belong to this exact cluster (no cluster label it could check)" - } - return iso -} - -func pvcScopeProbe(namespace string, claims []string, window time.Duration) scopeProbe { - return scopeProbe{metric: "kubelet_volume_stats_capacity_bytes", key: "persistentvolumeclaim", selectors: CNPGClaimSelectors(namespace, claims), window: window} -} - -// CNPGClaimSelectors selects the named claims of one namespace, in batches -// that keep each regex matcher small. -func CNPGClaimSelectors(namespace string, claims []string) []string { - names := make([]string, len(claims)) - for i, c := range claims { - names[i] = regexp.QuoteMeta(c) - } - sort.Strings(names) - var out []string - for start := 0; start < len(names); start += pvcUsageBatchSize { - end := min(start+pvcUsageBatchSize, len(names)) - out = append(out, "namespace="+strconv.Quote(namespace)+",persistentvolumeclaim=~"+strconv.Quote(strings.Join(names[start:end], "|"))) - } - return out -} - -// resolveScope applies an operator-configured scope first, then identity -// labels proven by kube-state-metrics Pod UIDs. Only those two may add -// matchers: labels merely seen on the series are not identity (the CNPG -// exporter's own `cluster` label is the database cluster's name). -// With no anchors, a cache lets the proof use the namespace's current Pods. -func resolveScope(ctx context.Context, namespace string, probe scopeProbe, anchors []prom.WorkloadPodIdentity, cache *k8s.ResourceCache) (string, CNPGIsolation, error) { - client := GetClient() - if client == nil { - return "", CNPGIsolation{}, errors.New("Prometheus client not initialized") - } - if config, _, configured := client.workloadMetricsConfig(); configured { - m, err := config.Matchers() - if err != nil { - return "", CNPGIsolation{}, err - } - iso := CNPGIsolation{Mode: CNPGIsolationConfigured, Labels: config.ClusterLabels, Note: "Matched by the cluster labels an operator configured"} - if config.SingleCluster { - iso.Note = "An operator declared this Prometheus single-cluster" - } - return m, iso, nil - } - var verified map[string]string - if len(anchors) > 0 || cache != nil { - if cfg, err := client.historicalClusterScope(ctx, PodScope{Namespace: namespace, Identities: anchors}, cache); err == nil { - verified = cfg.ClusterLabels - } - } - return decideScope(ctx, client, probe, verified) -} - -func decideScope(ctx context.Context, q cnpgQuerier, probe scopeProbe, verified map[string]string) (string, CNPGIsolation, error) { - if len(verified) > 0 { - m, err := prom.WorkloadMetricsScope{ClusterLabels: verified}.Matchers() - if err != nil { - return "", CNPGIsolation{}, err - } - for _, sel := range probe.selectors { - scoped, err := q.Query(ctx, "count("+probe.over(withScope(sel, m))+")") - if err != nil { - return "", CNPGIsolation{}, err - } - if len(scoped.Series) > 0 { - return m, cnpgVerifiedIsolation(verified), nil - } - } - for _, sel := range probe.selectors { - all, err := q.Query(ctx, "count("+probe.over(sel)+")") - if err != nil { - return "", CNPGIsolation{}, err - } - if len(all.Series) > 0 { - return "", CNPGIsolation{}, ErrCNPGScopeMismatch - } - } - return m, cnpgVerifiedIsolation(verified), nil - } - // Unproven: nothing is pinned. One object (Pod, claim) whose series carry - // more than one set of partition labels over the range may be two - // clusters' objects of the same name, so that is refused. - for _, sel := range probe.selectors { - res, err := q.Query(ctx, "max(count by ("+probe.key+") (count by ("+probe.key+","+strings.Join(partitionLabels, ",")+") ("+probe.over(sel)+")))") - if err != nil { - return "", CNPGIsolation{}, err - } - if len(res.Series) > 0 && len(res.Series[0].DataPoints) > 0 && res.Series[0].DataPoints[0].Value > 1 { - return "", CNPGIsolation{}, ErrCNPGScopeAmbiguous - } - } - return "", CNPGIsolation{Mode: CNPGIsolationUnverified, Note: "Matched by namespace and Pod names. Radar couldn't confirm these series belong to this exact cluster (no cluster label it could check)"}, nil -} - -func cnpgVerifiedIsolation(labels map[string]string) CNPGIsolation { - return CNPGIsolation{Mode: CNPGIsolationVerified, Labels: labels, Note: "Matched by cluster labels confirmed against this cluster's Pods"} -} - // CNPGHistoryThreshold is a reference line on a chart. type CNPGHistoryThreshold struct { Value float64 `json:"value"` @@ -432,7 +270,7 @@ func QueryCNPGHistory(ctx context.Context, req CNPGHistoryRequest) ([]CNPGHistor return queryCNPGHistory(ctx, client, req), nil } -func queryCNPGHistory(ctx context.Context, q cnpgQuerier, req CNPGHistoryRequest) []CNPGHistoryChart { +func queryCNPGHistory(ctx context.Context, q seriesQuerier, req CNPGHistoryRequest) []CNPGHistoryChart { sel := withScope(CNPGInstanceSelector(req.Namespace, req.Cluster), req.Matchers) pvcSel := "" if len(req.Claims) > 0 { @@ -483,7 +321,7 @@ func queryCNPGHistory(ctx context.Context, q cnpgQuerier, req CNPGHistoryRequest return charts } -func runCNPGHistoryChart(ctx context.Context, q cnpgQuerier, d cnpgHistoryDef, start, end time.Time, step time.Duration, c *CNPGHistoryChart) { +func runCNPGHistoryChart(ctx context.Context, q seriesQuerier, d cnpgHistoryDef, start, end time.Time, step time.Duration, c *CNPGHistoryChart) { series := []prom.Series{} for _, query := range d.queries { res, err := q.QueryRange(ctx, query.expr, start, end, step) @@ -574,7 +412,7 @@ func QueryCNPGFleetLag(ctx context.Context, namespace string, clusters []string, // A Pod scraped but not in recovery answers -1 through the `or` branch, so one // query carries both lag and presence. -func queryCNPGFleetLag(ctx context.Context, q cnpgQuerier, namespace string, clusters []string, matchers string) (CNPGFleetLag, error) { +func queryCNPGFleetLag(ctx context.Context, q seriesQuerier, namespace string, clusters []string, matchers string) (CNPGFleetLag, error) { sel := withScope(CNPGInstancesSelector(namespace, clusters), matchers) query := "(max by (pod) (cnpg_pg_replication_lag{" + sel + "}) and on (pod) (max by (pod) (cnpg_pg_replication_in_recovery{" + sel + "}) == 1)) or (-1 * count by (pod) (cnpg_collector_up{" + sel + "}))" res, err := q.Query(ctx, query) @@ -610,7 +448,7 @@ func queryCNPGFleetLag(ctx context.Context, q cnpgQuerier, namespace string, clu // querySustainedCNPGLag is best effort: without it the fleet still shows the // current lag, it just raises no sustained-lag problem. -func querySustainedCNPGLag(ctx context.Context, q cnpgQuerier, sel string, known map[string]bool) map[string]CNPGLagReading { +func querySustainedCNPGLag(ctx context.Context, q seriesQuerier, sel string, known map[string]bool) map[string]CNPGLagReading { // Exact on Prometheus 2.x and 3.x alike: every raw sample in the window is // at least the reported floor, and the series already existed when the // window began (it answers at offset 10m), so a standby that appeared a @@ -653,7 +491,7 @@ func QueryCNPGDiskGrowth(ctx context.Context, namespace string, claims []string, return queryCNPGDiskGrowth(ctx, client, namespace, claims, window, matchers) } -func queryCNPGDiskGrowth(ctx context.Context, q cnpgQuerier, namespace string, claims []string, window time.Duration, matchers string) (map[string]float64, error) { +func queryCNPGDiskGrowth(ctx context.Context, q seriesQuerier, namespace string, claims []string, window time.Duration, matchers string) (map[string]float64, error) { out := map[string]float64{} if len(claims) == 0 { return out, nil diff --git a/internal/prometheus/cnpg_history_test.go b/internal/prometheus/cnpg_history_test.go index aec56b8068..f2c2daaafc 100644 --- a/internal/prometheus/cnpg_history_test.go +++ b/internal/prometheus/cnpg_history_test.go @@ -83,14 +83,14 @@ func TestDecideScopeRefusesAmbiguousIdentity(t *testing.T) { q := &fakeCNPGQuerier{instant: func(string) (*prom.QueryResult, error) { return &prom.QueryResult{Series: []prom.Series{vec(nil, 2)}}, nil }} - if _, _, err := decideScope(context.Background(), q, cnpgProbe(0), nil); !errors.Is(err, ErrCNPGScopeAmbiguous) { + if _, _, err := decideScope(context.Background(), q, cnpgProbe(0), nil); !errors.Is(err, ErrScopeAmbiguous) { t.Fatalf("err = %v, want ambiguous", err) } q.instant = func(string) (*prom.QueryResult, error) { return &prom.QueryResult{Series: []prom.Series{vec(nil, 1)}}, nil } m, iso, err := decideScope(context.Background(), q, cnpgProbe(0), nil) - if err != nil || m != "" || iso.Mode != CNPGIsolationUnverified { + if err != nil || m != "" || iso.Mode != SeriesIsolationUnverified { t.Fatalf("single identity: m=%q iso=%+v err=%v", m, iso, err) } } @@ -105,7 +105,7 @@ func TestDecideScopeChecksIdentitiesOverTheWholeRange(t *testing.T) { } return &prom.QueryResult{Series: []prom.Series{vec(nil, v)}}, nil }} - if _, _, err := decideScope(context.Background(), q, cnpgProbe(time.Hour), nil); !errors.Is(err, ErrCNPGScopeAmbiguous) { + if _, _, err := decideScope(context.Background(), q, cnpgProbe(time.Hour), nil); !errors.Is(err, ErrScopeAmbiguous) { t.Fatalf("err = %v, want ambiguous over the range; queries %v", err, q.queries) } } @@ -151,14 +151,14 @@ func TestDecideScopeVerifiedLabelsMustReachProbedSeries(t *testing.T) { } return &prom.QueryResult{Series: []prom.Series{vec(nil, 3)}}, nil }} - if _, _, err := decideScope(context.Background(), q, cnpgProbe(0), map[string]string{"k8s_cluster_name": "east"}); !errors.Is(err, ErrCNPGScopeMismatch) { + if _, _, err := decideScope(context.Background(), q, cnpgProbe(0), map[string]string{"k8s_cluster_name": "east"}); !errors.Is(err, ErrScopeMismatch) { t.Fatalf("err = %v, want mismatch", err) } q.instant = func(string) (*prom.QueryResult, error) { return &prom.QueryResult{Series: []prom.Series{vec(nil, 3)}}, nil } m, iso, err := decideScope(context.Background(), q, cnpgProbe(0), map[string]string{"k8s_cluster_name": "east"}) - if err != nil || m != `k8s_cluster_name="east"` || iso.Mode != CNPGIsolationVerified { + if err != nil || m != `k8s_cluster_name="east"` || iso.Mode != SeriesIsolationVerified { t.Fatalf("verified: m=%q iso=%+v err=%v", m, iso, err) } } diff --git a/internal/prometheus/pvc_usage.go b/internal/prometheus/pvc_usage.go index 93388af2cc..19aa5a8672 100644 --- a/internal/prometheus/pvc_usage.go +++ b/internal/prometheus/pvc_usage.go @@ -37,7 +37,7 @@ type PVCUsageBatch struct { Usage map[string]PVCUsage Invalid map[string]bool // Isolation says how the series were tied to this cluster. - Isolation CNPGIsolation + Isolation SeriesIsolation } // Claims per query: keeps the regex matcher and the answer small whatever the @@ -76,9 +76,9 @@ func pvcScopeFailure(err error) (PVCUsageBatch, bool) { switch { case err == nil: return out, false - case errors.Is(err, ErrCNPGScopeAmbiguous): + case errors.Is(err, ErrScopeAmbiguous): out.Status, out.Error = PVCUsageAmbiguous, "these claim names have volume stats under more than one cluster identity in this Prometheus" - case errors.Is(err, ErrCNPGScopeMismatch): + case errors.Is(err, ErrScopeMismatch): out.Status, out.Error = PVCUsageScopeMismatch, "the cluster identity proven for this cluster does not appear on these claims' volume stats" default: out.Status, out.Error = PVCUsageQueryFailed, err.Error() @@ -86,9 +86,9 @@ func pvcScopeFailure(err error) (PVCUsageBatch, bool) { return out, true } -func queryPVCUsage(ctx context.Context, q cnpgQuerier, namespace string, claims []string, matchers string) PVCUsageBatch { +func queryPVCUsage(ctx context.Context, q seriesQuerier, namespace string, claims []string, matchers string) PVCUsageBatch { out := PVCUsageBatch{Status: PVCUsageAvailable, Usage: map[string]PVCUsage{}, Invalid: map[string]bool{}} - for _, sel := range CNPGClaimSelectors(namespace, claims) { + for _, sel := range ClaimSelectors(namespace, claims) { if err := queryPVCUsageBatch(ctx, q, withScope(sel, matchers), &out); err != nil { return PVCUsageBatch{Status: PVCUsageQueryFailed, Error: err.Error(), Usage: map[string]PVCUsage{}, Invalid: map[string]bool{}} } @@ -96,7 +96,7 @@ func queryPVCUsage(ctx context.Context, q cnpgQuerier, namespace string, claims return out } -func queryPVCUsageBatch(ctx context.Context, q cnpgQuerier, selector string, out *PVCUsageBatch) error { +func queryPVCUsageBatch(ctx context.Context, q seriesQuerier, selector string, out *PVCUsageBatch) error { used, err := q.Query(ctx, fmt.Sprintf(`max by (persistentvolumeclaim) (kubelet_volume_stats_used_bytes{%s})`, selector)) if err != nil { return err diff --git a/internal/prometheus/series_scope.go b/internal/prometheus/series_scope.go new file mode 100644 index 0000000000..8186c04f5c --- /dev/null +++ b/internal/prometheus/series_scope.go @@ -0,0 +1,181 @@ +package prometheus + +import ( + "context" + "errors" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/skyhook-io/radar/internal/k8s" + "github.com/skyhook-io/radar/pkg/prom" +) + +// A series scope keeps a query to this Kubernetes cluster's series in a +// Prometheus that may hold several clusters' series under the same namespace +// and object names. + +const ( + SeriesIsolationConfigured = "configured" + SeriesIsolationVerified = "verified" + SeriesIsolationUnverified = "unverified" +) + +// ErrScopeAmbiguous means the selected series exist under more than one +// cluster identity in this Prometheus, so any answer could mix clusters. +var ErrScopeAmbiguous = errors.New("prometheus series scope: the selected series appear under more than one cluster identity") + +// ErrScopeMismatch means the verified cluster identity labels select none +// of the probed series although unscoped ones exist. +var ErrScopeMismatch = errors.New("prometheus series scope: cluster identity labels do not appear on the selected series") + +type seriesQuerier interface { + Query(ctx context.Context, query string) (*prom.QueryResult, error) + QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*prom.QueryResult, error) +} + +// SeriesIsolation says how the queries keep to this Kubernetes cluster's series. +type SeriesIsolation struct { + Mode string `json:"mode"` + Labels map[string]string `json:"labels,omitempty"` + Note string `json:"note"` +} + +func withScope(selector, matchers string) string { + if matchers == "" { + return selector + } + return selector + "," + matchers +} + +// scopeProbe names the series whose identity labels decide a scope: one +// selector per batch and, for a chart over a range, the window every identity +// is collected over (an instant check would miss one that stopped reporting +// minutes ago but still fills the chart). +type scopeProbe struct { + metric string + // key is the label one Kubernetes object's series share (pod, claim). + key string + selectors []string + window time.Duration +} + +func (p scopeProbe) over(sel string) string { + if p.window <= 0 { + return p.metric + "{" + sel + "}" + } + return "count_over_time(" + p.metric + "{" + sel + "}[" + p.window.String() + "])" +} + +// ResolvePVCScope decides the cluster-identity matchers for the named claims' +// kubelet volume stats over window (0 for an instant read). +func ResolvePVCScope(ctx context.Context, namespace string, claims []string, anchors []prom.WorkloadPodIdentity, window time.Duration) (string, SeriesIsolation, error) { + m, iso, err := resolveScope(ctx, namespace, pvcScopeProbe(namespace, claims, window), anchors, nil) + return m, iso.forClaims(), err +} + +// forClaims words an unverified match for volume stats, which are matched by +// claim name rather than Pod name. +func (iso SeriesIsolation) forClaims() SeriesIsolation { + if iso.Mode == SeriesIsolationUnverified { + iso.Note = "Matched by namespace and claim names. Radar couldn't confirm these volume stats belong to this exact cluster (no cluster label it could check)" + } + return iso +} + +func pvcScopeProbe(namespace string, claims []string, window time.Duration) scopeProbe { + return scopeProbe{metric: "kubelet_volume_stats_capacity_bytes", key: "persistentvolumeclaim", selectors: ClaimSelectors(namespace, claims), window: window} +} + +// ClaimSelectors selects the named claims of one namespace, in batches that +// keep each regex matcher small. +func ClaimSelectors(namespace string, claims []string) []string { + names := make([]string, len(claims)) + for i, c := range claims { + names[i] = regexp.QuoteMeta(c) + } + sort.Strings(names) + var out []string + for start := 0; start < len(names); start += pvcUsageBatchSize { + end := min(start+pvcUsageBatchSize, len(names)) + out = append(out, "namespace="+strconv.Quote(namespace)+",persistentvolumeclaim=~"+strconv.Quote(strings.Join(names[start:end], "|"))) + } + return out +} + +// resolveScope applies an operator-configured scope first, then identity +// labels proven by kube-state-metrics Pod UIDs. Only those two may add +// matchers: labels merely seen on the series are not identity (the CNPG +// exporter's own `cluster` label is the database cluster's name). +// With no anchors, a cache lets the proof use the namespace's current Pods. +func resolveScope(ctx context.Context, namespace string, probe scopeProbe, anchors []prom.WorkloadPodIdentity, cache *k8s.ResourceCache) (string, SeriesIsolation, error) { + client := GetClient() + if client == nil { + return "", SeriesIsolation{}, errors.New("Prometheus client not initialized") + } + if config, _, configured := client.workloadMetricsConfig(); configured { + m, err := config.Matchers() + if err != nil { + return "", SeriesIsolation{}, err + } + iso := SeriesIsolation{Mode: SeriesIsolationConfigured, Labels: config.ClusterLabels, Note: "Matched by the cluster labels an operator configured"} + if config.SingleCluster { + iso.Note = "An operator declared this Prometheus single-cluster" + } + return m, iso, nil + } + var verified map[string]string + if len(anchors) > 0 || cache != nil { + if cfg, err := client.historicalClusterScope(ctx, PodScope{Namespace: namespace, Identities: anchors}, cache); err == nil { + verified = cfg.ClusterLabels + } + } + return decideScope(ctx, client, probe, verified) +} + +func decideScope(ctx context.Context, q seriesQuerier, probe scopeProbe, verified map[string]string) (string, SeriesIsolation, error) { + if len(verified) > 0 { + m, err := prom.WorkloadMetricsScope{ClusterLabels: verified}.Matchers() + if err != nil { + return "", SeriesIsolation{}, err + } + for _, sel := range probe.selectors { + scoped, err := q.Query(ctx, "count("+probe.over(withScope(sel, m))+")") + if err != nil { + return "", SeriesIsolation{}, err + } + if len(scoped.Series) > 0 { + return m, verifiedIsolation(verified), nil + } + } + for _, sel := range probe.selectors { + all, err := q.Query(ctx, "count("+probe.over(sel)+")") + if err != nil { + return "", SeriesIsolation{}, err + } + if len(all.Series) > 0 { + return "", SeriesIsolation{}, ErrScopeMismatch + } + } + return m, verifiedIsolation(verified), nil + } + // Unproven: nothing is pinned. One object (Pod, claim) whose series carry + // more than one set of partition labels over the range may be two + // clusters' objects of the same name, so that is refused. + for _, sel := range probe.selectors { + res, err := q.Query(ctx, "max(count by ("+probe.key+") (count by ("+probe.key+","+strings.Join(partitionLabels, ",")+") ("+probe.over(sel)+")))") + if err != nil { + return "", SeriesIsolation{}, err + } + if len(res.Series) > 0 && len(res.Series[0].DataPoints) > 0 && res.Series[0].DataPoints[0].Value > 1 { + return "", SeriesIsolation{}, ErrScopeAmbiguous + } + } + return "", SeriesIsolation{Mode: SeriesIsolationUnverified, Note: "Matched by namespace and Pod names. Radar couldn't confirm these series belong to this exact cluster (no cluster label it could check)"}, nil +} + +func verifiedIsolation(labels map[string]string) SeriesIsolation { + return SeriesIsolation{Mode: SeriesIsolationVerified, Labels: labels, Note: "Matched by cluster labels confirmed against this cluster's Pods"} +} diff --git a/internal/server/cnpg_history.go b/internal/server/cnpg_history.go index 941ba2f28f..3c06fde97c 100644 --- a/internal/server/cnpg_history.go +++ b/internal/server/cnpg_history.go @@ -44,18 +44,18 @@ var cnpgHistoryMemoTTL = 15 * time.Second // in-browser samples. State describes the query as a whole; each chart // carries its own state when the whole succeeded. type CNPGClusterHistoryResponse struct { - Cluster CNPGRuntimeObjectRef `json:"cluster"` - Source string `json:"source"` - State string `json:"state,omitempty"` - Reason string `json:"reason,omitempty"` - Range string `json:"range"` - Start string `json:"start,omitempty"` - End string `json:"end,omitempty"` - StepSeconds int `json:"stepSeconds,omitempty"` - Selector string `json:"selector,omitempty"` - Isolation *prometheuspkg.CNPGIsolation `json:"isolation,omitempty"` + Cluster CNPGRuntimeObjectRef `json:"cluster"` + Source string `json:"source"` + State string `json:"state,omitempty"` + Reason string `json:"reason,omitempty"` + Range string `json:"range"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + StepSeconds int `json:"stepSeconds,omitempty"` + Selector string `json:"selector,omitempty"` + Isolation *prometheuspkg.SeriesIsolation `json:"isolation,omitempty"` // PVCIsolation is the volume chart's own: claims are matched apart from Pods. - PVCIsolation *prometheuspkg.CNPGIsolation `json:"pvcIsolation,omitempty"` + PVCIsolation *prometheuspkg.SeriesIsolation `json:"pvcIsolation,omitempty"` SampledAt string `json:"sampledAt"` Charts []prometheuspkg.CNPGHistoryChart `json:"charts"` } @@ -217,9 +217,9 @@ func cnpgNoPrometheusReason(msg string) string { func cnpgHistoryScopeFailure(err error) (string, string) { switch { - case errors.Is(err, prometheuspkg.ErrCNPGScopeAmbiguous): + case errors.Is(err, prometheuspkg.ErrScopeAmbiguous): return cnpgHistoryStateAmbiguous, "This Prometheus holds series for these Pod names under more than one cluster identity, so history could mix clusters. An operator can configure the cluster identity labels Radar should require." - case errors.Is(err, prometheuspkg.ErrCNPGScopeMismatch): + case errors.Is(err, prometheuspkg.ErrScopeMismatch): return cnpgHistoryStateScopeMismatch, "The cluster identity labels proven for this cluster do not appear on the CNPG exporter series, so Radar cannot tell this cluster's history from another's." } return cnpgHistoryStateError, "Prometheus query failed: " + truncateCNPGRuntimeError(err.Error()) @@ -228,9 +228,9 @@ func cnpgHistoryScopeFailure(err error) (string, string) { // cnpgUsageScopeFailure is cnpgHistoryScopeFailure for kubelet volume stats. func cnpgUsageScopeFailure(err error) (string, string) { switch { - case errors.Is(err, prometheuspkg.ErrCNPGScopeAmbiguous): + case errors.Is(err, prometheuspkg.ErrScopeAmbiguous): return cnpgHistoryStateAmbiguous, "This Prometheus holds volume stats for these claim names under more than one cluster identity, so a value could be another cluster's. An operator can configure the cluster identity labels Radar should require." - case errors.Is(err, prometheuspkg.ErrCNPGScopeMismatch): + case errors.Is(err, prometheuspkg.ErrScopeMismatch): return cnpgHistoryStateScopeMismatch, "The cluster identity labels proven for this cluster do not appear on these claims' volume stats, so Radar cannot tell this cluster's volumes from another's." } return cnpgHistoryStateError, "Prometheus query failed: " + truncateCNPGRuntimeError(err.Error()) @@ -293,19 +293,19 @@ type CNPGFleetLag struct { SustainedPod string `json:"sustainedPod,omitempty"` SustainedWindow string `json:"sustainedWindow,omitempty"` // Isolation says how the series were tied to this cluster. - Isolation *prometheuspkg.CNPGIsolation `json:"isolation,omitempty"` + Isolation *prometheuspkg.SeriesIsolation `json:"isolation,omitempty"` } // CNPGFleetGrowth State: ok (BytesPerHour of the fastest-growing claim), // noSeries, denied, unavailable, error or notRead. type CNPGFleetGrowth struct { - State string `json:"state"` - Grant string `json:"grant,omitempty"` - Reason string `json:"reason,omitempty"` - BytesPerHour *float64 `json:"bytesPerHour,omitempty"` - Claim string `json:"claim,omitempty"` - Instance string `json:"instance,omitempty"` - Isolation *prometheuspkg.CNPGIsolation `json:"isolation,omitempty"` + State string `json:"state"` + Grant string `json:"grant,omitempty"` + Reason string `json:"reason,omitempty"` + BytesPerHour *float64 `json:"bytesPerHour,omitempty"` + Claim string `json:"claim,omitempty"` + Instance string `json:"instance,omitempty"` + Isolation *prometheuspkg.SeriesIsolation `json:"isolation,omitempty"` } func (s *Server) handleCNPGFleetMetrics(w http.ResponseWriter, r *http.Request) { @@ -413,7 +413,7 @@ func (s *Server) cnpgNamespaceFleetMetrics(r *http.Request, cache *k8s.ResourceC claimsByCluster, growthCov := s.cnpgFleetClaims(r, cache, namespace, clusters) matchers, scopeErr := "", error(nil) - var lagIso prometheuspkg.CNPGIsolation + var lagIso prometheuspkg.SeriesIsolation if podsAllowed { matchers, lagIso, scopeErr = prometheuspkg.ResolveCNPGScope(ctx, namespace, prometheuspkg.CNPGInstancesSelector(namespace, names), anchors, prometheuspkg.CNPGSustainedLagWindow) } diff --git a/internal/server/cnpg_history_test.go b/internal/server/cnpg_history_test.go index 6bf8ce9a59..84a93806b5 100644 --- a/internal/server/cnpg_history_test.go +++ b/internal/server/cnpg_history_test.go @@ -98,11 +98,11 @@ func TestCNPGClusterHistory_ChartsFromPrometheus(t *testing.T) { if status != http.StatusOK { t.Fatalf("status = %d: %s", status, body) } - if got.Source != cnpgHistorySourcePrometheus || got.State != cnpgHistoryStateOK || got.StepSeconds != 30 || got.Isolation == nil || got.Isolation.Mode != prometheuspkg.CNPGIsolationUnverified { + if got.Source != cnpgHistorySourcePrometheus || got.State != cnpgHistoryStateOK || got.StepSeconds != 30 || got.Isolation == nil || got.Isolation.Mode != prometheuspkg.SeriesIsolationUnverified { t.Fatalf("got %+v", got) } // The volume chart's claims are matched apart from the instance Pods, and say so. - if got.PVCIsolation == nil || got.PVCIsolation.Mode != prometheuspkg.CNPGIsolationUnverified || !strings.Contains(got.PVCIsolation.Note, "claim names") { + if got.PVCIsolation == nil || got.PVCIsolation.Mode != prometheuspkg.SeriesIsolationUnverified || !strings.Contains(got.PVCIsolation.Note, "claim names") { t.Errorf("pvcIsolation = %+v", got.PVCIsolation) } by := map[string]prometheuspkg.CNPGHistoryChart{} diff --git a/internal/server/cnpg_storage.go b/internal/server/cnpg_storage.go index 389d099e26..f99b00d007 100644 --- a/internal/server/cnpg_storage.go +++ b/internal/server/cnpg_storage.go @@ -64,7 +64,7 @@ type CNPGStorageCoverage struct { Grant string `json:"grant,omitempty"` Reason string `json:"reason,omitempty"` // Isolation, on usage read from Prometheus: how the series were tied to this cluster. - Isolation *prometheuspkg.CNPGIsolation `json:"isolation,omitempty"` + Isolation *prometheuspkg.SeriesIsolation `json:"isolation,omitempty"` } // CNPGClusterStorageResponse is GET /api/cnpg/clusters/{namespace}/{name}/storage. @@ -507,10 +507,10 @@ func (s *Server) cnpgClaimUsage(r *http.Request, namespace string, claims []stri case prometheuspkg.PVCUsageQueryFailed: return CNPGStorageCoverage{State: cnpgUsageStateError, Reason: "Prometheus query failed: " + truncateCNPGRuntimeError(batch.Error)}, batch case prometheuspkg.PVCUsageAmbiguous: - _, reason := cnpgUsageScopeFailure(prometheuspkg.ErrCNPGScopeAmbiguous) + _, reason := cnpgUsageScopeFailure(prometheuspkg.ErrScopeAmbiguous) return CNPGStorageCoverage{State: cnpgHistoryStateAmbiguous, Reason: reason}, batch case prometheuspkg.PVCUsageScopeMismatch: - _, reason := cnpgUsageScopeFailure(prometheuspkg.ErrCNPGScopeMismatch) + _, reason := cnpgUsageScopeFailure(prometheuspkg.ErrScopeMismatch) return CNPGStorageCoverage{State: cnpgHistoryStateScopeMismatch, Reason: reason}, batch } iso := &batch.Isolation @@ -525,8 +525,8 @@ func (s *Server) cnpgClaimUsage(r *http.Request, namespace string, claims []stri // cnpgUnverifiedCaveat qualifies a measurement stated as this cluster's when // its series were matched by name alone. -func cnpgUnverifiedCaveat(iso *prometheuspkg.CNPGIsolation) string { - if iso == nil || iso.Mode != prometheuspkg.CNPGIsolationUnverified { +func cnpgUnverifiedCaveat(iso *prometheuspkg.SeriesIsolation) string { + if iso == nil || iso.Mode != prometheuspkg.SeriesIsolationUnverified { return "" } return ". " + iso.Note @@ -562,7 +562,7 @@ func cnpgVolumeLabel(role, tablespace string) string { // cnpgDiskFindings reports volumes whose measured use crosses the thresholds. // Only a measurement can raise one; an unmeasured volume says nothing. -func cnpgDiskFindings(instance string, volumes []CNPGStorageVolume, iso *prometheuspkg.CNPGIsolation) []CNPGStorageFinding { +func cnpgDiskFindings(instance string, volumes []CNPGStorageVolume, iso *prometheuspkg.SeriesIsolation) []CNPGStorageFinding { var out []CNPGStorageFinding for _, v := range volumes { if v.Usage.Ratio == nil || *v.Usage.Ratio < cnpgDiskWarningRatio { @@ -763,15 +763,15 @@ type CNPGFleetDiskResponse struct { // noPrometheus, denied (Grant names what is missing), unavailable, error, or // notRead (the request's namespace bound was reached). type CNPGClusterDisk struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - State string `json:"state"` - Grant string `json:"grant,omitempty"` - Reason string `json:"reason,omitempty"` - Claims int `json:"claims"` - Measured int `json:"measured"` - Max *CNPGDiskUsage `json:"max,omitempty"` - Isolation *prometheuspkg.CNPGIsolation `json:"isolation,omitempty"` + Namespace string `json:"namespace"` + Name string `json:"name"` + State string `json:"state"` + Grant string `json:"grant,omitempty"` + Reason string `json:"reason,omitempty"` + Claims int `json:"claims"` + Measured int `json:"measured"` + Max *CNPGDiskUsage `json:"max,omitempty"` + Isolation *prometheuspkg.SeriesIsolation `json:"isolation,omitempty"` } type CNPGDiskUsage struct { diff --git a/internal/server/cnpg_storage_test.go b/internal/server/cnpg_storage_test.go index f3588f49b7..705d3066de 100644 --- a/internal/server/cnpg_storage_test.go +++ b/internal/server/cnpg_storage_test.go @@ -435,7 +435,7 @@ func TestCNPGDiskFindingsThresholds(t *testing.T) { if got[1].Message != "The tablespace archive volume of pg-1 is 90% full" { t.Errorf("message = %q", got[1].Message) } - unverified := cnpgDiskFindings("pg-1", vols, &prometheuspkg.CNPGIsolation{Mode: prometheuspkg.CNPGIsolationUnverified, Note: "Radar couldn't confirm these volume stats belong to this exact cluster"}) + unverified := cnpgDiskFindings("pg-1", vols, &prometheuspkg.SeriesIsolation{Mode: prometheuspkg.SeriesIsolationUnverified, Note: "Radar couldn't confirm these volume stats belong to this exact cluster"}) if !strings.Contains(unverified[1].Message, "couldn't confirm these volume stats belong to this exact cluster") { t.Errorf("an unverified match must say so: %q", unverified[1].Message) } From 10eea3802ef41b70a2e3e00fd0a8b164f8b2da40 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 4 Oct 2026 00:56:58 +0300 Subject: [PATCH 03/26] Name the informer cache-scope intersection for what it is namespacesWithinCache, cacheCoversNamespace and the informerScope interface move from capacity_auth.go to cache_scope.go: Capacity and CloudNativePG both use them to tell what Radar's informer holds from what the caller asked for. Behaviour, including nil (all namespaces) versus empty, is unchanged. --- internal/server/cache_scope.go | 58 +++++++++++++++++++++++++++ internal/server/capacity.go | 4 +- internal/server/capacity_auth.go | 50 ----------------------- internal/server/capacity_auth_test.go | 8 ++-- internal/server/capacity_groups.go | 4 +- internal/server/cnpg_cluster_ha.go | 4 +- internal/server/cnpg_operator.go | 2 +- internal/server/cnpg_storage.go | 2 +- internal/server/cnpg_workspace.go | 2 +- 9 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 internal/server/cache_scope.go diff --git a/internal/server/cache_scope.go b/internal/server/cache_scope.go new file mode 100644 index 0000000000..5f14b63391 --- /dev/null +++ b/internal/server/cache_scope.go @@ -0,0 +1,58 @@ +package server + +import "slices" + +// informerScope is the part of Radar's informer cache that says which +// namespaces it holds for a resource. +type informerScope interface { + IsKindClusterWide(string) bool + KindNamespaces(string) []string + IsKindReady(string) bool +} + +type cacheNamespaceResult struct { + namespaces []string + limited bool + partial bool + unavailable bool +} + +// namespacesWithinCache narrows requested (nil = all namespaces) to what the +// informer for resource holds. limited: the informer is not cluster-wide; +// partial: some requested namespaces are not held; unavailable: none are. +func namespacesWithinCache(cache informerScope, resource string, requested []string) cacheNamespaceResult { + result := cacheNamespaceResult{namespaces: requested} + if cache == nil || cache.IsKindClusterWide(resource) { + return result + } + result.limited = true + if noNamespaceAccess(requested) { + return result + } + cached := cache.KindNamespaces(resource) + if len(cached) == 0 { + result.namespaces = []string{} + result.unavailable = true + return result + } + result.namespaces = intersectNamespaces(cached, requested) + if requested == nil { + result.partial = true + return result + } + for _, namespace := range requested { + if !slices.Contains(cached, namespace) { + if len(result.namespaces) == 0 { + result.unavailable = true + } else { + result.partial = true + } + return result + } + } + return result +} + +func cacheCoversNamespace(cache informerScope, resource, namespace string) bool { + return cache != nil && cache.IsKindReady(resource) && (cache.IsKindClusterWide(resource) || slices.Contains(cache.KindNamespaces(resource), namespace)) +} diff --git a/internal/server/capacity.go b/internal/server/capacity.go index 735a7b06ae..f79b99f3b3 100644 --- a/internal/server/capacity.go +++ b/internal/server/capacity.go @@ -441,7 +441,7 @@ func (s *Server) loadCapacityModel(w http.ResponseWriter, r *http.Request, ident result.meta.Provider = capacityProvider(result.meta.Provider, nodePools, nodeClaims, nodeClasses, result.meta.Coverage) resourceCache := k8s.GetResourceCache() ownerResolutionAllowed, workloadAttributionPartial := capacityOwnerResolutionPermissions(pods, func(group, resource, namespace string) bool { - return s.canRead(r, group, resource, namespace, "list") && capacityCacheCoversNamespace(resourceCache, resource, namespace) + return s.canRead(r, group, resource, namespace, "list") && cacheCoversNamespace(resourceCache, resource, namespace) }) if workloadAttributionPartial { coverage := result.meta.Coverage[capacityapi.CoverageWorkloads] @@ -712,7 +712,7 @@ func (s *Server) loadCapacityPods(r *http.Request, meta *capacityapi.ResponseMet return nil } sourceNamespaces := namespaces - cacheNamespaces := capacityNamespacesWithinCache(cache, "pods", sourceNamespaces) + cacheNamespaces := namespacesWithinCache(cache, "pods", sourceNamespaces) namespaces = cacheNamespaces.namespaces if cacheNamespaces.unavailable { coverage := unavailableCoverage("pod_cache_scope_unavailable", []string{"scheduledRequests", "aggregateDemand", "workloads", "summary.actions", "demand.summary"}) diff --git a/internal/server/capacity_auth.go b/internal/server/capacity_auth.go index e2b4619f0e..40ff2bfbfc 100644 --- a/internal/server/capacity_auth.go +++ b/internal/server/capacity_auth.go @@ -37,53 +37,3 @@ func (s *Server) capacityNamespacesForSource(r *http.Request, namespaces []strin } return s.filterNamespacesByCanRead(r, group, resource, "list", namespaces) } - -type capacityInformerScope interface { - IsKindClusterWide(string) bool - KindNamespaces(string) []string - IsKindReady(string) bool -} - -type capacityCacheNamespaceResult struct { - namespaces []string - limited bool - partial bool - unavailable bool -} - -func capacityNamespacesWithinCache(cache capacityInformerScope, resource string, requested []string) capacityCacheNamespaceResult { - result := capacityCacheNamespaceResult{namespaces: requested} - if cache == nil || cache.IsKindClusterWide(resource) { - return result - } - result.limited = true - if noNamespaceAccess(requested) { - return result - } - cached := cache.KindNamespaces(resource) - if len(cached) == 0 { - result.namespaces = []string{} - result.unavailable = true - return result - } - result.namespaces = intersectNamespaces(cached, requested) - if requested == nil { - result.partial = true - return result - } - for _, namespace := range requested { - if !slices.Contains(cached, namespace) { - if len(result.namespaces) == 0 { - result.unavailable = true - } else { - result.partial = true - } - return result - } - } - return result -} - -func capacityCacheCoversNamespace(cache capacityInformerScope, resource, namespace string) bool { - return cache != nil && cache.IsKindReady(resource) && (cache.IsKindClusterWide(resource) || slices.Contains(cache.KindNamespaces(resource), namespace)) -} diff --git a/internal/server/capacity_auth_test.go b/internal/server/capacity_auth_test.go index ac548f06f0..414211b198 100644 --- a/internal/server/capacity_auth_test.go +++ b/internal/server/capacity_auth_test.go @@ -99,7 +99,7 @@ func TestCapacityNamespacesHonorInformerCoverage(t *testing.T) { limited := capacityTestInformerScope{namespaces: map[string][]string{"pods": {"team-a", "team-b"}}} tests := []struct { name string - cache capacityInformerScope + cache informerScope requested []string want []string wantLimited bool @@ -115,7 +115,7 @@ func TestCapacityNamespacesHonorInformerCoverage(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got := capacityNamespacesWithinCache(test.cache, "pods", test.requested) + got := namespacesWithinCache(test.cache, "pods", test.requested) if !slices.Equal(got.namespaces, test.want) || got.limited != test.wantLimited || got.partial != test.wantPartial || got.unavailable != test.wantUnavailable { t.Fatalf("cache namespaces = %#v, want namespaces=%v limited=%v partial=%v unavailable=%v", got, test.want, test.wantLimited, test.wantPartial, test.wantUnavailable) } @@ -132,13 +132,13 @@ func TestCapacityOwnerResolutionHonorsInformerCoverage(t *testing.T) { "jobs": {"team-b"}, }} permissions, partial := capacityOwnerResolutionPermissions([]*corev1.Pod{replicaPod, jobPod}, func(_ string, resource, namespace string) bool { - return capacityCacheCoversNamespace(cache, resource, namespace) + return cacheCoversNamespace(cache, resource, namespace) }) if permissions[replicaPod] || !permissions[jobPod] || !partial { t.Fatalf("owner permissions = %#v partial=%v, want ReplicaSet denied and Job allowed", permissions, partial) } cache.notReady = map[string]bool{"jobs": true} - if capacityCacheCoversNamespace(cache, "jobs", "team-b") { + if cacheCoversNamespace(cache, "jobs", "team-b") { t.Fatal("owner resolution used an informer that has not synced") } } diff --git a/internal/server/capacity_groups.go b/internal/server/capacity_groups.go index 7c97d70b5e..2ab315bbfd 100644 --- a/internal/server/capacity_groups.go +++ b/internal/server/capacity_groups.go @@ -21,7 +21,7 @@ const ( // needs: informer scope plus the ConfigMap lister. Narrowed to an interface so // the coverage-verdict/detection pairing is testable without an informer. type capacityConfigMapSource interface { - capacityInformerScope + informerScope ConfigMaps() corelisters.ConfigMapLister } @@ -53,7 +53,7 @@ func capacityAutoscalerStatus(allowed bool, cache capacityConfigMapSource) (*aut if !allowed { return unavailable(deniedCoverage("autoscaler_status_configmap_denied", impact)) } - if cache == nil || !capacityCacheCoversNamespace(cache, "configmaps", autoscalerStatusNamespace) { + if cache == nil || !cacheCoversNamespace(cache, "configmaps", autoscalerStatusNamespace) { return unavailable(unavailableCoverage("autoscaler_status_cache_scope", impact)) } lister := cache.ConfigMaps() diff --git a/internal/server/cnpg_cluster_ha.go b/internal/server/cnpg_cluster_ha.go index 0a03a602e3..47b7dee4e0 100644 --- a/internal/server/cnpg_cluster_ha.go +++ b/internal/server/cnpg_cluster_ha.go @@ -500,7 +500,7 @@ func (s *Server) cnpgHAPDBs(r *http.Request, cache *k8s.ResourceCache, cluster * return out } lister := cache.PodDisruptionBudgets() - within := capacityNamespacesWithinCache(cache, "poddisruptionbudgets", []string{namespace}) + within := namespacesWithinCache(cache, "poddisruptionbudgets", []string{namespace}) if reason := cnpgUncachedReason("Disruption budgets", "PodDisruptionBudgets", namespace, lister == nil, within.unavailable, cache.IsKindReady("poddisruptionbudgets")); reason != "" { out.CNPGHASource = CNPGHASource{State: cnpgHAStateUnavailable, Reason: reason} return out @@ -643,7 +643,7 @@ func (s *Server) cnpgHAJobs(r *http.Request, cache *k8s.ResourceCache, cluster * return out } lister := cache.Jobs() - within := capacityNamespacesWithinCache(cache, "jobs", []string{namespace}) + within := namespacesWithinCache(cache, "jobs", []string{namespace}) if reason := cnpgUncachedReason("Instance Jobs", "Jobs", namespace, lister == nil, within.unavailable, cache.IsKindReady("jobs")); reason != "" { out.CNPGHASource = CNPGHASource{State: cnpgHAStateUnavailable, Reason: reason} return out diff --git a/internal/server/cnpg_operator.go b/internal/server/cnpg_operator.go index 803a1e06ca..911d7eb1c6 100644 --- a/internal/server/cnpg_operator.go +++ b/internal/server/cnpg_operator.go @@ -343,7 +343,7 @@ func (s *Server) cnpgOperatorConfigMap(r *http.Request, cache *k8s.ResourceCache state.Reason = "ConfigMaps are still loading" return ref } - if !capacityCacheCoversNamespace(cache, "configmaps", namespace) { + if !cacheCoversNamespace(cache, "configmaps", namespace) { state.Reason = "Radar does not watch ConfigMaps in " + namespace return ref } diff --git a/internal/server/cnpg_storage.go b/internal/server/cnpg_storage.go index f99b00d007..99b5689927 100644 --- a/internal/server/cnpg_storage.go +++ b/internal/server/cnpg_storage.go @@ -298,7 +298,7 @@ func cnpgCachedClaims(cache *k8s.ResourceCache, namespace string, selector label if lister == nil { return nil, "Radar's own identity cannot list persistentvolumeclaims" } - if within := capacityNamespacesWithinCache(cache, "persistentvolumeclaims", []string{namespace}); within.unavailable { + if within := namespacesWithinCache(cache, "persistentvolumeclaims", []string{namespace}); within.unavailable { return nil, "Radar's own identity cannot list persistentvolumeclaims in " + namespace } items, err := lister.PersistentVolumeClaims(namespace).List(selector) diff --git a/internal/server/cnpg_workspace.go b/internal/server/cnpg_workspace.go index 4cc2dddc18..5faaed28de 100644 --- a/internal/server/cnpg_workspace.go +++ b/internal/server/cnpg_workspace.go @@ -523,7 +523,7 @@ func (s *Server) cnpgTypedScope(r *http.Request, cache *k8s.ResourceCache, names if !ok { return cnpgKindAccess{state: cnpgCoverageDenied}, nil, []string{} } - within := capacityNamespacesWithinCache(cache, resource, allowed) + within := namespacesWithinCache(cache, resource, allowed) if within.unavailable { log.Printf("[cnpg] %s cache does not cover the requested scope", resource) return cnpgKindAccess{state: cnpgCoverageError}, nil, []string{} From 7a5912e2e489bdc6b8ed9d93794055dca861d764 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 4 Oct 2026 01:10:33 +0300 Subject: [PATCH 04/26] Share workspace screen primitives between Capacity and CloudNativePG web/src/components/workspace holds what both workspaces' screens are built from: Notice, ScreenEmptyState, ScreenBody, Segments, FilterChips, SectionTable, the shared table classes, text helpers and the refresh-failed notice. CloudNativePG no longer imports Capacity's internals, and keeps only its own header, gate and coverage wrappers. The drawer-trail URL codec moves to utils/drawer-trail, the back label and the subject-filtered Issues link to utils/page-links, and CloudNativePG's detail kinds take kind and group from the workspace kind table. --- web/src/App.tsx | 2 +- .../components/capacity/CapacityActivity.tsx | 34 +-- .../components/capacity/CapacityDemand.tsx | 40 +-- .../components/capacity/CapacityOverview.tsx | 47 +--- .../capacity/CapacityPoolDetail.tsx | 60 +--- .../capacity/ClusterSchedulingCard.tsx | 11 +- .../capacity/certaintyGlyph.test.tsx | 10 +- web/src/components/capacity/shared.tsx | 56 +--- .../components/cnpg/CNPGBlockingSessions.tsx | 4 +- .../components/cnpg/CNPGClusterActivity.tsx | 3 +- .../components/cnpg/CNPGClusterRuntime.tsx | 7 +- web/src/components/cnpg/CNPGDeclarations.tsx | 23 +- web/src/components/cnpg/CNPGDetailPage.tsx | 6 +- web/src/components/cnpg/CNPGDrawerTrail.tsx | 4 +- web/src/components/cnpg/CNPGOperator.tsx | 15 +- .../components/cnpg/CNPGOperatorDiagnosis.tsx | 2 +- web/src/components/cnpg/CNPGOverview.tsx | 14 +- web/src/components/cnpg/CNPGPooling.tsx | 15 +- web/src/components/cnpg/CNPGProtection.tsx | 17 +- web/src/components/cnpg/CNPGStorage.tsx | 5 +- web/src/components/cnpg/CNPGSummaryHost.tsx | 15 +- web/src/components/cnpg/CNPGTrends.tsx | 3 +- web/src/components/cnpg/CNPGView.tsx | 7 +- web/src/components/cnpg/grantText.test.tsx | 2 +- web/src/components/cnpg/paths.test.ts | 13 +- web/src/components/cnpg/paths.ts | 22 -- .../components/cnpg/refreshNotice.test.tsx | 10 +- web/src/components/cnpg/routes.test.ts | 7 +- web/src/components/cnpg/routes.ts | 69 ++--- web/src/components/cnpg/shared.tsx | 230 +--------------- web/src/components/workspace/layout.tsx | 260 ++++++++++++++++++ web/src/components/workspace/table.ts | 8 + web/src/utils/drawer-trail.ts | 35 +++ web/src/utils/page-links.ts | 21 ++ 34 files changed, 437 insertions(+), 640 deletions(-) create mode 100644 web/src/components/workspace/layout.tsx create mode 100644 web/src/components/workspace/table.ts create mode 100644 web/src/utils/drawer-trail.ts create mode 100644 web/src/utils/page-links.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index fba17fd1d6..0ba540f3e8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,7 +40,7 @@ import type { FleetTakeoverTarget } from './context/NavCustomization' import { PrimaryNavRail } from './components/nav/PrimaryNavRail' import { CNPGView } from './components/cnpg/CNPGView' import { CNPG_SCREENS, cnpgDetailKindFor, cnpgDetailPath, parseCNPGRoute } from './components/cnpg/routes' -import { currentPageLabel } from './components/cnpg/paths' +import { currentPageLabel } from './utils/page-links' import { navigateFromPrimaryRail } from './components/nav/navigation' import { useNavRailPinned } from './hooks/useNavRailPinned' import { useMediaQuery } from './hooks/useMediaQuery' diff --git a/web/src/components/capacity/CapacityActivity.tsx b/web/src/components/capacity/CapacityActivity.tsx index 49168872fb..a7e2cc7d74 100644 --- a/web/src/components/capacity/CapacityActivity.tsx +++ b/web/src/components/capacity/CapacityActivity.tsx @@ -17,37 +17,9 @@ import { useCapacityActivity, } from "../../api/client"; import type { SelectedResource } from "../../types"; -import { - ActivityStateBadge, - CapacityFreshness, - InlineEmpty, - LinkButton, - Notice, - PageControls, - PoolSelector, - ROW_HOVER, - ScopeBadges, - ScrollableContent, - TABLE_HEAD, - TABLE_WRAP, - TBODY, - TD, - TH, - activityTypeLabel, - activityWindowPreset, - coverageHasObservations, - coverageIsLowerBound, - coverageMessage, - errorMessage, - formatTimestamp, - identityKey, - identityToSelectedResource, - integrationBlock, - relativeTime, - retentionLabel, - useCapacityCursorRecovery, - useCapacityPagination, -} from "./shared"; +import { ActivityStateBadge, CapacityFreshness, InlineEmpty, LinkButton, PageControls, PoolSelector, ScopeBadges, ScrollableContent, activityTypeLabel, activityWindowPreset, coverageHasObservations, coverageIsLowerBound, coverageMessage, errorMessage, formatTimestamp, identityKey, identityToSelectedResource, integrationBlock, relativeTime, retentionLabel, useCapacityCursorRecovery, useCapacityPagination } from "./shared"; +import { Notice } from "../workspace/layout"; +import { ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from "../workspace/table"; const WINDOW_PILLS: [number | undefined, string][] = [ [undefined, "Retained"], diff --git a/web/src/components/capacity/CapacityDemand.tsx b/web/src/components/capacity/CapacityDemand.tsx index 7dfe268fdb..4e092a8b46 100644 --- a/web/src/components/capacity/CapacityDemand.tsx +++ b/web/src/components/capacity/CapacityDemand.tsx @@ -21,41 +21,9 @@ import { } from "../../api/client"; import type { SelectedResource } from "../../types"; import { refToSelectedResource } from "../../utils/navigation"; -import { - CapacityFreshness, - CapacityIssueEvidence, - DemandStateBadge, - EmptyState, - InlineEmpty, - LinkButton, - Notice, - PageControls, - PoolEvaluationBadge, - PoolSelector, - QuantityInline, - ResourceLink, - ROW_HOVER, - ScopeBadges, - ScrollableContent, - TABLE_HEAD, - TABLE_WRAP, - TBODY, - TD, - TH, - coverageHasObservations, - coverageIsLowerBound, - coverageMessage, - demandStateLabel, - errorMessage, - formatTimestamp, - humanizeCode, - identityKey, - integrationBlock, - namespaceCoverageDescription, - quantityText, - useCapacityCursorRecovery, - useCapacityPagination, -} from "./shared"; +import { CapacityFreshness, CapacityIssueEvidence, DemandStateBadge, InlineEmpty, LinkButton, PageControls, PoolEvaluationBadge, PoolSelector, QuantityInline, ResourceLink, ScopeBadges, ScrollableContent, coverageHasObservations, coverageIsLowerBound, coverageMessage, demandStateLabel, errorMessage, formatTimestamp, humanizeCode, identityKey, integrationBlock, namespaceCoverageDescription, quantityText, useCapacityCursorRecovery, useCapacityPagination } from "./shared"; +import { Notice, ScreenEmptyState } from "../workspace/layout"; +import { ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from "../workspace/table"; const STATE_PILLS: [CapacityDemandState | undefined, string][] = [ [undefined, "All states"], @@ -184,7 +152,7 @@ export function CapacityDemand({ const clearPodFilter = () => updateSearchParam("pod", undefined); if (poolFilter && !responseData && isNotFoundError(query.error)) { return ( - - -
    {children}
    - - ); -} - export function RefreshError({ message }: { message: string }) { return ( @@ -1336,31 +1327,6 @@ export function InlineEmpty({ ); } -export function EmptyState({ - icon: Icon, - title, - detail, - action, -}: { - icon: ComponentType<{ className?: string }>; - title: string; - detail: ReactNode; - action?: ReactNode; -}) { - return ( -
    -
    - -

    - {title} -

    -

    {detail}

    - {action} -
    -
    - ); -} - export function ScrollableContent({ children }: { children: ReactNode }) { // Expanding/collapsing cards can toggle the scrollbar; a stable gutter // keeps the centered column from shifting sideways when that happens. @@ -1469,7 +1435,7 @@ rules: export function DeniedCapacityState({ detail }: { detail: string }) { const [copied, setCopied] = useState(false); return ( - ); return ( - ); } - -// ============================================================================ -// Shared table cell classes (keeps every capacity table visually identical) -// ============================================================================ - -export const TABLE_WRAP = "overflow-x-auto"; -export const TABLE_HEAD = - "border-b border-theme-border bg-theme-base/60 text-[11px] uppercase tracking-wide text-theme-text-tertiary"; -export const TH = "px-3 py-2.5 text-left font-medium whitespace-nowrap"; -export const TD = "px-3 py-2.5 align-top text-sm text-theme-text-primary"; -export const TBODY = "table-divide-subtle"; -export const ROW_HOVER = "transition-colors hover:bg-theme-hover/50"; diff --git a/web/src/components/cnpg/CNPGBlockingSessions.tsx b/web/src/components/cnpg/CNPGBlockingSessions.tsx index 12f372dcde..36a66d4779 100644 --- a/web/src/components/cnpg/CNPGBlockingSessions.tsx +++ b/web/src/components/cnpg/CNPGBlockingSessions.tsx @@ -9,7 +9,7 @@ import { cnpgActionOutcomeLocked, useCNPGAction, useCNPGClusterCapabilities } fr import { useCNPGSessions, type CNPGBackend, type CNPGSessionInstance, type CNPGSessionsResponse } from '../../api/cnpg-sessions' import { useToast } from '../ui/Toast' import { buildBlockingTree, cnpgConnectionFigure, cnpgNoMetricsReadings, countVictims, type BlockingNode } from './blocking' -import { CNPGRefreshFailedNotice } from './shared' +import { RefreshFailedNotice } from '../workspace/layout' function age(s?: number): string { if (s === undefined || s === null) return '—' @@ -53,7 +53,7 @@ export function CNPGBlockingSessions({
    {!data && q.isLoading && } {!data && !q.isLoading &&
    Sessions could not be read: {q.error instanceof Error ? q.error.message : 'unknown error'}
    } - + {data && }
    diff --git a/web/src/components/cnpg/CNPGClusterActivity.tsx b/web/src/components/cnpg/CNPGClusterActivity.tsx index 6e1dd900f8..58f19bd6f3 100644 --- a/web/src/components/cnpg/CNPGClusterActivity.tsx +++ b/web/src/components/cnpg/CNPGClusterActivity.tsx @@ -3,8 +3,7 @@ import { PaneLoader, TimelineList, formatAge, type NavigateToResource } from '@s import { useCNPGClusterActivity } from '../../api/cnpg' import { useCNPGClusterActivityWindow } from '../../api/cnpg-history' import { CNPGIntervalBanner, useCNPGIntervalParams } from './CNPGTrends' -import { Notice } from '../capacity/shared' -import { Segments } from './shared' +import { Notice, Segments } from '../workspace/layout' const RANGES = [ { id: '6', label: '6 h' }, diff --git a/web/src/components/cnpg/CNPGClusterRuntime.tsx b/web/src/components/cnpg/CNPGClusterRuntime.tsx index 3262f317c9..16733fa863 100644 --- a/web/src/components/cnpg/CNPGClusterRuntime.tsx +++ b/web/src/components/cnpg/CNPGClusterRuntime.tsx @@ -6,8 +6,6 @@ import { PaneLoader, Tooltip, formatAge, toneTextClass } from '@skyhook-io/k8s-u import { useCNPGRuntime, type CNPGRuntimeInstance } from '../../api/cnpg' import { useCNPGSessions, type CNPGSessionsResponse } from '../../api/cnpg-sessions' import { useCNPGClusterHistory } from '../../api/cnpg-history' -import { Notice } from '../capacity/shared' -import { CNPGRefreshFailedNotice, Segments } from './shared' import { CNPGStorage } from './CNPGStorage' import { CNPGBlockingSessions } from './CNPGBlockingSessions' import { cnpgConnectionFigure } from './blocking' @@ -16,6 +14,7 @@ import { cnpgCheckpointView, cnpgDatabaseHealthRows, cnpgIdAge, cnpgPickedInstan import { formatBytes } from './lsn' import { historyLatest, latestRate } from './trendSamples' import { CNPGTrends, useSampleBuffer, type CNPGIntervalTarget, type Sample } from './CNPGTrends' +import { Notice, RefreshFailedNotice, Segments } from '../workspace/layout' type Section = 'replication' | 'sessions' | 'transactions' | 'storage' | 'slots' | 'trends' @@ -132,7 +131,7 @@ export function CNPGClusterRuntime({ )}
    - + {section === 'replication' && (denied ? ( @@ -307,7 +306,7 @@ function TransactionsView({ ) return (
    - + {!deniedGrant && picker} {deniedGrant ? ( <> diff --git a/web/src/components/cnpg/CNPGDeclarations.tsx b/web/src/components/cnpg/CNPGDeclarations.tsx index 4a8e890d48..2feee03057 100644 --- a/web/src/components/cnpg/CNPGDeclarations.tsx +++ b/web/src/components/cnpg/CNPGDeclarations.tsx @@ -18,22 +18,9 @@ import { } from '@skyhook-io/k8s-ui' import { useCNPGPublisherSlots } from './logicalSlots' import type { SelectedResource } from '../../types' -import { - CNPGRefreshFailedNotice, - CNPGWorkspaceHeader, - CoverageNotice, - FilterChips, - ScreenBody, - Segments, - Sub, - clusterResource, - cnpgResource, - coverageEmpty, - worstCoverage, - namespaceChip, - type CNPGScreenProps, -} from './shared' -import { sameResource } from './routes' +import { CNPGWorkspaceHeader, CoverageNotice, clusterResource, cnpgResource, coverageEmpty, worstCoverage, type CNPGScreenProps } from './shared' +import { FilterChips, namespaceChip, RefreshFailedNotice, ScreenBody, Segments, Sub } from '../workspace/layout' +import { sameSelectedResource } from '../../utils/drawer-trail' type State = 'applied' | 'failed' | 'pending' @@ -309,7 +296,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP key={i.key} item={i} sourceStated={noSources} - active={!i.isField && sameResource(inspected, i.resource)} + active={!i.isField && sameSelectedResource(inspected, i.resource)} onInspect={() => onInspect(i.resource)} /> ))} @@ -346,7 +333,7 @@ function LogicalPathRow({ path, onInspect }: { path: CNPGLogicalPath; onInspect: } + notice={} onNavigate={(ref) => onInspect(refToSelectedResource(ref))} compact /> diff --git a/web/src/components/cnpg/CNPGDetailPage.tsx b/web/src/components/cnpg/CNPGDetailPage.tsx index f04dcb907d..797df66862 100644 --- a/web/src/components/cnpg/CNPGDetailPage.tsx +++ b/web/src/components/cnpg/CNPGDetailPage.tsx @@ -7,16 +7,16 @@ import { useConnection } from '../../context/ConnectionContext' import { useContexts } from '../../api/client' import { useContextSwitchFlow } from '../useContextSwitchFlow' import { WorkloadView } from '../workload/WorkloadView' -import { EmptyState } from '../capacity/shared' import { CNPGClusterActivity } from './CNPGClusterActivity' import { CNPGClusterRuntime } from './CNPGClusterRuntime' import { CNPGProtection } from './CNPGProtection' import { CNPGRestoreValidation } from './recovery/CNPGRestoreValidation' import { CNPGScreenGate } from './shared' import { CNPG_DETAIL_KINDS, CNPG_SCREENS, cnpgDetailKindFor, cnpgDetailPath, cnpgScreenPath, type CNPGDetailTarget } from './routes' -import { currentPageLabel } from './paths' +import { currentPageLabel } from '../../utils/page-links' import { useCNPGFleet } from './useCNPGSidebarWorkspace' import { CNPGOperatorBanner } from './CNPGOperatorBanner' +import { ScreenEmptyState } from '../workspace/layout' interface ReturnState { returnLabel?: string @@ -228,7 +228,7 @@ function NotInContext({ const pinned = contexts?.find((c) => c.name === pinnedContext) return ( <> - = Object.fromEntries( Object.values(CNPG_KIND_BY_KEY).map((k) => [k.plural, k.kind]), @@ -19,7 +19,7 @@ export function CNPGDrawerTrailBack({ resource }: { resource: SelectedResource } const [searchParams, setSearchParams] = useSearchParams() if (!location.pathname.startsWith('/cnpg')) return null const trail = decodeDrawerTrail(searchParams.get('drawer')) - if (trail.length < 2 || !sameResource(trail[trail.length - 1], resource)) return null + if (trail.length < 2 || !sameSelectedResource(trail[trail.length - 1], resource)) return null const prev = trail[trail.length - 2] const back = () => { const params = new URLSearchParams(searchParams) diff --git a/web/src/components/cnpg/CNPGOperator.tsx b/web/src/components/cnpg/CNPGOperator.tsx index 19bdf2a95d..e97632ec96 100644 --- a/web/src/components/cnpg/CNPGOperator.tsx +++ b/web/src/components/cnpg/CNPGOperator.tsx @@ -1,20 +1,9 @@ import { useMemo } from 'react' import { Badge, getCNPGImageCatalogEntries, isApiGroup, PaneLoader, Tooltip } from '@skyhook-io/k8s-ui' import { useCNPGOperator, type CNPGOperatorComponent, type CNPGOperatorConfig } from '../../api/cnpg' -import { Notice } from '../capacity/shared' import { CNPGOperatorDiagnosisSection } from './CNPGOperatorDiagnosis' -import { - CNPGWorkspaceHeader, - CoverageNotice, - coverageEmpty, - worstCoverage, - Mono, - ScreenBody, - SectionTable, - Sub, - cnpgResource, - type CNPGScreenProps, -} from './shared' +import { CNPGWorkspaceHeader, CoverageNotice, coverageEmpty, worstCoverage, cnpgResource, type CNPGScreenProps } from './shared' +import { Mono, Notice, ScreenBody, SectionTable, Sub } from '../workspace/layout' interface CatalogRow { key: string diff --git a/web/src/components/cnpg/CNPGOperatorDiagnosis.tsx b/web/src/components/cnpg/CNPGOperatorDiagnosis.tsx index 3de4126fe7..266886bd10 100644 --- a/web/src/components/cnpg/CNPGOperatorDiagnosis.tsx +++ b/web/src/components/cnpg/CNPGOperatorDiagnosis.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { Badge, formatAge, type CNPGFleet } from '@skyhook-io/k8s-ui' import type { CNPGOperatorDiagnosis, CNPGOperatorReconcilePod, CNPGReadCoverage } from '../../api/cnpg-recovery' import { buildWorkloadPath } from '../../utils/navigation' -import { GrantText, Mono, Sub } from './shared' +import { GrantText, Mono, Sub } from '../workspace/layout' function Row({ label, children }: { label: string; children: ReactNode }) { return ( diff --git a/web/src/components/cnpg/CNPGOverview.tsx b/web/src/components/cnpg/CNPGOverview.tsx index d0f04ea6a7..721b8d6bb5 100644 --- a/web/src/components/cnpg/CNPGOverview.tsx +++ b/web/src/components/cnpg/CNPGOverview.tsx @@ -15,12 +15,14 @@ import { } from '@skyhook-io/k8s-ui' import type { SelectedResource } from '../../types' import { useConnection } from '../../context/ConnectionContext' -import { EmptyState, ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../capacity/shared' -import { BreakText, CNPGWorkspaceHeader, CoverageNotice, FilterChips, type CNPGScreenProps } from './shared' -import { cnpgClusterFullPath, cnpgClusterProblemsPath, currentPageLabel } from './paths' -import { sameResource } from './routes' +import { CNPGWorkspaceHeader, CoverageNotice, type CNPGScreenProps } from './shared' +import { cnpgClusterFullPath, cnpgClusterProblemsPath } from './paths' +import { currentPageLabel } from '../../utils/page-links' import { CNPGOperatorBanner } from './CNPGOperatorBanner' import { cnpgInstancePillLabel, cnpgPillsToShow, cnpgRowStatus } from './fleetStatus' +import { BreakText, FilterChips, ScreenEmptyState } from '../workspace/layout' +import { ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../workspace/table' +import { sameSelectedResource } from '../../utils/drawer-trail' type Filter = 'attention' | 'all' @@ -198,7 +200,7 @@ export function CNPGOverview({ return (
    - {rows.map((row) => { const ref: SelectedResource = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: row.namespace, name: row.name } - const active = sameResource(inspected, ref) + const active = sameSelectedResource(inspected, ref) return ( = { healthy: 'success', diff --git a/web/src/components/cnpg/CNPGProtection.tsx b/web/src/components/cnpg/CNPGProtection.tsx index 764b113cd5..d0db12357e 100644 --- a/web/src/components/cnpg/CNPGProtection.tsx +++ b/web/src/components/cnpg/CNPGProtection.tsx @@ -15,21 +15,8 @@ import { type HealthLevel, Tooltip, } from '@skyhook-io/k8s-ui' -import { - CNPGWorkspaceHeader, - CoverageNotice, - FilterChips, - Mono, - PathText, - ScreenBody, - SectionTable, - Sub, - clusterResource, - coverageEmpty, - cnpgResource, - namespaceChip, - type CNPGScreenProps, -} from './shared' +import { CNPGWorkspaceHeader, CoverageNotice, clusterResource, coverageEmpty, cnpgResource, type CNPGScreenProps } from './shared' +import { FilterChips, Mono, namespaceChip, PathText, ScreenBody, SectionTable, Sub } from '../workspace/layout' const SEVERITY: Record = { healthy: 'success', diff --git a/web/src/components/cnpg/CNPGStorage.tsx b/web/src/components/cnpg/CNPGStorage.tsx index 9c39e1c3d5..5a19ec10b6 100644 --- a/web/src/components/cnpg/CNPGStorage.tsx +++ b/web/src/components/cnpg/CNPGStorage.tsx @@ -27,13 +27,12 @@ import { type CNPGStorageVolume, type CNPGStorageWAL, } from '../../api/cnpg-storage' -import { Notice } from '../capacity/shared' import { CreateResourceDialog } from '../shared/CreateResourceDialog' import { useCNPGWriteGuard } from './actions/useCNPGWriteGuard' import { buildResizeManifest, cnpgFloorTone, cnpgInstanceDiskTone, cnpgSharedExpansionGap, cnpgSlotRetentionText, cnpgWALUsageFloor } from './storageModel' // Binary units throughout, matching claim capacities such as 1Gi. import { formatBytes } from './lsn' -import { CNPGRefreshFailedNotice } from './shared' +import { Notice, RefreshFailedNotice } from '../workspace/layout' const CNPG_GROUP = 'postgresql.cnpg.io' @@ -358,7 +357,7 @@ export function CNPGStorage({ namespace, name, primary }: { namespace: string; n return (
    - + {data.findings.map((f) => ( - + {context === 'drawer' && } {row.cluster?.spec?.bootstrap?.recovery && ( @@ -214,7 +215,7 @@ function useLogicalWorkspace(ws: CNPGWorkspaceResponse | null, subscriptions: an function LogicalPathSlot({ path, children }: { path: CNPGLogicalPath; children: (slot: ReturnType, notice: ReactNode) => ReactNode }) { const { observed, query } = useCNPGPublisherSlots(path.publisher) - return <>{children(cnpgLogicalSlotFact(path, observed), )} + return <>{children(cnpgLogicalSlotFact(path, observed), )} } function SubscriptionSummaryHost(props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: NavigateToRef }) { @@ -238,7 +239,7 @@ function PublicationSummaryHost(props: { resource: any; workspace: CNPGWorkspace ) : [] const observed = cnpgPublisherSlotsFrom(runtime.data, runtime.error, runtime.isRefetchError) - const notice = + const notice = return ({ path, slot: cnpgLogicalSlotFact(path, observed), notice }))} /> } @@ -271,7 +272,7 @@ function PoolerSummaryHost({ ctx }: { ctx: SummaryContext }) { if (query.isLoading) return const workspace = query.data?.installed ? query.data : null const go = ctx.onNavigate ? (ref: ResourceRef) => ctx.onNavigate?.(refToSelectedResource(ref)) : undefined - return } /> + return } /> } // The object's own apiVersion decides: Velero also ships a Backup kind. @@ -315,7 +316,7 @@ function IssueLinks({ children }: { children: ReactNode }) { return ( - navigate(cnpgIssuesPath(p.subject, searchParams.get('namespaces'))) + navigate(issuesPathForSubject(p.subject, searchParams.get('namespaces'))) } > {children} diff --git a/web/src/components/cnpg/CNPGTrends.tsx b/web/src/components/cnpg/CNPGTrends.tsx index b68d1b9644..f54b4cc81e 100644 --- a/web/src/components/cnpg/CNPGTrends.tsx +++ b/web/src/components/cnpg/CNPGTrends.tsx @@ -11,9 +11,8 @@ import { type CNPGHistoryRange, type CNPGClusterHistoryResponse, } from '../../api/cnpg-history' -import { Notice } from '../capacity/shared' -import { Segments } from './shared' import { cacheHitSeries, chartedDatabases, rateSeries, sampleFrom, sessionStateSeries, SAMPLE_BUFFER_LIMIT, type Sample } from './trendSamples' +import { Notice, Segments } from '../workspace/layout' const COLOR = '#60a5fa' const FILL = '#60a5fa22' diff --git a/web/src/components/cnpg/CNPGView.tsx b/web/src/components/cnpg/CNPGView.tsx index 0dc899d196..7498c5db61 100644 --- a/web/src/components/cnpg/CNPGView.tsx +++ b/web/src/components/cnpg/CNPGView.tsx @@ -12,8 +12,9 @@ import { CNPGPooling } from './CNPGPooling' import { CNPGOperator } from './CNPGOperator' import { CNPGScreenGate } from './shared' import { CNPGDetailPage } from './CNPGDetailPage' -import { decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes' +import { parseCNPGRoute } from './routes' import { useCNPGFleet, useCNPGSidebarWorkspace } from './useCNPGSidebarWorkspace' +import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail' interface CNPGViewProps { namespaces: string[] @@ -59,7 +60,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose useEffect(() => { if (targetKey !== (lastSynced.current ?? '')) { lastSynced.current = targetKey - if (drawerTarget && !sameResource(drawerTarget, selectedResource)) onOpenResource(drawerTarget) + if (drawerTarget && !sameSelectedResource(drawerTarget, selectedResource)) onOpenResource(drawerTarget) else if (!drawerTarget && selectedResource) onCloseResource() return } @@ -69,7 +70,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose if (!selectedResource) { params.delete('drawer') } else { - const idx = trail.findIndex((r) => sameResource(r, selectedResource)) + const idx = trail.findIndex((r) => sameSelectedResource(r, selectedResource)) const next = idx >= 0 ? trail.slice(0, idx + 1) : [...trail, selectedResource] params.set('drawer', encodeDrawerTrail(next)) } diff --git a/web/src/components/cnpg/grantText.test.tsx b/web/src/components/cnpg/grantText.test.tsx index f9dadda8a3..ee19383185 100644 --- a/web/src/components/cnpg/grantText.test.tsx +++ b/web/src/components/cnpg/grantText.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { GrantText } from './shared' +import { GrantText } from '../workspace/layout' describe('GrantText', () => { it('keeps the verb and resource together, with the scope as plain text', () => { diff --git a/web/src/components/cnpg/paths.test.ts b/web/src/components/cnpg/paths.test.ts index cda57e4aa6..fdbf4f13af 100644 --- a/web/src/components/cnpg/paths.test.ts +++ b/web/src/components/cnpg/paths.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { cnpgClusterProblemsPath, cnpgDimensionPath, cnpgIssuesPath, cnpgWithinDetail } from './paths' +import { cnpgClusterProblemsPath, cnpgDimensionPath, cnpgWithinDetail } from './paths' +import { issuesPathForSubject } from '../../utils/page-links' describe('cnpgDimensionPath', () => { it('opens each health dimension where it is explained', () => { @@ -32,15 +33,15 @@ describe('cnpgClusterProblemsPath', () => { }) }) -describe('cnpgIssuesPath', () => { +describe('issuesPathForSubject', () => { it('links to the Issues page narrowed to the subject', () => { - expect(cnpgIssuesPath({ kind: 'Backup', namespace: 'pg', name: 'b-1' })).toBe('/issues?kind=Backup&resource=pg%2Fb-1') - expect(cnpgIssuesPath({ kind: 'ClusterImageCatalog', namespace: '', name: 'pg' })).toBe('/issues?kind=ClusterImageCatalog&resource=pg') + expect(issuesPathForSubject({ kind: 'Backup', namespace: 'pg', name: 'b-1' })).toBe('/issues?kind=Backup&resource=pg%2Fb-1') + expect(issuesPathForSubject({ kind: 'ClusterImageCatalog', namespace: '', name: 'pg' })).toBe('/issues?kind=ClusterImageCatalog&resource=pg') }) it('keeps the current namespace view filter', () => { - expect(cnpgIssuesPath({ kind: 'Cluster', namespace: 'pg', name: 'main' }, 'pg,app')).toBe('/issues?namespaces=pg%2Capp&kind=Cluster&resource=pg%2Fmain') + expect(issuesPathForSubject({ kind: 'Cluster', namespace: 'pg', name: 'main' }, 'pg,app')).toBe('/issues?namespaces=pg%2Capp&kind=Cluster&resource=pg%2Fmain') }) it('carries the API group, so a CNPG Cluster is not a CAPI one', () => { - expect(cnpgIssuesPath({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' })).toBe('/issues?kind=Cluster&group=postgresql.cnpg.io&resource=pg%2Fmain') + expect(issuesPathForSubject({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' })).toBe('/issues?kind=Cluster&group=postgresql.cnpg.io&resource=pg%2Fmain') }) }) diff --git a/web/src/components/cnpg/paths.ts b/web/src/components/cnpg/paths.ts index 885d24e6f4..3acbad61e1 100644 --- a/web/src/components/cnpg/paths.ts +++ b/web/src/components/cnpg/paths.ts @@ -11,14 +11,6 @@ export function cnpgClusterProblemsPath(namespace: string, name: string, ctx?: s return `${path}${path.includes('?') ? '&' : '?'}problems=all` } -/** - * The label for "← back" on the page a push lands on: the title of the page - * being left, which Radar keeps in the document title. - */ -export function currentPageLabel(): string { - return document.title.replace(/\s*·\s*Radar$/, '') || 'previous page' -} - /** * Where a Cluster's health dimension is explained: Serving and Replication in * Runtime's Replication view (instances and their roles), Storage in Runtime's @@ -53,17 +45,3 @@ export function cnpgWithinDetail(currentPathname: string, currentSearch: string, const qs = params.toString() return qs ? `${path}?${qs}` : path } - -/** - * Radar's Issues page narrowed to one subject (it has no link to a single issue). - * The subject travels as `resource=ns/name`, never `namespace=`: App reads a bare - * `namespace` as the view filter, and a URL without `namespaces` clears it. - */ -export function cnpgIssuesPath(subject: { kind: string; group?: string; namespace: string; name: string }, viewNamespaces?: string | null): string { - const params = new URLSearchParams() - if (viewNamespaces) params.set('namespaces', viewNamespaces) - params.set('kind', subject.kind) - if (subject.group) params.set('group', subject.group) - params.set('resource', subject.namespace ? `${subject.namespace}/${subject.name}` : subject.name) - return `/issues?${params}` -} diff --git a/web/src/components/cnpg/refreshNotice.test.tsx b/web/src/components/cnpg/refreshNotice.test.tsx index 86019c2288..10cc1fd899 100644 --- a/web/src/components/cnpg/refreshNotice.test.tsx +++ b/web/src/components/cnpg/refreshNotice.test.tsx @@ -1,18 +1,18 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { CNPGRefreshFailedNotice } from './shared' +import { RefreshFailedNotice } from '../workspace/layout' -describe('CNPGRefreshFailedNotice', () => { +describe('RefreshFailedNotice', () => { it('says a refresh failed and how old the data on screen is', () => { const html = renderToStaticMarkup( - , + , ) expect(html).toContain('Last refresh failed: Gateway Timeout') expect(html).toContain('showing data from 5m ago') }) it('names the oldest of several failed reads', () => { const html = renderToStaticMarkup( - { expect(html).toContain('3h ago') }) it('renders nothing while refreshes succeed', () => { - expect(renderToStaticMarkup()).toBe('') + expect(renderToStaticMarkup()).toBe('') }) }) diff --git a/web/src/components/cnpg/routes.test.ts b/web/src/components/cnpg/routes.test.ts index ea390980ab..d455bf7b5c 100644 --- a/web/src/components/cnpg/routes.test.ts +++ b/web/src/components/cnpg/routes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { cnpgDetailKindFor, cnpgDetailPath, decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes' +import { cnpgDetailKindFor, cnpgDetailPath, parseCNPGRoute } from './routes' +import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail' describe('CNPG routes', () => { it('parses workspace screens and falls back to Overview for unknown or unavailable ones', () => { @@ -30,8 +31,8 @@ describe('CNPG routes', () => { it('distinguishes same-named kinds from different groups', () => { const cnpg = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'a', name: 'x' } const capi = { kind: 'clusters', group: 'cluster.x-k8s.io', namespace: 'a', name: 'x' } - expect(sameResource(cnpg, capi)).toBe(false) - expect(sameResource(cnpg, { ...cnpg })).toBe(true) + expect(sameSelectedResource(cnpg, capi)).toBe(false) + expect(sameSelectedResource(cnpg, { ...cnpg })).toBe(true) }) it('parses full-detail routes for every CNPG kind and the cluster-scoped placeholder', () => { diff --git a/web/src/components/cnpg/routes.ts b/web/src/components/cnpg/routes.ts index f2e039d3ea..9116c32d03 100644 --- a/web/src/components/cnpg/routes.ts +++ b/web/src/components/cnpg/routes.ts @@ -1,4 +1,4 @@ -import type { SelectedResource } from '../../types' +import { CNPG_KIND_BY_KEY, type CNPGWorkspaceKey } from '@skyhook-io/k8s-ui' export type CNPGScreen = 'overview' | 'protection' | 'declarations' | 'pooling' | 'operator' @@ -23,21 +23,29 @@ export interface CNPGRoute { } // The CNPG kinds that have a CNPG-framed full detail, with the workspace -// destination each one lives under. -export const CNPG_DETAIL_KINDS: Record = { - clusters: { group: 'postgresql.cnpg.io', kind: 'Cluster', home: 'overview' }, - backups: { group: 'postgresql.cnpg.io', kind: 'Backup', home: 'protection' }, - scheduledbackups: { group: 'postgresql.cnpg.io', kind: 'ScheduledBackup', home: 'protection' }, - objectstores: { group: 'barmancloud.cnpg.io', kind: 'ObjectStore', home: 'protection' }, - databases: { group: 'postgresql.cnpg.io', kind: 'Database', home: 'declarations' }, - publications: { group: 'postgresql.cnpg.io', kind: 'Publication', home: 'declarations' }, - subscriptions: { group: 'postgresql.cnpg.io', kind: 'Subscription', home: 'declarations' }, - databaseroles: { group: 'postgresql.cnpg.io', kind: 'DatabaseRole', home: 'declarations' }, - poolers: { group: 'postgresql.cnpg.io', kind: 'Pooler', home: 'pooling' }, - imagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ImageCatalog', home: 'operator' }, - clusterimagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ClusterImageCatalog', home: 'operator', clusterScoped: true }, +// destination each one lives under. Kind and group come from the workspace's +// kind table, so the two never disagree. +const CNPG_DETAIL_HOMES: Partial> = { + clusters: 'overview', + backups: 'protection', + scheduledBackups: 'protection', + objectStores: 'protection', + databases: 'declarations', + publications: 'declarations', + subscriptions: 'declarations', + databaseRoles: 'declarations', + poolers: 'pooling', + imageCatalogs: 'operator', + clusterImageCatalogs: 'operator', } +export const CNPG_DETAIL_KINDS: Record = Object.fromEntries( + (Object.entries(CNPG_DETAIL_HOMES) as [CNPGWorkspaceKey, CNPGScreen][]).map(([key, home]) => { + const k = CNPG_KIND_BY_KEY[key] + return [k.plural, { group: k.group, kind: k.kind, home, ...(key === 'clusterImageCatalogs' ? { clusterScoped: true } : {}) }] + }), +) + export function cnpgDetailKindFor(plural: string, group: string | undefined): string | null { const p = plural.toLowerCase() const spec = CNPG_DETAIL_KINDS[p] @@ -78,36 +86,3 @@ export function cnpgDetailPath(target: Omit, ctx?: st export function cnpgScreenPath(screen: CNPGScreen): string { return CNPG_SCREENS.find((s) => s.id === screen)?.path ?? '/cnpg' } - -// Drawer identity in the URL: kind:group:namespace:name, chained with "~" for -// the in-drawer trail (last entry is the one shown). Kubernetes names and API -// groups cannot contain ":" or "~", and the group is mandatory — CNPG's Cluster -// and Backup collide with CAPI, KubeBlocks and Velero kinds. -export function encodeDrawerRef(r: SelectedResource): string { - return [r.kind, r.group ?? '', r.namespace ?? '', r.name].join(':') -} - -export function decodeDrawerRef(s: string): SelectedResource | null { - const parts = s.split(':') - if (parts.length !== 4 || !parts[0] || !parts[3]) return null - return { kind: parts[0], group: parts[1], namespace: parts[2], name: parts[3] } -} - -export function decodeDrawerTrail(param: string | null): SelectedResource[] { - if (!param) return [] - return param.split('~').map(decodeDrawerRef).filter((r): r is SelectedResource => r !== null) -} - -export function encodeDrawerTrail(trail: SelectedResource[]): string { - return trail.map(encodeDrawerRef).join('~') -} - -export function sameResource(a: SelectedResource | null | undefined, b: SelectedResource | null | undefined): boolean { - if (!a || !b) return false - return ( - a.kind.toLowerCase() === b.kind.toLowerCase() && - (a.group ?? '') === (b.group ?? '') && - (a.namespace ?? '') === (b.namespace ?? '') && - a.name === b.name - ) -} diff --git a/web/src/components/cnpg/shared.tsx b/web/src/components/cnpg/shared.tsx index 93f1e3e9c8..fb54a166fa 100644 --- a/web/src/components/cnpg/shared.tsx +++ b/web/src/components/cnpg/shared.tsx @@ -1,21 +1,16 @@ import type { ReactNode } from 'react' import type { UseQueryResult } from '@tanstack/react-query' -import { clsx } from 'clsx' -import { AlertTriangle, Database, X } from 'lucide-react' +import { Database } from 'lucide-react' import { CNPG_KIND_BY_KEY, PaneLoader, - Tooltip, - formatUpdatedAgo, - toneTextClass, type CNPGFleet, type CNPGKindCoverage, type CNPGWorkspaceResponse, } from '@skyhook-io/k8s-ui' import type { SelectedResource } from '../../types' import { useConnection } from '../../context/ConnectionContext' -import { EmptyState, Notice, ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../capacity/shared' -import { sameResource } from './routes' +import { Notice, ScreenEmptyState } from '../workspace/layout' export interface CNPGScreenProps { data: CNPGWorkspaceResponse @@ -85,7 +80,7 @@ export function CNPGScreenGate({ if (!data && query.isLoading) return if (!data) { return ( - {children(data, fleet)} } -export function ScreenBody({ children }: { children: ReactNode }) { - return ( -
    -
    {children}
    -
    - ) -} - -export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) { - if (chips.length === 0) return null - return ( -
    - {chips.map((c) => ( - - {c.label} - - - ))} -
    - ) -} - -export function namespaceChip(namespaces: string[], onClear: () => void) { - return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : [] -} - -export function Segments({ - value, - options, - onChange, - label, -}: { - value: T - options: { id: T; label: string; count?: number }[] - onChange: (id: T) => void - label: string -}) { - return ( -
    - {options.map((o) => { - const on = o.id === value - return ( - - ) - })} -
    - ) -} - -export interface TableColumn { - header: ReactNode - width?: string - cell: (row: T) => ReactNode - className?: string -} - -/** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */ -export function SectionTable({ - title, - subtitle, - columns, - rows, - rowKey, - rowResource, - onInspect, - inspected, - empty, - minWidth = 760, - footer, -}: { - title: ReactNode - subtitle?: ReactNode - columns: TableColumn[] - rows: T[] - rowKey: (row: T) => string - rowResource?: (row: T) => SelectedResource | null - onInspect?: (resource: SelectedResource) => void - inspected?: SelectedResource | null - empty: ReactNode - minWidth?: number - footer?: ReactNode -}) { - return ( -
    -
    -

    {title}

    - {subtitle && {subtitle}} -
    -
    - {rows.length === 0 ? ( -
    {empty}
    - ) : ( -
    - - - {columns.map((c, i) => ( - - ))} - - - - {columns.map((c, i) => ( - - ))} - - - - {rows.map((row) => { - const res = rowResource?.(row) ?? null - const active = !!res && sameResource(inspected, res) - return ( - onInspect(res) : undefined} - className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')} - aria-selected={res ? active : undefined} - > - {columns.map((c, i) => ( - - ))} - - ) - })} - -
    {c.header}
    {c.cell(row)}
    -
    - )} -
    - {footer &&
    {footer}
    } -
    - ) -} - /** Empty-state text for a collection, derived from how much of it was readable. */ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string): string { switch (cov?.state) { @@ -276,54 +123,6 @@ export function worstCoverage(...covs: (CNPGKindCoverage | undefined)[]): CNPGKi return covs.filter(Boolean).sort((a, b) => (rank[a!.state] ?? 9) - (rank[b!.state] ?? 9))[0] } -/** - * Text that wraps only after `after` ("/" for a path, "-" for a resource - * name), never mid-word; a single segment too long for its cell is cut with an - * ellipsis. The whole value shows on hover. - */ -export function BreakText({ value, after, className }: { value: string; after: '/' | '-'; className?: string }) { - const parts = value.split(after === '/' ? /(?<=\/)/ : /(?<=-)/) - return ( - - - {parts.map((p, i) => ( - - {p} - - ))} - - - ) -} - -/** A URL or path; see BreakText. */ -export function PathText({ value, className }: { value: string; className?: string }) { - return -} - -/** - * A grant as one unit: the verb and resource in a code span that does not - * wrap, its scope ("cluster-wide", "in namespace pg") as plain text after it. - */ -export function GrantText({ grant }: { grant: string }) { - const m = /^(.*?)( cluster-wide| in namespace \S+)$/.exec(grant) - const [what, scope] = m ? [m[1], m[2]] : [grant, ''] - return ( - <> - {what} - {scope} - - ) -} - -export function Mono({ children }: { children: ReactNode }) { - return {children} -} - -export function Sub({ children }: { children: ReactNode }) { - return
    {children}
    -} - export function clusterResource(namespace: string, name: string): SelectedResource { return { kind: 'clusters', group: 'postgresql.cnpg.io', namespace, name } } @@ -331,24 +130,3 @@ export function clusterResource(namespace: string, name: string): SelectedResour export function cnpgResource(plural: string, namespace: string, name: string, group = 'postgresql.cnpg.io'): SelectedResource { return { kind: plural, group, namespace, name } } - -type RefreshableQuery = Pick, 'isRefetchError' | 'error' | 'dataUpdatedAt'> - -/** - * A refetch failed while the last good answer stays on screen: say so, why, - * and how old that answer is, so cached values are not read as current. - */ -export function CNPGRefreshFailedNotice({ queries, className }: { queries: RefreshableQuery[]; className?: string }) { - const failed = queries.filter((q) => q.isRefetchError) - if (failed.length === 0) return null - const oldest = failed.reduce((a, b) => (b.dataUpdatedAt < a.dataUpdatedAt ? b : a)) - const reason = oldest.error instanceof Error ? oldest.error.message : 'unknown error' - return ( -
    - - - Last refresh failed: {reason.length > 160 ? `${reason.slice(0, 160)}…` : reason} · showing data from {formatUpdatedAgo(Date.now() - oldest.dataUpdatedAt)} - -
    - ) -} diff --git a/web/src/components/workspace/layout.tsx b/web/src/components/workspace/layout.tsx new file mode 100644 index 0000000000..2fe65f9535 --- /dev/null +++ b/web/src/components/workspace/layout.tsx @@ -0,0 +1,260 @@ +import type { ComponentType, ReactNode } from 'react' +import type { UseQueryResult } from '@tanstack/react-query' +import { clsx } from 'clsx' +import { AlertTriangle, X } from 'lucide-react' +import { Tooltip, formatUpdatedAgo, toneTextClass } from '@skyhook-io/k8s-ui' +import type { SelectedResource } from '../../types' +import { sameSelectedResource } from '../../utils/drawer-trail' +import { ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from './table' + +export function Notice({ children }: { children: ReactNode }) { + return ( +
    + +
    {children}
    +
    + ); +} + +/** A whole screen with nothing to show: not installed, unreadable, or empty. */ +export function ScreenEmptyState({ + icon: Icon, + title, + detail, + action, +}: { + icon: ComponentType<{ className?: string }>; + title: string; + detail: ReactNode; + action?: ReactNode; +}) { + return ( +
    +
    + +

    + {title} +

    +

    {detail}

    + {action} +
    +
    + ); +} + +export function ScreenBody({ children }: { children: ReactNode }) { + return ( +
    +
    {children}
    +
    + ) +} + +export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) { + if (chips.length === 0) return null + return ( +
    + {chips.map((c) => ( + + {c.label} + + + ))} +
    + ) +} + +export function namespaceChip(namespaces: string[], onClear: () => void) { + return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : [] +} + +export function Segments({ + value, + options, + onChange, + label, +}: { + value: T + options: { id: T; label: string; count?: number }[] + onChange: (id: T) => void + label: string +}) { + return ( +
    + {options.map((o) => { + const on = o.id === value + return ( + + ) + })} +
    + ) +} + +export interface TableColumn { + header: ReactNode + width?: string + cell: (row: T) => ReactNode + className?: string +} + +/** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */ +export function SectionTable({ + title, + subtitle, + columns, + rows, + rowKey, + rowResource, + onInspect, + inspected, + empty, + minWidth = 760, + footer, +}: { + title: ReactNode + subtitle?: ReactNode + columns: TableColumn[] + rows: T[] + rowKey: (row: T) => string + rowResource?: (row: T) => SelectedResource | null + onInspect?: (resource: SelectedResource) => void + inspected?: SelectedResource | null + empty: ReactNode + minWidth?: number + footer?: ReactNode +}) { + return ( +
    +
    +

    {title}

    + {subtitle && {subtitle}} +
    +
    + {rows.length === 0 ? ( +
    {empty}
    + ) : ( +
    + + + {columns.map((c, i) => ( + + ))} + + + + {columns.map((c, i) => ( + + ))} + + + + {rows.map((row) => { + const res = rowResource?.(row) ?? null + const active = !!res && sameSelectedResource(inspected, res) + return ( + onInspect(res) : undefined} + className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')} + aria-selected={res ? active : undefined} + > + {columns.map((c, i) => ( + + ))} + + ) + })} + +
    {c.header}
    {c.cell(row)}
    +
    + )} +
    + {footer &&
    {footer}
    } +
    + ) +} + +/** + * Text that wraps only after `after` ("/" for a path, "-" for a resource + * name), never mid-word; a single segment too long for its cell is cut with an + * ellipsis. The whole value shows on hover. + */ +export function BreakText({ value, after, className }: { value: string; after: '/' | '-'; className?: string }) { + const parts = value.split(after === '/' ? /(?<=\/)/ : /(?<=-)/) + return ( + + + {parts.map((p, i) => ( + + {p} + + ))} + + + ) +} + +/** A URL or path; see BreakText. */ +export function PathText({ value, className }: { value: string; className?: string }) { + return +} + +/** + * A grant as one unit: the verb and resource in a code span that does not + * wrap, its scope ("cluster-wide", "in namespace pg") as plain text after it. + */ +export function GrantText({ grant }: { grant: string }) { + const m = /^(.*?)( cluster-wide| in namespace \S+)$/.exec(grant) + const [what, scope] = m ? [m[1], m[2]] : [grant, ''] + return ( + <> + {what} + {scope} + + ) +} + +export function Mono({ children }: { children: ReactNode }) { + return {children} +} + +export function Sub({ children }: { children: ReactNode }) { + return
    {children}
    +} + +type RefreshableQuery = Pick, 'isRefetchError' | 'error' | 'dataUpdatedAt'> + +/** + * A refetch failed while the last good answer stays on screen: say so, why, + * and how old that answer is, so cached values are not read as current. + */ +export function RefreshFailedNotice({ queries, className }: { queries: RefreshableQuery[]; className?: string }) { + const failed = queries.filter((q) => q.isRefetchError) + if (failed.length === 0) return null + const oldest = failed.reduce((a, b) => (b.dataUpdatedAt < a.dataUpdatedAt ? b : a)) + const reason = oldest.error instanceof Error ? oldest.error.message : 'unknown error' + return ( +
    + + + Last refresh failed: {reason.length > 160 ? `${reason.slice(0, 160)}…` : reason} · showing data from {formatUpdatedAgo(Date.now() - oldest.dataUpdatedAt)} + +
    + ) +} diff --git a/web/src/components/workspace/table.ts b/web/src/components/workspace/table.ts new file mode 100644 index 0000000000..96f4054c30 --- /dev/null +++ b/web/src/components/workspace/table.ts @@ -0,0 +1,8 @@ +// Shared table cell classes: every workspace table (Capacity, CloudNativePG) looks the same. +export const TABLE_WRAP = 'overflow-x-auto'; +export const TABLE_HEAD = + 'border-b border-theme-border bg-theme-base/60 text-[11px] uppercase tracking-wide text-theme-text-tertiary'; +export const TH = 'px-3 py-2.5 text-left font-medium whitespace-nowrap'; +export const TD = 'px-3 py-2.5 align-top text-sm text-theme-text-primary'; +export const TBODY = 'table-divide-subtle'; +export const ROW_HOVER = 'transition-colors hover:bg-theme-hover/50'; diff --git a/web/src/utils/drawer-trail.ts b/web/src/utils/drawer-trail.ts new file mode 100644 index 0000000000..d7963f4657 --- /dev/null +++ b/web/src/utils/drawer-trail.ts @@ -0,0 +1,35 @@ +import type { SelectedResource } from '../types' + +// Drawer identity in the URL: kind:group:namespace:name, chained with "~" for +// the in-drawer trail (last entry is the one shown). Kubernetes names and API +// groups cannot contain ":" or "~", and the group is mandatory: CRD kinds +// collide across groups (CloudNativePG's Cluster and Backup with CAPI, +// KubeBlocks and Velero kinds). +export function encodeDrawerRef(r: SelectedResource): string { + return [r.kind, r.group ?? '', r.namespace ?? '', r.name].join(':') +} + +export function decodeDrawerRef(s: string): SelectedResource | null { + const parts = s.split(':') + if (parts.length !== 4 || !parts[0] || !parts[3]) return null + return { kind: parts[0], group: parts[1], namespace: parts[2], name: parts[3] } +} + +export function decodeDrawerTrail(param: string | null): SelectedResource[] { + if (!param) return [] + return param.split('~').map(decodeDrawerRef).filter((r): r is SelectedResource => r !== null) +} + +export function encodeDrawerTrail(trail: SelectedResource[]): string { + return trail.map(encodeDrawerRef).join('~') +} + +export function sameSelectedResource(a: SelectedResource | null | undefined, b: SelectedResource | null | undefined): boolean { + if (!a || !b) return false + return ( + a.kind.toLowerCase() === b.kind.toLowerCase() && + (a.group ?? '') === (b.group ?? '') && + (a.namespace ?? '') === (b.namespace ?? '') && + a.name === b.name + ) +} diff --git a/web/src/utils/page-links.ts b/web/src/utils/page-links.ts new file mode 100644 index 0000000000..72e41abed1 --- /dev/null +++ b/web/src/utils/page-links.ts @@ -0,0 +1,21 @@ +/** + * The label for "← back" on the page a push lands on: the title of the page + * being left, which Radar keeps in the document title. + */ +export function currentPageLabel(): string { + return document.title.replace(/\s*·\s*Radar$/, '') || 'previous page' +} + +/** + * Radar's Issues page narrowed to one subject (it has no link to a single issue). + * The subject travels as `resource=ns/name`, never `namespace=`: App reads a bare + * `namespace` as the view filter, and a URL without `namespaces` clears it. + */ +export function issuesPathForSubject(subject: { kind: string; group?: string; namespace: string; name: string }, viewNamespaces?: string | null): string { + const params = new URLSearchParams() + if (viewNamespaces) params.set('namespaces', viewNamespaces) + params.set('kind', subject.kind) + if (subject.group) params.set('group', subject.group) + params.set('resource', subject.namespace ? `${subject.namespace}/${subject.name}` : subject.name) + return `/issues?${params}` +} From 4aa160484106e8e6362c02e78d2359c5628b4888 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 4 Oct 2026 01:14:37 +0300 Subject: [PATCH 05/26] Move per-kind access and coverage out of the CNPG workspace listScope, typedKindScope, accessFromScope, kindAccess (with covers and coverage), KindCoverage and its states, readWorkspaceKind and keepGroups move to kind_access.go so the next workspace reads its kinds the same way. The group filter takes its groups as a parameter; CloudNativePG passes its two groups through cnpgWorkspaceReadKind and filterCNPGGroup. kindAccess now carries its denied namespaces instead of returning them alongside. Group filtering, the namespace-disclosure rule and every state are unchanged. --- internal/server/cnpg_cluster_ha.go | 4 +- internal/server/cnpg_history.go | 6 +- internal/server/cnpg_operator.go | 42 ++-- internal/server/cnpg_operator_status.go | 6 +- internal/server/cnpg_operator_test.go | 6 +- internal/server/cnpg_storage.go | 6 +- internal/server/cnpg_workspace.go | 252 ++++-------------------- internal/server/cnpg_workspace_test.go | 28 +-- internal/server/kind_access.go | 224 +++++++++++++++++++++ 9 files changed, 307 insertions(+), 267 deletions(-) create mode 100644 internal/server/kind_access.go diff --git a/internal/server/cnpg_cluster_ha.go b/internal/server/cnpg_cluster_ha.go index 47b7dee4e0..0a384c68f5 100644 --- a/internal/server/cnpg_cluster_ha.go +++ b/internal/server/cnpg_cluster_ha.go @@ -596,7 +596,7 @@ func (s *Server) cnpgHAPrimaryLease(ctx context.Context, r *http.Request, c cnpg // namespace of a visible operator Deployment. Which operator replica leads is // a different fact from which instance is primary. func (s *Server) cnpgHAOperatorLease(ctx context.Context, r *http.Request, c cnpgHAClients, cache *k8s.ResourceCache, now time.Time) CNPGHALease { - acc, _, deployments := s.cnpgOperatorDeployments(r, cache, s.cnpgOperatorScope(r)) + acc, deployments := s.cnpgOperatorDeployments(r, cache, s.cnpgOperatorScope(r)) var namespace string for _, d := range deployments { if d.Labels[cnpgOperatorNameLabel] == cnpgOperatorNameValue { @@ -606,7 +606,7 @@ func (s *Server) cnpgHAOperatorLease(ctx context.Context, r *http.Request, c cnp } if namespace == "" { reason := "The operator Deployment is not visible to you, so its namespace is unknown" - if acc.state == cnpgCoverageFull { + if acc.state == kindCoverageFull { reason = "No operator Deployment labelled " + cnpgOperatorNameLabel + "=" + cnpgOperatorNameValue + " was found" } return CNPGHALease{CNPGHASource: CNPGHASource{State: cnpgHAStateUnavailable, Reason: reason}, Name: cnpgOperatorLeaseName} diff --git a/internal/server/cnpg_history.go b/internal/server/cnpg_history.go index 3c06fde97c..3f01fce587 100644 --- a/internal/server/cnpg_history.go +++ b/internal/server/cnpg_history.go @@ -328,14 +328,14 @@ func (s *Server) handleCNPGFleetMetrics(w http.ResponseWriter, r *http.Request) return } - var clusterKind cnpgWorkspaceKind + var clusterKind workspaceKind for _, k := range cnpgWorkspaceKinds { if k.key == cnpgWorkspaceClusterKey { clusterKind = k } } - acc, _, clusters := s.cnpgWorkspaceReadKind(r, cache, clusterKind, namespaces) - if acc.state != cnpgCoverageFull && acc.state != cnpgCoveragePartial { + acc, clusters := s.cnpgWorkspaceReadKind(r, cache, clusterKind, namespaces) + if acc.state != kindCoverageFull && acc.state != kindCoveragePartial { s.writeJSON(w, resp) return } diff --git a/internal/server/cnpg_operator.go b/internal/server/cnpg_operator.go index 911d7eb1c6..6d95e282c7 100644 --- a/internal/server/cnpg_operator.go +++ b/internal/server/cnpg_operator.go @@ -65,9 +65,9 @@ type CNPGOperatorConfigRef struct { // CNPGOperatorResponse is GET /api/cnpg/operator. type CNPGOperatorResponse struct { - Coverage map[string]CNPGWorkspaceCoverage `json:"coverage"` - Components []CNPGOperatorComponent `json:"components"` - Config []CNPGOperatorConfigRef `json:"config"` + Coverage map[string]KindCoverage `json:"coverage"` + Components []CNPGOperatorComponent `json:"components"` + Config []CNPGOperatorConfigRef `json:"config"` // Diagnosis is one entry per operator Deployment: leader Lease, watched // namespaces, webhook reachability, reconcile counters and recent events. Diagnosis []CNPGOperatorDiagnosis `json:"diagnosis"` @@ -93,15 +93,15 @@ func (s *Server) handleCNPGOperator(w http.ResponseWriter, r *http.Request) { scope := s.cnpgOperatorScope(r) resp := CNPGOperatorResponse{ - Coverage: map[string]CNPGWorkspaceCoverage{}, + Coverage: map[string]KindCoverage{}, Components: []CNPGOperatorComponent{}, Config: []CNPGOperatorConfigRef{}, } - depAcc, depDenied, deployments := s.cnpgOperatorDeployments(r, cache, scope) - resp.Coverage["deployments"] = cnpgCoverageOf(depAcc, depDenied) - svcAcc, svcDenied, services := s.cnpgOperatorServices(r, cache, scope) - resp.Coverage["services"] = cnpgCoverageOf(svcAcc, svcDenied) + depAcc, deployments := s.cnpgOperatorDeployments(r, cache, scope) + resp.Coverage["deployments"] = depAcc.coverage() + svcAcc, services := s.cnpgOperatorServices(r, cache, scope) + resp.Coverage["services"] = svcAcc.coverage() var operators []*appsv1.Deployment for _, d := range deployments { @@ -165,14 +165,14 @@ func (s *Server) cnpgOperatorScope(r *http.Request) []string { return s.getUserNamespaces(r, nil) } -func (s *Server) cnpgOperatorDeployments(r *http.Request, cache *k8s.ResourceCache, scope []string) (cnpgKindAccess, []string, []*appsv1.Deployment) { - acc, denied, read := s.cnpgTypedScope(r, cache, scope, "apps", "deployments") - if acc.state == cnpgCoverageDenied || acc.state == cnpgCoverageError { - return acc, denied, nil +func (s *Server) cnpgOperatorDeployments(r *http.Request, cache *k8s.ResourceCache, scope []string) (kindAccess, []*appsv1.Deployment) { + acc, read := s.typedKindScope(r, cache, scope, "apps", "deployments") + if acc.state == kindCoverageDenied || acc.state == kindCoverageError { + return acc, nil } lister := cache.Deployments() if lister == nil || !cache.IsKindReady("deployments") { - return cnpgKindAccess{state: cnpgCoverageSyncing}, nil, nil + return kindAccess{state: kindCoverageSyncing}, nil } var out []*appsv1.Deployment if read == nil { @@ -189,22 +189,22 @@ func (s *Server) cnpgOperatorDeployments(r *http.Request, cache *k8s.ResourceCac } return out[i].Name < out[j].Name }) - return acc, denied, out + return acc, out } -func (s *Server) cnpgOperatorServices(r *http.Request, cache *k8s.ResourceCache, scope []string) (cnpgKindAccess, []string, []*corev1.Service) { - acc, denied, read := s.cnpgTypedScope(r, cache, scope, "", "services") - if acc.state == cnpgCoverageDenied || acc.state == cnpgCoverageError { - return acc, denied, nil +func (s *Server) cnpgOperatorServices(r *http.Request, cache *k8s.ResourceCache, scope []string) (kindAccess, []*corev1.Service) { + acc, read := s.typedKindScope(r, cache, scope, "", "services") + if acc.state == kindCoverageDenied || acc.state == kindCoverageError { + return acc, nil } lister := cache.Services() if lister == nil || !cache.IsKindReady("services") { - return cnpgKindAccess{state: cnpgCoverageSyncing}, nil, nil + return kindAccess{state: kindCoverageSyncing}, nil } hasPlugin, err := labels.Parse(cnpgPluginNameLabel) if err != nil { log.Printf("[cnpg] Failed to build plugin selector: %v", err) - return cnpgKindAccess{state: cnpgCoverageError}, nil, nil + return kindAccess{state: kindCoverageError}, nil } var out []*corev1.Service if read == nil { @@ -215,7 +215,7 @@ func (s *Server) cnpgOperatorServices(r *http.Request, cache *k8s.ResourceCache, out = append(out, items...) } } - return acc, denied, out + return acc, out } func cnpgOperatorContainerOf(d *appsv1.Deployment) *corev1.Container { diff --git a/internal/server/cnpg_operator_status.go b/internal/server/cnpg_operator_status.go index b570bb2d0f..d6ece27a80 100644 --- a/internal/server/cnpg_operator_status.go +++ b/internal/server/cnpg_operator_status.go @@ -149,10 +149,10 @@ func (s *Server) readCNPGOperatorFacts(r *http.Request) cnpgOperatorFacts { out.webhookUnknown = out.deploymentsUnknown return out } - acc, _, deployments := s.cnpgOperatorDeployments(r, cache, s.cnpgOperatorScope(r)) - if acc.state != cnpgCoverageFull { + acc, deployments := s.cnpgOperatorDeployments(r, cache, s.cnpgOperatorScope(r)) + if acc.state != kindCoverageFull { out.deploymentsUnknown = "Radar cannot list Deployments in every namespace, so the operator may be out of view" - if acc.state == cnpgCoverageSyncing { + if acc.state == kindCoverageSyncing { out.deploymentsUnknown = "Deployments are still syncing" } } diff --git a/internal/server/cnpg_operator_test.go b/internal/server/cnpg_operator_test.go index b1f8e49af6..436c8a25b0 100644 --- a/internal/server/cnpg_operator_test.go +++ b/internal/server/cnpg_operator_test.go @@ -191,7 +191,7 @@ func TestCNPGOperator_DiscoversOperatorPluginAndConfig(t *testing.T) { got, body := getCNPGOperatorNoAuth(t, "") for _, key := range []string{"deployments", "services"} { - if got.Coverage[key].State != cnpgCoverageFull { + if got.Coverage[key].State != kindCoverageFull { t.Errorf("coverage[%s] = %+v, want full", key, got.Coverage[key]) } } @@ -339,7 +339,7 @@ func TestCNPGOperator_DeniedDeploymentsWithholdComponents(t *testing.T) { got, _ := readCNPGOperator(t, env.authGet(t, "/api/cnpg/operator", "partial", "")) cov := got.Coverage["deployments"] - if cov.State != cnpgCoveragePartial || len(cov.DeniedNamespaces) != 1 || cov.DeniedNamespaces[0] != "cnpg-system" { + if cov.State != kindCoveragePartial || len(cov.DeniedNamespaces) != 1 || cov.DeniedNamespaces[0] != "cnpg-system" { t.Errorf("deployments coverage = %+v, want partial denied [cnpg-system]", cov) } for _, c := range got.Components { @@ -359,7 +359,7 @@ func TestCNPGOperator_DeniedDeploymentsWithholdComponents(t *testing.T) { env.srv.permCache.Set("none", nil, none) got, _ = readCNPGOperator(t, env.authGet(t, "/api/cnpg/operator", "none", "")) - if got.Coverage["deployments"].State != cnpgCoverageDenied || got.Coverage["services"].State != cnpgCoverageDenied { + if got.Coverage["deployments"].State != kindCoverageDenied || got.Coverage["services"].State != kindCoverageDenied { t.Errorf("coverage = %+v, want both denied", got.Coverage) } if got.Components == nil || len(got.Components) != 0 || got.Config == nil { diff --git a/internal/server/cnpg_storage.go b/internal/server/cnpg_storage.go index 99b5689927..d0e7b58031 100644 --- a/internal/server/cnpg_storage.go +++ b/internal/server/cnpg_storage.go @@ -796,14 +796,14 @@ func (s *Server) handleCNPGFleetDisk(w http.ResponseWriter, r *http.Request) { namespaces := s.parseNamespacesForUser(r) resp := CNPGFleetDiskResponse{SampledAt: time.Now().UTC().Format(time.RFC3339), Source: cnpgUsageSource, Clusters: []CNPGClusterDisk{}} - var clusterKind cnpgWorkspaceKind + var clusterKind workspaceKind for _, k := range cnpgWorkspaceKinds { if k.key == cnpgWorkspaceClusterKey { clusterKind = k } } - acc, _, clusters := s.cnpgWorkspaceReadKind(r, cache, clusterKind, namespaces) - if acc.state != cnpgCoverageFull && acc.state != cnpgCoveragePartial { + acc, clusters := s.cnpgWorkspaceReadKind(r, cache, clusterKind, namespaces) + if acc.state != kindCoverageFull && acc.state != kindCoveragePartial { s.writeJSON(w, resp) return } diff --git a/internal/server/cnpg_workspace.go b/internal/server/cnpg_workspace.go index 5faaed28de..c29347b4bd 100644 --- a/internal/server/cnpg_workspace.go +++ b/internal/server/cnpg_workspace.go @@ -1,11 +1,8 @@ package server import ( - "context" - "errors" "log" "net/http" - "slices" "sort" "strings" "time" @@ -24,15 +21,6 @@ import ( const cnpgBarmanGroup = "barmancloud.cnpg.io" -const ( - cnpgCoverageFull = "full" - cnpgCoveragePartial = "partial" - cnpgCoverageDenied = "denied" - cnpgCoverageNotInstalled = "notInstalled" - cnpgCoverageSyncing = "syncing" - cnpgCoverageError = "error" -) - const ( cnpgWorkspacePodsKey = "pods" cnpgWorkspaceBackupsKey = "backups" @@ -47,15 +35,11 @@ const cnpgBackupWindow = 7 * 24 * time.Hour const cnpgNoDeclarativeBackupCheckID = "cnpgNoDeclarativeBackup" -type cnpgWorkspaceKind struct { - key string - group string - kind string - resource string - clusterScoped bool -} +// cnpgGroups are the API groups CloudNativePG objects are read from; a kind +// listed by name keeps only objects of these groups. +var cnpgGroups = []string{cnpgGroup, cnpgBarmanGroup} -var cnpgWorkspaceKinds = []cnpgWorkspaceKind{ +var cnpgWorkspaceKinds = []workspaceKind{ {key: cnpgWorkspaceClusterKey, group: cnpgGroup, kind: "Cluster", resource: "clusters"}, {key: cnpgWorkspaceBackupsKey, group: cnpgGroup, kind: "Backup", resource: "backups"}, {key: cnpgWorkspaceSchedKey, group: cnpgGroup, kind: "ScheduledBackup", resource: "scheduledbackups"}, @@ -69,28 +53,6 @@ var cnpgWorkspaceKinds = []cnpgWorkspaceKind{ {key: "objectStores", group: cnpgBarmanGroup, kind: "ObjectStore", resource: "objectstores"}, } -// CNPGWorkspaceCoverage states how much of one kind the caller could see. -// DeniedNamespaces lists only namespaces already in the caller's scope, so it -// may be omitted on a partial state; AllowedNamespaces is always set on a -// partial state and is the authority for which namespaces were read. -type CNPGWorkspaceCoverage struct { - State string `json:"state"` - DeniedNamespaces []string `json:"deniedNamespaces,omitempty"` - AllowedNamespaces []string `json:"allowedNamespaces,omitempty"` -} - -func cnpgCoverageOf(acc cnpgKindAccess, denied []string) CNPGWorkspaceCoverage { - cov := CNPGWorkspaceCoverage{State: acc.state, DeniedNamespaces: denied} - if acc.state == cnpgCoveragePartial { - cov.AllowedNamespaces = make([]string, 0, len(acc.namespaces)) - for ns := range acc.namespaces { - cov.AllowedNamespaces = append(cov.AllowedNamespaces, ns) - } - sort.Strings(cov.AllowedNamespaces) - } - return cov -} - // CNPGWorkspaceIssue is the subset of issuesapi.Issue the workspace renders. type CNPGWorkspaceIssue struct { ID string `json:"id"` @@ -120,49 +82,34 @@ type CNPGWorkspaceAuditFinding struct { // CNPGWorkspaceResponse is GET /api/cnpg/workspace. type CNPGWorkspaceResponse struct { - Installed bool `json:"installed"` - Context string `json:"context"` - Namespaces []string `json:"namespaces"` - Coverage map[string]CNPGWorkspaceCoverage `json:"coverage"` - Objects map[string][]any `json:"objects"` - Issues []CNPGWorkspaceIssue `json:"issues"` - Audit []CNPGWorkspaceAuditFinding `json:"audit"` - BackupsOmitted int `json:"backupsOmitted"` + Installed bool `json:"installed"` + Context string `json:"context"` + Namespaces []string `json:"namespaces"` + Coverage map[string]KindCoverage `json:"coverage"` + Objects map[string][]any `json:"objects"` + Issues []CNPGWorkspaceIssue `json:"issues"` + Audit []CNPGWorkspaceAuditFinding `json:"audit"` + BackupsOmitted int `json:"backupsOmitted"` // ScheduleReadings words each readable ScheduledBackup's schedule as the // operator reads it, keyed "namespace/name"; a schedule the operator // cannot parse has no entry. ScheduleReadings map[string]string `json:"scheduleReadings,omitempty"` } -// cnpgKindAccess is the resolved read scope for one kind. all means every -// namespace in the request's scope (or the cluster-scoped kind itself). -type cnpgKindAccess struct { - state string - all bool - namespaces map[string]bool -} - -func (a cnpgKindAccess) covers(namespace string) bool { - if a.state != cnpgCoverageFull && a.state != cnpgCoveragePartial { - return false - } - return a.all || a.namespaces[namespace] -} - func newCNPGWorkspaceResponse(namespaces []string) CNPGWorkspaceResponse { resp := CNPGWorkspaceResponse{ Context: k8s.ActiveClusterContext(), Namespaces: namespaces, - Coverage: map[string]CNPGWorkspaceCoverage{}, + Coverage: map[string]KindCoverage{}, Objects: map[string][]any{}, Issues: []CNPGWorkspaceIssue{}, Audit: []CNPGWorkspaceAuditFinding{}, } for _, k := range cnpgWorkspaceKinds { - resp.Coverage[k.key] = CNPGWorkspaceCoverage{State: cnpgCoverageNotInstalled} + resp.Coverage[k.key] = KindCoverage{State: kindCoverageNotInstalled} resp.Objects[k.key] = []any{} } - resp.Coverage[cnpgWorkspacePodsKey] = CNPGWorkspaceCoverage{State: cnpgCoverageNotInstalled} + resp.Coverage[cnpgWorkspacePodsKey] = KindCoverage{State: kindCoverageNotInstalled} resp.Objects[cnpgWorkspacePodsKey] = []any{} return resp } @@ -198,22 +145,22 @@ func (s *Server) handleCNPGWorkspace(w http.ResponseWriter, r *http.Request) { } } - access := map[string]cnpgKindAccess{} + access := map[string]kindAccess{} items := map[string][]*unstructured.Unstructured{} for _, k := range cnpgWorkspaceKinds { if disc != nil { if _, ok := disc.GetGVRWithGroup(k.kind, k.group); !ok { - access[k.key] = cnpgKindAccess{state: cnpgCoverageNotInstalled} + access[k.key] = kindAccess{state: kindCoverageNotInstalled} continue } } - acc, denied, list := s.cnpgWorkspaceReadKind(r, cache, k, namespaces) - if acc.state != cnpgCoverageNotInstalled { + acc, list := s.cnpgWorkspaceReadKind(r, cache, k, namespaces) + if acc.state != kindCoverageNotInstalled { resp.Installed = true } access[k.key] = acc items[k.key] = list - resp.Coverage[k.key] = cnpgCoverageOf(acc, denied) + resp.Coverage[k.key] = acc.coverage() } if !resp.Installed { s.writeJSON(w, resp) @@ -236,9 +183,9 @@ func (s *Server) handleCNPGWorkspace(w http.ResponseWriter, r *http.Request) { resp.Objects[k.key] = out } - podAccess, podDenied, pods, instancePods := s.cnpgWorkspaceReadPods(r, cache, namespaces, cnpgClusterUIDs(items[cnpgWorkspaceClusterKey])) + podAccess, pods, instancePods := s.cnpgWorkspaceReadPods(r, cache, namespaces, cnpgClusterUIDs(items[cnpgWorkspaceClusterKey])) access[cnpgWorkspacePodsKey] = podAccess - resp.Coverage[cnpgWorkspacePodsKey] = cnpgCoverageOf(podAccess, podDenied) + resp.Coverage[cnpgWorkspacePodsKey] = podAccess.coverage() resp.Objects[cnpgWorkspacePodsKey] = pods resp.Issues = s.cnpgWorkspaceIssues(r, namespaces, access, instancePods) @@ -248,98 +195,8 @@ func (s *Server) handleCNPGWorkspace(w http.ResponseWriter, r *http.Request) { s.writeJSON(w, resp) } -// cnpgWorkspaceScope resolves where the caller may list one namespaced -// resource: nil allowed means the whole request scope. -// -// denied names namespaces only when the candidate set came from the caller — -// their view filter or their RBAC-allowed list. When the scope is "all" the -// candidates are every namespace in Radar's cache, and naming the denied ones -// would disclose namespaces the caller was never shown; partial then carries -// the fact without the names. -func (s *Server) cnpgWorkspaceScope(r *http.Request, namespaces []string, group, resource string) (allowed, denied []string, partial, any bool) { - if noNamespaceAccess(namespaces) { - return []string{}, nil, false, false - } - if s.canRead(r, group, resource, "", "list") { - return namespaces, nil, false, true - } - candidates := namespaces - if candidates == nil { - candidates = allNamespaceNames() - } - if len(candidates) == 0 { - return []string{}, nil, false, false - } - allowed = s.filterNamespacesByCanRead(r, group, resource, "list", candidates) - partial = len(allowed) < len(candidates) - if namespaces != nil { - for _, ns := range candidates { - if !slices.Contains(allowed, ns) { - denied = append(denied, ns) - } - } - sort.Strings(denied) - } - return allowed, denied, partial, len(allowed) > 0 -} - -func accessFromScope(allowed []string, partial bool) cnpgKindAccess { - acc := cnpgKindAccess{state: cnpgCoverageFull, all: allowed == nil} - if partial { - acc.state = cnpgCoveragePartial - } - if allowed != nil { - acc.namespaces = make(map[string]bool, len(allowed)) - for _, ns := range allowed { - acc.namespaces[ns] = true - } - } - return acc -} - -func (s *Server) cnpgWorkspaceReadKind(r *http.Request, cache *k8s.ResourceCache, k cnpgWorkspaceKind, namespaces []string) (cnpgKindAccess, []string, []*unstructured.Unstructured) { - var acc cnpgKindAccess - var denied, readNamespaces []string - if k.clusterScoped { - if !s.canRead(r, k.group, k.resource, "", "list") { - return cnpgKindAccess{state: cnpgCoverageDenied}, nil, nil - } - acc = cnpgKindAccess{state: cnpgCoverageFull, all: true} - } else { - allowed, d, partial, ok := s.cnpgWorkspaceScope(r, namespaces, k.group, k.resource) - if !ok { - return cnpgKindAccess{state: cnpgCoverageDenied}, nil, nil - } - acc, denied, readNamespaces = accessFromScope(allowed, partial), d, allowed - } - - list, err := readCNPGKind(r.Context(), cache, k, readNamespaces) - switch { - case err == nil: - return acc, denied, list - case errors.Is(err, k8s.ErrUnknownDynamicKind): - return cnpgKindAccess{state: cnpgCoverageNotInstalled}, nil, nil - case errors.Is(err, errDynamicNotSynced): - return cnpgKindAccess{state: cnpgCoverageSyncing}, nil, nil - default: - log.Printf("[cnpg] Failed to list %s.%s for workspace: %v", k.kind, k.group, err) - return cnpgKindAccess{state: cnpgCoverageError}, nil, nil - } -} - -func readCNPGKind(ctx context.Context, cache *k8s.ResourceCache, k cnpgWorkspaceKind, namespaces []string) ([]*unstructured.Unstructured, error) { - if namespaces == nil { - return filterCNPGGroup(listDynamicSynced(ctx, cache, k.kind, k.group, "")) - } - var out []*unstructured.Unstructured - for _, ns := range namespaces { - list, err := filterCNPGGroup(listDynamicSynced(ctx, cache, k.kind, k.group, ns)) - if err != nil { - return nil, err - } - out = append(out, list...) - } - return out, nil +func (s *Server) cnpgWorkspaceReadKind(r *http.Request, cache *k8s.ResourceCache, k workspaceKind, namespaces []string) (kindAccess, []*unstructured.Unstructured) { + return s.readWorkspaceKind(r, cache, k, namespaces, cnpgGroups) } // filterCNPGGroup drops anything whose apiVersion is not a CNPG group, so a @@ -348,17 +205,7 @@ func filterCNPGGroup(items []*unstructured.Unstructured, err error) ([]*unstruct if err != nil { return nil, err } - out := items[:0:0] - for _, u := range items { - if u == nil { - continue - } - if g := u.GroupVersionKind().Group; g != cnpgGroup && g != cnpgBarmanGroup { - continue - } - out = append(out, u) - } - return out, nil + return keepGroups(items, cnpgGroups), nil } func sortCNPGObjects(items []*unstructured.Unstructured) { @@ -513,50 +360,19 @@ func trimCNPGPod(p *corev1.Pod) cnpgWorkspacePod { return out } -// cnpgTypedScope resolves where the caller may list a typed kind and which of -// those namespaces Radar's informer actually holds. The informer may itself be -// namespace-scoped when Radar's own identity cannot list the kind -// cluster-wide; what it does not hold is unread, not empty. read is nil for -// "every namespace". -func (s *Server) cnpgTypedScope(r *http.Request, cache *k8s.ResourceCache, namespaces []string, group, resource string) (acc cnpgKindAccess, denied, read []string) { - allowed, denied, partial, ok := s.cnpgWorkspaceScope(r, namespaces, group, resource) - if !ok { - return cnpgKindAccess{state: cnpgCoverageDenied}, nil, []string{} - } - within := namespacesWithinCache(cache, resource, allowed) - if within.unavailable { - log.Printf("[cnpg] %s cache does not cover the requested scope", resource) - return cnpgKindAccess{state: cnpgCoverageError}, nil, []string{} - } - if allowed != nil { - for _, ns := range allowed { - if slices.Contains(within.namespaces, ns) { - continue - } - partial = true - if namespaces != nil { - denied = append(denied, ns) - } - } - sort.Strings(denied) - } - acc = accessFromScope(within.namespaces, partial || within.partial) - return acc, denied, within.namespaces -} - // cnpgWorkspaceReadPods returns the instance Pods of visible Clusters, plus // the namespace/name set of what it returned — the only Pods whose issues the // response may carry. -func (s *Server) cnpgWorkspaceReadPods(r *http.Request, cache *k8s.ResourceCache, namespaces []string, clusterUIDs map[string]types.UID) (cnpgKindAccess, []string, []any, map[string]bool) { +func (s *Server) cnpgWorkspaceReadPods(r *http.Request, cache *k8s.ResourceCache, namespaces []string, clusterUIDs map[string]types.UID) (kindAccess, []any, map[string]bool) { out := []any{} returned := map[string]bool{} - acc, denied, read := s.cnpgTypedScope(r, cache, namespaces, "", "pods") - if acc.state == cnpgCoverageDenied || acc.state == cnpgCoverageError { - return acc, nil, out, returned + acc, read := s.typedKindScope(r, cache, namespaces, "", "pods") + if acc.state == kindCoverageDenied || acc.state == kindCoverageError { + return acc, out, returned } if cache.Pods() == nil { log.Printf("[cnpg] Pod cache unavailable for workspace") - return cnpgKindAccess{state: cnpgCoverageError}, nil, out, returned + return kindAccess{state: kindCoverageError}, out, returned } pods := listPodsScoped(cache.Pods(), read) @@ -572,7 +388,7 @@ func (s *Server) cnpgWorkspaceReadPods(r *http.Request, cache *k8s.ResourceCache returned[p.Namespace+"/"+p.Name] = true } } - return acc, denied, out, returned + return acc, out, returned } var cnpgWorkspaceKeyByGroupKind = func() map[string]string { @@ -589,7 +405,7 @@ var cnpgWorkspaceKeyByGroupKind = func() map[string]string { // who may list Clusters but not Pods. A row is kept only when its own subject // is visible here — a CNPG kind covered in its namespace, or an instance Pod // this response returned. IDs are the subject-derived IDs /api/issues uses. -func (s *Server) cnpgWorkspaceIssues(r *http.Request, namespaces []string, access map[string]cnpgKindAccess, instancePods map[string]bool) []CNPGWorkspaceIssue { +func (s *Server) cnpgWorkspaceIssues(r *http.Request, namespaces []string, access map[string]kindAccess, instancePods map[string]bool) []CNPGWorkspaceIssue { out := []CNPGWorkspaceIssue{} if noNamespaceAccess(namespaces) { return out @@ -626,7 +442,7 @@ func (s *Server) cnpgWorkspaceIssues(r *http.Request, namespaces []string, acces return out } -func cnpgWorkspaceIssueVisible(iss issues.Issue, access map[string]cnpgKindAccess, instancePods map[string]bool) bool { +func cnpgWorkspaceIssueVisible(iss issues.Issue, access map[string]kindAccess, instancePods map[string]bool) bool { if iss.Group == "" && iss.Kind == "Pod" { return access[cnpgWorkspacePodsKey].covers(iss.Namespace) && instancePods[iss.Namespace+"/"+iss.Name] } @@ -643,7 +459,7 @@ func cnpgWorkspaceIssueVisible(iss issues.Issue, access map[string]cnpgKindAcces // cnpgWorkspaceAudit reports the declarative-backup posture finding only for // Clusters whose namespace had its ScheduledBackups read: without that list, // "no schedule targets this cluster" is an absence nobody established. -func cnpgWorkspaceAudit(clusters, scheduled []*unstructured.Unstructured, schedAccess cnpgKindAccess) []CNPGWorkspaceAuditFinding { +func cnpgWorkspaceAudit(clusters, scheduled []*unstructured.Unstructured, schedAccess kindAccess) []CNPGWorkspaceAuditFinding { out := []CNPGWorkspaceAuditFinding{} var subjects []*unstructured.Unstructured for _, c := range clusters { diff --git a/internal/server/cnpg_workspace_test.go b/internal/server/cnpg_workspace_test.go index f03a1de4c7..4ff69256dd 100644 --- a/internal/server/cnpg_workspace_test.go +++ b/internal/server/cnpg_workspace_test.go @@ -138,7 +138,7 @@ func TestCNPGWorkspace_NotInstalled(t *testing.T) { } assertEveryKey(t, got) for k, c := range got.Coverage { - if c.State != cnpgCoverageNotInstalled { + if c.State != kindCoverageNotInstalled { t.Errorf("coverage[%s] = %q, want notInstalled", k, c.State) } } @@ -216,7 +216,7 @@ func TestCNPGWorkspace_AuthDisabledReturnsEverythingAndOnlyOwnedInstancePods(t * } assertEveryKey(t, got) for k, c := range got.Coverage { - if c.State != cnpgCoverageFull { + if c.State != kindCoverageFull { t.Errorf("coverage[%s] = %+v, want full with auth disabled", k, c) } } @@ -297,7 +297,7 @@ func TestCNPGWorkspace_DeniedKindAndItsIssuesAreWithheld(t *testing.T) { } control := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "reader", "")) - if control.Coverage["backups"].State != cnpgCoverageFull || !containsName(control.Objects["backups"], "pg-orders-broken") { + if control.Coverage["backups"].State != kindCoverageFull || !containsName(control.Objects["backups"], "pg-orders-broken") { t.Fatalf("control: backups coverage=%+v objects=%v", control.Coverage["backups"], objectNames(control.Objects["backups"])) } if !hasBackupIssue(control) { @@ -305,7 +305,7 @@ func TestCNPGWorkspace_DeniedKindAndItsIssuesAreWithheld(t *testing.T) { } got := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "no-backups", "")) - if got.Coverage["backups"].State != cnpgCoverageDenied { + if got.Coverage["backups"].State != kindCoverageDenied { t.Errorf("backups coverage = %+v, want denied", got.Coverage["backups"]) } if len(got.Objects["backups"]) != 0 { @@ -314,7 +314,7 @@ func TestCNPGWorkspace_DeniedKindAndItsIssuesAreWithheld(t *testing.T) { if hasBackupIssue(got) { t.Error("an issue on a Backup the caller cannot list was returned") } - if got.Coverage["clusters"].State != cnpgCoverageFull || !containsName(got.Objects["clusters"], "pg-orders") { + if got.Coverage["clusters"].State != kindCoverageFull || !containsName(got.Objects["clusters"], "pg-orders") { t.Errorf("clusters coverage=%+v objects=%v, want full", got.Coverage["clusters"], objectNames(got.Objects["clusters"])) } } @@ -334,7 +334,7 @@ func TestCNPGWorkspace_PartialNamespaceCoverage(t *testing.T) { got := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "scoped", "")) cov := got.Coverage["clusters"] - if cov.State != cnpgCoveragePartial || len(cov.DeniedNamespaces) != 1 || cov.DeniedNamespaces[0] != "b" { + if cov.State != kindCoveragePartial || len(cov.DeniedNamespaces) != 1 || cov.DeniedNamespaces[0] != "b" { t.Errorf("clusters coverage = %+v, want partial denied [b]", cov) } if len(cov.AllowedNamespaces) != 1 || cov.AllowedNamespaces[0] != "a" { @@ -347,7 +347,7 @@ func TestCNPGWorkspace_PartialNamespaceCoverage(t *testing.T) { // A view filter narrows the scope; the denied list never grows past it. filtered := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace?namespaces=a", "scoped", "")) - if filtered.Coverage["clusters"].State != cnpgCoverageFull { + if filtered.Coverage["clusters"].State != kindCoverageFull { t.Errorf("filtered to a: coverage = %+v, want full", filtered.Coverage["clusters"]) } if len(filtered.Namespaces) != 1 || filtered.Namespaces[0] != "a" { @@ -370,12 +370,12 @@ func TestCNPGWorkspace_ClusterImageCatalogNeedsClusterScopeGrant(t *testing.T) { env.srv.permCache.Set("cluster-wide", nil, clusterWide) got := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "ns-only", "")) - if got.Coverage["clusterImageCatalogs"].State != cnpgCoverageDenied || len(got.Objects["clusterImageCatalogs"]) != 0 { + if got.Coverage["clusterImageCatalogs"].State != kindCoverageDenied || len(got.Objects["clusterImageCatalogs"]) != 0 { t.Errorf("namespace-level grant exposed ClusterImageCatalogs: %+v %v", got.Coverage["clusterImageCatalogs"], objectNames(got.Objects["clusterImageCatalogs"])) } got = decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace?namespaces=pg", "cluster-wide", "")) - if got.Coverage["clusterImageCatalogs"].State != cnpgCoverageFull || !containsName(got.Objects["clusterImageCatalogs"], "pg-fleet") { + if got.Coverage["clusterImageCatalogs"].State != kindCoverageFull || !containsName(got.Objects["clusterImageCatalogs"], "pg-fleet") { t.Errorf("cluster-scope grant: %+v %v, want full with pg-fleet regardless of the view filter", got.Coverage["clusterImageCatalogs"], objectNames(got.Objects["clusterImageCatalogs"])) } } @@ -450,7 +450,7 @@ func TestCNPGWorkspace_AuditNeedsScheduledBackupEvidence(t *testing.T) { } got = decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "no-schedules", "")) - if got.Coverage["scheduledBackups"].State != cnpgCoverageDenied { + if got.Coverage["scheduledBackups"].State != kindCoverageDenied { t.Errorf("scheduledBackups coverage = %+v, want denied", got.Coverage["scheduledBackups"]) } if len(got.Audit) != 0 { @@ -532,7 +532,7 @@ func TestCNPGWorkspace_PodEvidenceFollowsPodAccess(t *testing.T) { } got := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "clusters-only", "")) - if got.Coverage["pods"].State != cnpgCoverageDenied || len(got.Objects["pods"]) != 0 { + if got.Coverage["pods"].State != kindCoverageDenied || len(got.Objects["pods"]) != 0 { t.Errorf("pods coverage=%+v objects=%v, want denied and []", got.Coverage["pods"], objectNames(got.Objects["pods"])) } for _, iss := range got.Issues { @@ -560,7 +560,7 @@ func TestCNPGWorkspace_DeniedNamespacesNeverComeFromTheServerInventory(t *testin got := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "wide", "")) cov := got.Coverage["clusters"] - if cov.State != cnpgCoveragePartial { + if cov.State != kindCoveragePartial { t.Errorf("unfiltered: clusters coverage = %+v, want partial", cov) } if len(cov.DeniedNamespaces) != 0 { @@ -575,7 +575,7 @@ func TestCNPGWorkspace_DeniedNamespacesNeverComeFromTheServerInventory(t *testin got = decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace?namespaces=default,broken", "wide", "")) cov = got.Coverage["clusters"] - if cov.State != cnpgCoveragePartial || len(cov.DeniedNamespaces) != 1 || cov.DeniedNamespaces[0] != "broken" { + if cov.State != kindCoveragePartial || len(cov.DeniedNamespaces) != 1 || cov.DeniedNamespaces[0] != "broken" { t.Errorf("filtered: clusters coverage = %+v, want partial naming broken", cov) } if len(cov.AllowedNamespaces) != 1 || cov.AllowedNamespaces[0] != "default" { @@ -612,7 +612,7 @@ func TestCNPGWorkspace_ScheduleReadingsFollowScheduledBackupAccess(t *testing.T) } partial := decodeWorkspace(t, env.authGet(t, "/api/cnpg/workspace", "only-a", "")) - if partial.Coverage["scheduledBackups"].State != cnpgCoveragePartial { + if partial.Coverage["scheduledBackups"].State != kindCoveragePartial { t.Errorf("coverage = %+v, want partial", partial.Coverage["scheduledBackups"]) } if partial.ScheduleReadings["a/nightly-a"] == "" { diff --git a/internal/server/kind_access.go b/internal/server/kind_access.go new file mode 100644 index 0000000000..fc76c810b7 --- /dev/null +++ b/internal/server/kind_access.go @@ -0,0 +1,224 @@ +package server + +import ( + "context" + "errors" + "log" + "net/http" + "slices" + "sort" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/skyhook-io/radar/internal/k8s" +) + +// Coverage states for one kind a workspace lists. +const ( + kindCoverageFull = "full" + kindCoveragePartial = "partial" + kindCoverageDenied = "denied" + kindCoverageNotInstalled = "notInstalled" + kindCoverageSyncing = "syncing" + kindCoverageError = "error" +) + +// KindCoverage states how much of one kind the caller could see. +// DeniedNamespaces lists only namespaces already in the caller's scope, so it +// may be omitted on a partial state; AllowedNamespaces is always set on a +// partial state and is the authority for which namespaces were read. +type KindCoverage struct { + State string `json:"state"` + DeniedNamespaces []string `json:"deniedNamespaces,omitempty"` + AllowedNamespaces []string `json:"allowedNamespaces,omitempty"` +} + +// kindAccess is the resolved read scope for one kind. all means every +// namespace in the request's scope (or the cluster-scoped kind itself). +// denied names the in-scope namespaces left unread, under the disclosure rule +// of listScope. +type kindAccess struct { + state string + all bool + namespaces map[string]bool + denied []string +} + +func (a kindAccess) covers(namespace string) bool { + if a.state != kindCoverageFull && a.state != kindCoveragePartial { + return false + } + return a.all || a.namespaces[namespace] +} + +func (a kindAccess) coverage() KindCoverage { + cov := KindCoverage{State: a.state, DeniedNamespaces: a.denied} + if a.state == kindCoveragePartial { + cov.AllowedNamespaces = make([]string, 0, len(a.namespaces)) + for ns := range a.namespaces { + cov.AllowedNamespaces = append(cov.AllowedNamespaces, ns) + } + sort.Strings(cov.AllowedNamespaces) + } + return cov +} + +// listScope resolves where the caller may list one namespaced resource: nil +// allowed means the whole request scope. +// +// denied names namespaces only when the candidate set came from the caller — +// their view filter or their RBAC-allowed list. When the scope is "all" the +// candidates are every namespace in Radar's cache, and naming the denied ones +// would disclose namespaces the caller was never shown; partial then carries +// the fact without the names. +func (s *Server) listScope(r *http.Request, namespaces []string, group, resource string) (allowed, denied []string, partial, any bool) { + if noNamespaceAccess(namespaces) { + return []string{}, nil, false, false + } + if s.canRead(r, group, resource, "", "list") { + return namespaces, nil, false, true + } + candidates := namespaces + if candidates == nil { + candidates = allNamespaceNames() + } + if len(candidates) == 0 { + return []string{}, nil, false, false + } + allowed = s.filterNamespacesByCanRead(r, group, resource, "list", candidates) + partial = len(allowed) < len(candidates) + if namespaces != nil { + for _, ns := range candidates { + if !slices.Contains(allowed, ns) { + denied = append(denied, ns) + } + } + sort.Strings(denied) + } + return allowed, denied, partial, len(allowed) > 0 +} + +func accessFromScope(allowed []string, partial bool) kindAccess { + acc := kindAccess{state: kindCoverageFull, all: allowed == nil} + if partial { + acc.state = kindCoveragePartial + } + if allowed != nil { + acc.namespaces = make(map[string]bool, len(allowed)) + for _, ns := range allowed { + acc.namespaces[ns] = true + } + } + return acc +} + +// typedKindScope resolves where the caller may list a typed kind and which of +// those namespaces Radar's informer actually holds. The informer may itself be +// namespace-scoped when Radar's own identity cannot list the kind +// cluster-wide; what it does not hold is unread, not empty. read is nil for +// "every namespace". +func (s *Server) typedKindScope(r *http.Request, cache informerScope, namespaces []string, group, resource string) (acc kindAccess, read []string) { + allowed, denied, partial, ok := s.listScope(r, namespaces, group, resource) + if !ok { + return kindAccess{state: kindCoverageDenied}, []string{} + } + within := namespacesWithinCache(cache, resource, allowed) + if within.unavailable { + log.Printf("[workspace] %s cache does not cover the requested scope", resource) + return kindAccess{state: kindCoverageError}, []string{} + } + if allowed != nil { + for _, ns := range allowed { + if slices.Contains(within.namespaces, ns) { + continue + } + partial = true + if namespaces != nil { + denied = append(denied, ns) + } + } + sort.Strings(denied) + } + acc = accessFromScope(within.namespaces, partial || within.partial) + acc.denied = denied + return acc, within.namespaces +} + +// workspaceKind is one kind a workspace lists from Radar's dynamic cache. +type workspaceKind struct { + key string + group string + kind string + resource string + clusterScoped bool +} + +// readWorkspaceKind authorizes and lists one kind, keeping only objects of +// groups. A namespaced kind falls back to the namespaces the caller may list +// when a cluster-wide list is denied; a cluster-scoped kind needs the +// cluster-scope list. +func (s *Server) readWorkspaceKind(r *http.Request, cache *k8s.ResourceCache, k workspaceKind, namespaces, groups []string) (kindAccess, []*unstructured.Unstructured) { + var acc kindAccess + var readNamespaces []string + if k.clusterScoped { + if !s.canRead(r, k.group, k.resource, "", "list") { + return kindAccess{state: kindCoverageDenied}, nil + } + acc = kindAccess{state: kindCoverageFull, all: true} + } else { + allowed, denied, partial, ok := s.listScope(r, namespaces, k.group, k.resource) + if !ok { + return kindAccess{state: kindCoverageDenied}, nil + } + acc, readNamespaces = accessFromScope(allowed, partial), allowed + acc.denied = denied + } + + list, err := listKindInGroups(r.Context(), cache, k, readNamespaces, groups) + switch { + case err == nil: + return acc, list + case errors.Is(err, k8s.ErrUnknownDynamicKind): + return kindAccess{state: kindCoverageNotInstalled}, nil + case errors.Is(err, errDynamicNotSynced): + return kindAccess{state: kindCoverageSyncing}, nil + default: + log.Printf("[workspace] Failed to list %s.%s: %v", k.kind, k.group, err) + return kindAccess{state: kindCoverageError}, nil + } +} + +// listKindInGroups lists k in namespaces (nil = all), keeping only objects of +// groups. +func listKindInGroups(ctx context.Context, cache *k8s.ResourceCache, k workspaceKind, namespaces, groups []string) ([]*unstructured.Unstructured, error) { + if namespaces == nil { + list, err := listDynamicSynced(ctx, cache, k.kind, k.group, "") + if err != nil { + return nil, err + } + return keepGroups(list, groups), nil + } + var out []*unstructured.Unstructured + for _, ns := range namespaces { + list, err := listDynamicSynced(ctx, cache, k.kind, k.group, ns) + if err != nil { + return nil, err + } + out = append(out, keepGroups(list, groups)...) + } + return out, nil +} + +// keepGroups drops anything whose apiVersion is not one of groups, so an +// object of another group can never ride along on a kind-name match (a Velero +// Backup on a CloudNativePG Backup, a CAPI Cluster on a CNPG Cluster). +func keepGroups(items []*unstructured.Unstructured, groups []string) []*unstructured.Unstructured { + out := items[:0:0] + for _, u := range items { + if u == nil || !slices.Contains(groups, u.GroupVersionKind().Group) { + continue + } + out = append(out, u) + } + return out +} From afc3ed4504aab928865f041ba65513198a7c6efc Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 4 Oct 2026 01:15:21 +0300 Subject: [PATCH 06/26] Read grants as structured objects in the client A grant arrives as {verb, group, resource, subresource, namespace} instead of a sentence the client had to split back apart. formatGrant and grantParts in k8s-ui word it exactly as the server's Grant.String does, GrantText renders the parts without a regex, and every CloudNativePG type and message that carries a grant takes the object. --- .../k8s-ui/src/components/cnpg/ha.test.ts | 6 ++-- packages/k8s-ui/src/components/cnpg/ha.ts | 5 +-- .../k8s-ui/src/components/cnpg/pooler.test.ts | 2 +- packages/k8s-ui/src/components/cnpg/pooler.ts | 5 +-- .../components/cnpg/workspace-disk.test.ts | 2 +- .../cnpg/workspace-fleet-metrics.test.ts | 4 +-- .../k8s-ui/src/components/cnpg/workspace.ts | 11 +++--- packages/k8s-ui/src/utils/grant.test.ts | 18 ++++++++++ packages/k8s-ui/src/utils/grant.ts | 36 +++++++++++++++++++ packages/k8s-ui/src/utils/index.ts | 1 + web/src/api/cnpg-ha.test.ts | 10 +++--- web/src/api/cnpg-ha.ts | 4 +-- web/src/api/cnpg-history.ts | 4 +-- web/src/api/cnpg-recovery.ts | 3 +- web/src/api/cnpg-sessions.ts | 5 +-- web/src/api/cnpg-storage.ts | 4 +-- web/src/api/cnpg.ts | 8 ++--- .../cnpg/CNPGBlockingSessions.test.tsx | 2 +- .../components/cnpg/CNPGBlockingSessions.tsx | 6 ++-- .../components/cnpg/CNPGClusterRuntime.tsx | 4 +-- web/src/components/cnpg/CNPGStorage.tsx | 8 +++-- web/src/components/cnpg/CNPGTrends.tsx | 4 +-- .../cnpg/actions/CNPGClusterActions.tsx | 4 +-- .../cnpg/actions/actionModel.test.ts | 2 +- web/src/components/cnpg/grantText.test.tsx | 4 +-- web/src/components/cnpg/logicalSlots.test.ts | 2 +- web/src/components/cnpg/logicalSlots.ts | 3 +- .../components/cnpg/operations/model.test.ts | 2 +- web/src/components/cnpg/operations/model.ts | 3 +- .../cnpg/recovery/CNPGRestoreProgress.tsx | 4 +-- .../cnpg/recovery/restoreModel.test.ts | 4 +-- .../components/cnpg/recovery/restoreModel.ts | 10 +++--- web/src/components/cnpg/useCNPGPoolerLive.ts | 3 +- web/src/components/workspace/layout.tsx | 7 ++-- 34 files changed, 132 insertions(+), 68 deletions(-) create mode 100644 packages/k8s-ui/src/utils/grant.test.ts create mode 100644 packages/k8s-ui/src/utils/grant.ts diff --git a/packages/k8s-ui/src/components/cnpg/ha.test.ts b/packages/k8s-ui/src/components/cnpg/ha.test.ts index 569e522729..20d4f57773 100644 --- a/packages/k8s-ui/src/components/cnpg/ha.test.ts +++ b/packages/k8s-ui/src/components/cnpg/ha.test.ts @@ -96,7 +96,7 @@ describe('cnpgZoneSpread', () => { expect(s.sharedNode).toBe(true) }) it('is unknown, not single-zone, when Nodes are not readable', () => { - const s = cnpgZoneSpread(ha({ nodes: { state: 'denied', grant: 'get nodes' } })) + const s = cnpgZoneSpread(ha({ nodes: { state: 'denied', grant: { verb: 'get', resource: 'nodes' } } })) expect(s.known).toBe(false) expect(s.singleZone).toBe(false) }) @@ -118,7 +118,7 @@ describe('cnpgQuorumFact', () => { expect(cnpgQuorumFact(q({ status: { standbyNames: [], standbyNumber: 0 } })).text).toContain('no synchronous configuration recorded') }) it('an unreadable object is unknown', () => { - expect(cnpgQuorumFact(q({ object: { state: 'denied', grant: 'get failoverquorums' } })).tone).toBe('unknown') + expect(cnpgQuorumFact(q({ object: { state: 'denied', grant: { verb: 'get', group: 'postgresql.cnpg.io', resource: 'failoverquorums', namespace: 'db' } } })).tone).toBe('unknown') }) }) @@ -266,7 +266,7 @@ describe('folded HA and certificates summaries', () => { }) it('names what it could not read instead of reading calm', () => { - const denied = { state: 'denied' as const, grant: 'x' } + const denied = { state: 'denied' as const, grant: { verb: 'list', resource: 'pods', namespace: 'db' } } const unread = ha({ pods: denied, nodes: denied, pdbs: { ...denied, enabled: true, items: [] }, primaryLease: denied, operatorLease: denied, jobs: { ...denied, items: [] } } as never) expect(cnpgHASummary(unread, undefined)).toEqual({ text: 'Not read: Pods, zones, disruption budgets, primary lease, operator lease, Jobs, pending restarts', diff --git a/packages/k8s-ui/src/components/cnpg/ha.ts b/packages/k8s-ui/src/components/cnpg/ha.ts index 75587c5367..5271bf96a7 100644 --- a/packages/k8s-ui/src/components/cnpg/ha.ts +++ b/packages/k8s-ui/src/components/cnpg/ha.ts @@ -4,6 +4,7 @@ // unavailable source is "unknown", never none or healthy. import type { HealthLevel } from '../resources/resource-utils' +import { formatGrant, type Grant } from '../../utils/grant' import { cnpgFormatLag, cnpgLagTone, cnpgReplicationTone, cnpgSustainedLagProblemId, type CNPGFleetRow } from './workspace' import type { Fact, FoldSummary } from '../workspace' import { worseTone } from '../ui/status-tone' @@ -13,7 +14,7 @@ export type CNPGHASourceState = 'ok' | 'denied' | 'notFound' | 'notInstalled' | export interface CNPGHASource { state: CNPGHASourceState reason?: string - grant?: string + grant?: Grant } export interface CNPGHAInstance { @@ -145,7 +146,7 @@ export function cnpgHASourceText(src: CNPGHASource | undefined, what: string): s case 'ok': return '' case 'denied': - return `No access to ${what}${src.grant ? ` (needs ${src.grant})` : ''}` + return `No access to ${what}${src.grant ? ` (needs ${formatGrant(src.grant)})` : ''}` case 'notInstalled': return src.reason ?? `${what}: not available in this CloudNativePG version` case 'notFound': diff --git a/packages/k8s-ui/src/components/cnpg/pooler.test.ts b/packages/k8s-ui/src/components/cnpg/pooler.test.ts index 7434f5c1a2..ed4e1f1a9e 100644 --- a/packages/k8s-ui/src/components/cnpg/pooler.test.ts +++ b/packages/k8s-ui/src/components/cnpg/pooler.test.ts @@ -30,7 +30,7 @@ describe('observedPause', () => { expect(observedPause({ state: 'ok', pods: [{ pod: 'a', state: 'ok', paused: true }, { pod: 'b', state: 'ok', paused: true }] })?.text).toBe('Paused on 2 of 2 PgBouncers') expect(observedPause({ state: 'ok', pods: [{ pod: 'a', state: 'ok', paused: true }, { pod: 'b', state: 'ok', paused: false }] })?.level).toBe('alert') expect(observedPause({ state: 'ok', pods: [{ pod: 'a', state: 'ok', paused: false }, { pod: 'b', state: 'error' }] })?.text).toBe('Serving (not paused) on 1 of 2 PgBouncers · 1 not read') - expect(observedPause({ state: 'denied', grant: 'create pods/exec in namespace x', pods: [] })?.text).toContain('create pods/exec') + expect(observedPause({ state: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'x' }, pods: [] })?.text).toContain('create pods/exec') }) }) diff --git a/packages/k8s-ui/src/components/cnpg/pooler.ts b/packages/k8s-ui/src/components/cnpg/pooler.ts index f7b3f63058..b6e7a997d2 100644 --- a/packages/k8s-ui/src/components/cnpg/pooler.ts +++ b/packages/k8s-ui/src/components/cnpg/pooler.ts @@ -1,4 +1,5 @@ import type { HealthLevel } from '../resources/resource-utils' +import { formatGrant, type Grant } from '../../utils/grant' /** The Deployment a Pooler runs, as the host read it. */ export interface CNPGPoolerDeploymentLive { @@ -39,7 +40,7 @@ export interface CNPGPoolerPressureLive { /** Each PgBouncer's own SHOW STATE. */ export interface CNPGPoolerObservedLive { state: 'loading' | 'denied' | 'error' | 'ok' - grant?: string + grant?: Grant reason?: string pods: { pod: string; state: string; paused?: boolean; error?: string }[] } @@ -178,7 +179,7 @@ export function poolerBackendService(cluster: string | undefined, type: string | export function observedPause(o: CNPGPoolerObservedLive | undefined): { text: string; level: HealthLevel } | null { if (!o) return null if (o.state === 'loading') return { text: 'Reading…', level: 'unknown' } - if (o.state === 'denied') return { text: `Not observable: needs ${o.grant ?? 'create pods/exec'}`, level: 'unknown' } + if (o.state === 'denied') return { text: `Not observable: needs ${formatGrant(o.grant) ?? 'create pods/exec'}`, level: 'unknown' } if (o.state === 'error') return { text: `Not observable: ${o.reason ?? 'read failed'}`, level: 'unknown' } if (o.pods.length === 0) return { text: 'No PgBouncer Pods', level: 'unknown' } const read = o.pods.filter((p) => p.state === 'ok' && p.paused !== undefined) diff --git a/packages/k8s-ui/src/components/cnpg/workspace-disk.test.ts b/packages/k8s-ui/src/components/cnpg/workspace-disk.test.ts index 64e25b7e10..54850a0087 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace-disk.test.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace-disk.test.ts @@ -52,7 +52,7 @@ describe('cnpgDiskFact', () => { }) it('names the missing grant when denied', () => { - expect(cnpgDiskFact(reading('pg', { state: 'denied', grant: 'list persistentvolumeclaims in db', measured: 0 })).source).toBe('Needs list persistentvolumeclaims in db') + expect(cnpgDiskFact(reading('pg', { state: 'denied', grant: { verb: 'list', resource: 'persistentvolumeclaims', namespace: 'db' }, measured: 0 })).source).toBe('Needs list persistentvolumeclaims in namespace db') }) }) diff --git a/packages/k8s-ui/src/components/cnpg/workspace-fleet-metrics.test.ts b/packages/k8s-ui/src/components/cnpg/workspace-fleet-metrics.test.ts index 5fdba63953..d68a539d85 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace-fleet-metrics.test.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace-fleet-metrics.test.ts @@ -59,9 +59,9 @@ describe('applyCNPGFleetMetrics', () => { expect(row(f, 'dark').replication.tone).toBe('unknown') expect(row(f, 'solo').replication.text).toBe('Single instance') - const denied = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'denied', grant: 'get pods in db' })], { source: 'prometheus' }) + const denied = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'denied', grant: { verb: 'get', resource: 'pods', namespace: 'db' } })], { source: 'prometheus' }) expect(row(denied, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown') - expect(row(denied, 'ha').replication.source).toBe('Needs get pods in db') + expect(row(denied, 'ha').replication.source).toBe('Needs get pods in namespace db') const none = applyCNPGFleetMetrics(fleet(), undefined, { source: 'none', reason: 'Radar is not connected to Prometheus' }) expect(row(none, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown') diff --git a/packages/k8s-ui/src/components/cnpg/workspace.ts b/packages/k8s-ui/src/components/cnpg/workspace.ts index 0cef2dd842..92ff5464af 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace.ts @@ -6,6 +6,7 @@ import { formatAge, type HealthLevel } from '../resources/resource-utils' import { worseTone } from '../ui/status-tone' import type { Fact, ProblemOrigin, WorkspaceProblem } from '../workspace' import { formatBytes } from '../../utils/format' +import { formatGrant, type Grant } from '../../utils/grant' import { CNPG_BARMAN_PLUGIN_NAME, getCNPGClusterBackupConfig, @@ -1097,7 +1098,7 @@ export interface CNPGDiskReading { name: string /** ok | partial | noSeries | noPrometheus | denied | unavailable | error | notRead | ambiguous | scopeMismatch */ state: string - grant?: string + grant?: Grant reason?: string claims: number measured: number @@ -1150,7 +1151,7 @@ export function cnpgDiskFact(r: CNPGDiskReading | undefined): Fact { } switch (r.state) { case 'denied': - return { text: 'No access', tone: 'unknown', source: r.grant ? `Needs ${r.grant}` : r.reason } + return { text: 'No access', tone: 'unknown', source: r.grant ? `Needs ${formatGrant(r.grant)}` : r.reason } case 'noPrometheus': return { text: 'No usage metrics', tone: 'unknown', source: CNPG_PROMETHEUS_NOT_CONNECTED, detail: r.reason } case 'noSeries': @@ -1206,7 +1207,7 @@ export interface CNPGFleetMetricsReading { /** ok | noStandby | noSeries | denied | ambiguous | scopeMismatch | error | notRead */ lag: { state: string - grant?: string + grant?: Grant reason?: string seconds?: number pod?: string @@ -1217,7 +1218,7 @@ export interface CNPGFleetMetricsReading { isolation?: CNPGMetricIsolation } /** ok | noSeries | denied | unavailable | error | notRead */ - growth: { state: string; grant?: string; reason?: string; bytesPerHour?: number; claim?: string; instance?: string; isolation?: CNPGMetricIsolation } + growth: { state: string; grant?: Grant; reason?: string; bytesPerHour?: number; claim?: string; instance?: string; isolation?: CNPGMetricIsolation } } /** How Prometheus series were tied to one cluster; `unverified` matched only by namespace and Pod or claim names. */ @@ -1288,7 +1289,7 @@ function measuredReplication(base: Fact, reading: CNPGFleetMetricsReading | unde case 'noStandby': return { text: `${prefix} · lag unknown`, tone: 'unknown', source: `No standby reports lag: ${lag.reason ?? 'no instance reports being a standby'} · ${src.lagSource ?? 'Prometheus'}` } case 'denied': - return { text: `${prefix} · lag unknown`, tone: 'unknown', source: lag.grant ? `Needs ${lag.grant}` : lag.reason } + return { text: `${prefix} · lag unknown`, tone: 'unknown', source: lag.grant ? `Needs ${formatGrant(lag.grant)}` : lag.reason } } return { text: `${prefix} · lag unknown`, tone: 'unknown', source: lag?.reason ?? 'Replication lag needs Prometheus scraping the CNPG exporter' } } diff --git a/packages/k8s-ui/src/utils/grant.test.ts b/packages/k8s-ui/src/utils/grant.test.ts new file mode 100644 index 0000000000..e1a675e32e --- /dev/null +++ b/packages/k8s-ui/src/utils/grant.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { formatGrant } from './grant' + +describe('formatGrant', () => { + it('words a namespaced grant like the server', () => { + expect(formatGrant({ verb: 'patch', group: 'postgresql.cnpg.io', resource: 'clusters', subresource: 'status', namespace: 'pg' })).toBe( + 'patch clusters/status (postgresql.cnpg.io) in namespace pg', + ) + expect(formatGrant({ verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'pg' })).toBe('get pods/proxy in namespace pg') + }) + it('words a cluster-wide grant with the group as resource.group', () => { + expect(formatGrant({ verb: 'get', resource: 'nodes' })).toBe('get nodes cluster-wide') + expect(formatGrant({ verb: 'list', group: 'postgresql.cnpg.io', resource: 'clusters' })).toBe('list clusters.postgresql.cnpg.io cluster-wide') + }) + it('is undefined without a grant', () => { + expect(formatGrant(undefined)).toBeUndefined() + }) +}) diff --git a/packages/k8s-ui/src/utils/grant.ts b/packages/k8s-ui/src/utils/grant.ts new file mode 100644 index 0000000000..75f2a73014 --- /dev/null +++ b/packages/k8s-ui/src/utils/grant.ts @@ -0,0 +1,36 @@ +/** + * One RBAC permission a read or action needs, as the server sends it. An + * empty namespace means cluster-wide (a cluster-scoped resource, or a list + * across every namespace). + */ +export interface Grant { + verb: string + group?: string + resource: string + subresource?: string + namespace?: string +} + +function grantResource(g: Grant): string { + return g.subresource ? `${g.resource}/${g.subresource}` : g.resource +} + +/** + * The grant's two halves for display: what ("patch clusters/status + * (postgresql.cnpg.io)") and where (" in namespace pg", " cluster-wide"). + * Worded like the server's Grant.String so the two never disagree. + */ +export function grantParts(g: Grant): { what: string; scope: string } { + if (g.namespace) { + return { what: `${g.verb} ${grantResource(g)}${g.group ? ` (${g.group})` : ''}`, scope: ` in namespace ${g.namespace}` } + } + return { what: `${g.verb} ${grantResource(g)}${g.group ? `.${g.group}` : ''}`, scope: ' cluster-wide' } +} + +export function formatGrant(g: Grant): string +export function formatGrant(g: Grant | undefined): string | undefined +export function formatGrant(g: Grant | undefined): string | undefined { + if (!g) return undefined + const { what, scope } = grantParts(g) + return what + scope +} diff --git a/packages/k8s-ui/src/utils/index.ts b/packages/k8s-ui/src/utils/index.ts index 094d399c2a..5aa6c33207 100644 --- a/packages/k8s-ui/src/utils/index.ts +++ b/packages/k8s-ui/src/utils/index.ts @@ -32,3 +32,4 @@ export * from './bulk-workload-actions' export * from './inClusterConsent' export * from './workflow-execution' export * from './network-policy' +export * from './grant' diff --git a/web/src/api/cnpg-ha.test.ts b/web/src/api/cnpg-ha.test.ts index 68f7ab0f01..84c24edfd5 100644 --- a/web/src/api/cnpg-ha.test.ts +++ b/web/src/api/cnpg-ha.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { CNPGFleetRow } from '@skyhook-io/k8s-ui' +import type { CNPGFleetRow, Grant } from '@skyhook-io/k8s-ui' import type { CNPGRuntimeResponse } from './cnpg' import { cnpgInstanceLiveUnavailable, cnpgReplicationGap, withLiveReplication } from './cnpg-ha' @@ -40,9 +40,9 @@ describe('withLiveReplication', () => { }) describe('cnpgInstanceLiveUnavailable', () => { - const rt = (proxy: 'allowed' | 'denied', grant?: string) => ({ permission: { proxy, grant }, instances: [] }) as unknown as CNPGRuntimeResponse + const rt = (proxy: 'allowed' | 'denied', grant?: Grant) => ({ permission: { proxy, grant }, instances: [] }) as unknown as CNPGRuntimeResponse it('names the grant only when the proxy is denied', () => { - expect(cnpgInstanceLiveUnavailable(rt('denied', 'get pods/proxy in db'), undefined)).toBe('needs get pods/proxy in db') + expect(cnpgInstanceLiveUnavailable(rt('denied', { verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'db' }), undefined)).toBe('needs get pods/proxy in namespace db') expect(cnpgInstanceLiveUnavailable(rt('allowed'), undefined)).toBeUndefined() expect(cnpgInstanceLiveUnavailable(undefined, undefined)).toBe('instance managers not read yet') expect(cnpgInstanceLiveUnavailable(undefined, new Error('boom'))).toBe('the runtime read failed: boom') @@ -56,8 +56,8 @@ describe('cnpgReplicationGap', () => { 'pg-1 did not report (PostgreSQL is not running on this instance)', ) expect(cnpgReplicationGap(rt([{ pod: 'pg-1', role: 'primary', status: { state: 'ok', replication: [] } }]), undefined)).toBeUndefined() - expect(cnpgReplicationGap({ permission: { proxy: 'denied', grant: 'get pods/proxy in db' }, instances: [] } as unknown as CNPGRuntimeResponse, undefined)).toBe( - 'needs get pods/proxy in db', + expect(cnpgReplicationGap({ permission: { proxy: 'denied', grant: { verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'db' } }, instances: [] } as unknown as CNPGRuntimeResponse, undefined)).toBe( + 'needs get pods/proxy in namespace db', ) }) }) diff --git a/web/src/api/cnpg-ha.ts b/web/src/api/cnpg-ha.ts index 689555922e..6d42bf4338 100644 --- a/web/src/api/cnpg-ha.ts +++ b/web/src/api/cnpg-ha.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { cnpgFormatLag, cnpgReplicationTone, type CNPGClusterHA, type CNPGFleetRow, type CNPGInstanceLive, type CNPGReplicationLive } from '@skyhook-io/k8s-ui' +import { cnpgFormatLag, cnpgReplicationTone, formatGrant, type CNPGClusterHA, type CNPGFleetRow, type CNPGInstanceLive, type CNPGReplicationLive } from '@skyhook-io/k8s-ui' import { fetchJSON } from './client' import type { CNPGRuntimeResponse } from './cnpg' @@ -36,7 +36,7 @@ export function cnpgInstanceLive(rt: CNPGRuntimeResponse | undefined): CNPGInsta /** Why the HA section has no instance-manager facts at all; undefined when it has them. */ export function cnpgInstanceLiveUnavailable(rt: CNPGRuntimeResponse | undefined, error: unknown): string | undefined { - if (rt?.permission.proxy === 'denied') return `needs ${rt.permission.grant ?? 'get pods/proxy'}` + if (rt?.permission.proxy === 'denied') return `needs ${formatGrant(rt.permission.grant) ?? 'get pods/proxy'}` if (rt) return undefined return error instanceof Error ? `the runtime read failed: ${error.message}` : 'instance managers not read yet' } diff --git a/web/src/api/cnpg-history.ts b/web/src/api/cnpg-history.ts index 9a89b59e7a..0a92734b9f 100644 --- a/web/src/api/cnpg-history.ts +++ b/web/src/api/cnpg-history.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { CNPGFleetMetricsReading } from '@skyhook-io/k8s-ui' +import type { CNPGFleetMetricsReading, Grant } from '@skyhook-io/k8s-ui' import { fetchJSON } from './client' import type { CNPGClusterActivityResponse } from './cnpg' @@ -33,7 +33,7 @@ export interface CNPGHistoryChart { /** ok | empty (scraped, nothing to plot) | noSeries (not scraped) | denied | error | notRead */ state: string reason?: string - grant?: string + grant?: Grant thresholds?: { value: number; label: string }[] series: CNPGHistorySeries[] omitted?: number diff --git a/web/src/api/cnpg-recovery.ts b/web/src/api/cnpg-recovery.ts index f34575c26e..02b9425a91 100644 --- a/web/src/api/cnpg-recovery.ts +++ b/web/src/api/cnpg-recovery.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { Grant } from '@skyhook-io/k8s-ui' import { apiFetch, ApiError, fetchJSON } from './client' import type { CNPGActionCapability } from './cnpg' import { getApiBase } from './config' @@ -7,7 +8,7 @@ export type CNPGReadState = 'ok' | 'denied' | 'notFound' | 'error' | 'skipped' | export interface CNPGReadCoverage { state: CNPGReadState - grant?: string + grant?: Grant reason?: string } diff --git a/web/src/api/cnpg-sessions.ts b/web/src/api/cnpg-sessions.ts index 747bb9d2ea..f94f2e056d 100644 --- a/web/src/api/cnpg-sessions.ts +++ b/web/src/api/cnpg-sessions.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import type { Grant } from '@skyhook-io/k8s-ui' import { fetchJSON } from './client' import type { CNPGActionCapability, CNPGClusterFacts, CNPGRuntimeSourceState } from './cnpg' @@ -40,7 +41,7 @@ export interface CNPGSessionsResponse { podUID?: string role?: string sampledAt: string - permission: { exec: 'allowed' | 'denied' | 'unknown'; grant: string } + permission: { exec: 'allowed' | 'denied' | 'unknown'; grant?: Grant } state: CNPGRuntimeSourceState error?: string capturedAt?: string @@ -149,7 +150,7 @@ export function useCNPGPoolerCapabilities(namespace: string, name: string, enabl export interface CNPGPgBouncerStateResponse { pooler: { namespace: string; name: string; uid: string } sampledAt: string - permission: { exec: 'allowed' | 'denied' | 'unknown'; grant: string } + permission: { exec: 'allowed' | 'denied' | 'unknown'; grant?: Grant } pods: { pod: string; state: CNPGRuntimeSourceState; error?: string; paused?: boolean; suspended?: boolean; active?: boolean }[] } diff --git a/web/src/api/cnpg-storage.ts b/web/src/api/cnpg-storage.ts index 0bccaa9f49..0ab8ee0830 100644 --- a/web/src/api/cnpg-storage.ts +++ b/web/src/api/cnpg-storage.ts @@ -1,12 +1,12 @@ import { useQuery } from '@tanstack/react-query' -import type { CNPGDiskReading, CNPGMetricIsolation } from '@skyhook-io/k8s-ui' +import type { CNPGDiskReading, CNPGMetricIsolation, Grant } from '@skyhook-io/k8s-ui' import { fetchJSON } from './client' export type CNPGPVCRole = 'PG_DATA' | 'PG_WAL' | 'PG_TABLESPACE' export interface CNPGStorageCoverage { state: string - grant?: string + grant?: Grant reason?: string /** On Prometheus usage: how the series were tied to this cluster. */ isolation?: CNPGMetricIsolation diff --git a/web/src/api/cnpg.ts b/web/src/api/cnpg.ts index 2278ad8899..ce234342be 100644 --- a/web/src/api/cnpg.ts +++ b/web/src/api/cnpg.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { CNPGSchedulePreview, CNPGWorkspaceResponse, TimelineEvent } from '@skyhook-io/k8s-ui' +import type { CNPGSchedulePreview, CNPGWorkspaceResponse, Grant, TimelineEvent } from '@skyhook-io/k8s-ui' import { ApiError, fetchJSON } from './client' import type { CNPGOperatorDiagnosis } from './cnpg-recovery' @@ -129,7 +129,7 @@ export interface CNPGActionCapability { allowed: boolean reason?: string permission: 'allowed' | 'denied' | 'unknown' - grant?: string + grant?: Grant } export interface CNPGInstanceFact { @@ -455,7 +455,7 @@ export interface CNPGRuntimeInstance { export interface CNPGRuntimeResponse { cluster: { namespace: string; name: string; uid: string } sampledAt: string - permission: { proxy: 'allowed' | 'denied'; grant?: string } + permission: { proxy: 'allowed' | 'denied'; grant?: Grant } instances: CNPGRuntimeInstance[] } @@ -476,7 +476,7 @@ export function useCNPGRuntime(namespace: string, name: string, enabled = true) export interface CNPGPoolerRuntimeResponse { pooler: { namespace: string; name: string; uid: string } sampledAt: string - permission: { proxy: 'allowed' | 'denied'; grant?: string } + permission: { proxy: 'allowed' | 'denied'; grant?: Grant } pods: { pod: string state: CNPGRuntimeSourceState diff --git a/web/src/components/cnpg/CNPGBlockingSessions.test.tsx b/web/src/components/cnpg/CNPGBlockingSessions.test.tsx index 666835af64..ae26006c4a 100644 --- a/web/src/components/cnpg/CNPGBlockingSessions.test.tsx +++ b/web/src/components/cnpg/CNPGBlockingSessions.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('../../api/cnpg-sessions', () => ({ useCNPGSessions: () => ({ - data: { pod: 'pg-1', state: 'denied', permission: { exec: 'denied', grant: 'create pods/exec in db' }, instances: [] }, + data: { pod: 'pg-1', state: 'denied', permission: { exec: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'db' } }, instances: [] }, isLoading: false, error: null, isRefetchError: false, diff --git a/web/src/components/cnpg/CNPGBlockingSessions.tsx b/web/src/components/cnpg/CNPGBlockingSessions.tsx index 36a66d4779..5d0e95e86b 100644 --- a/web/src/components/cnpg/CNPGBlockingSessions.tsx +++ b/web/src/components/cnpg/CNPGBlockingSessions.tsx @@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react' import { useQueries } from '@tanstack/react-query' import { clsx } from 'clsx' import { Lock } from 'lucide-react' -import { ActionConfirmDialog, PaneLoader, Tooltip, formatAge, toneFillClass, toneTextClass } from '@skyhook-io/k8s-ui' +import { ActionConfirmDialog, PaneLoader, Tooltip, formatAge, toneFillClass, toneTextClass, formatGrant } from '@skyhook-io/k8s-ui' import { formatCPUString, formatMemoryString, parseCPUToNanocores, parseMemoryToBytes } from '@skyhook-io/k8s-ui/utils/format' import { podMetricsQuery, usePodMetrics } from '../../api/client' import { cnpgActionOutcomeLocked, useCNPGAction, useCNPGClusterCapabilities } from '../../api/cnpg' @@ -82,7 +82,7 @@ function Body({
    - Blocking detail needs {data.permission.grant} + Blocking detail needs {formatGrant(data.permission.grant) ?? 'create pods/exec'}

    Who blocks whom is read inside PostgreSQL, which needs exec into the instance.{' '} @@ -341,7 +341,7 @@ function SignalDialog({ typedConfirmation={terminate ? String(session.pid) : undefined} confirmLabel={terminate ? 'Terminate backend' : 'Stop query'} disruptive={terminate} - disabledReason={caps.data && data.permission.exec === 'denied' ? `Needs ${data.permission.grant}` : undefined} + disabledReason={caps.data && data.permission.exec === 'denied' ? `Needs ${formatGrant(data.permission.grant) ?? 'create pods/exec'}` : undefined} incompleteReason={!caps.data ? 'Reading the cluster…' : undefined} isLoading={mutation.isPending} error={mutation.error?.message} diff --git a/web/src/components/cnpg/CNPGClusterRuntime.tsx b/web/src/components/cnpg/CNPGClusterRuntime.tsx index 16733fa863..235445244c 100644 --- a/web/src/components/cnpg/CNPGClusterRuntime.tsx +++ b/web/src/components/cnpg/CNPGClusterRuntime.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { useLocation, useSearchParams } from 'react-router-dom' import { clsx } from 'clsx' import { Lock } from 'lucide-react' -import { PaneLoader, Tooltip, formatAge, toneTextClass } from '@skyhook-io/k8s-ui' +import { PaneLoader, Tooltip, formatAge, toneTextClass, formatGrant } from '@skyhook-io/k8s-ui' import { useCNPGRuntime, type CNPGRuntimeInstance } from '../../api/cnpg' import { useCNPGSessions, type CNPGSessionsResponse } from '../../api/cnpg-sessions' import { useCNPGClusterHistory } from '../../api/cnpg-history' @@ -85,7 +85,7 @@ export function CNPGClusterRuntime({ } const data = q.data const denied = data.permission.proxy === 'denied' - const grant = data.permission.grant ?? `get pods/proxy in ${namespace}` + const grant = formatGrant(data.permission.grant) ?? `get pods/proxy in namespace ${namespace}` const primary = data.instances.find((i) => i.role === 'primary') const replicas = data.instances.filter((i) => i.role !== 'primary') // Sessions and Transactions read one instance's exporter; the primary diff --git a/web/src/components/cnpg/CNPGStorage.tsx b/web/src/components/cnpg/CNPGStorage.tsx index 5a19ec10b6..2f880af4d2 100644 --- a/web/src/components/cnpg/CNPGStorage.tsx +++ b/web/src/components/cnpg/CNPGStorage.tsx @@ -16,6 +16,8 @@ import { parseQuantityToNumber, toneFillClass, toneTextClass, + formatGrant, + type Grant, } from '@skyhook-io/k8s-ui' import { useResource } from '../../api/client' import type { CNPGRuntimeInstance } from '../../api/cnpg' @@ -317,7 +319,7 @@ function InstanceCard({ inst, walCoverage, stated }: { inst: CNPGStorageInstance walCoverage.state === 'ok' ? (

    No running instance to read
    ) : ( - +
    Unknown
    ) @@ -328,11 +330,11 @@ function InstanceCard({ inst, walCoverage, stated }: { inst: CNPGStorageInstance } // "