diff --git a/CLAUDE.md b/CLAUDE.md index 2f7371dac8..932bf21d03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Not everything is in this file. The following files contain critical details tha | Adding or modifying **HTTP endpoints** | `internal/server/server.go` — all routes are defined here — **plus** the handler's doc comments (why the route is gated the way it is lives there; copy the gate of the closest sibling only after reading it) and the integration's section in [docs/integrations.md](docs/integrations.md) | | Adding or modifying **CLI flags** | `cmd/explorer/main.go` — flag definitions and defaults | | Adding a **new CRD integration** (renderer, topology, discovery) | [docs/INTEGRATION_GUIDE.md](docs/INTEGRATION_GUIDE.md) — full checklist with collision gotchas | +| Building a **workspace integration** (several related CRDs with their own screens, like `/cnpg` or `/capacity`) | [docs/INTEGRATION_GUIDE.md](docs/INTEGRATION_GUIDE.md#3-workspace-integrations) — the shared server, UI and action pieces to import, and what is not shared yet; [DESIGN.md](DESIGN.md#unknown-partial-and-denied-values) — how unknown, partial and denied values read | | Working on the **CloudNativePG workspace** (`/cnpg`) | [docs/cnpg.md](docs/cnpg.md) — destinations, navigation (drawer trail, return label, `ctx` guard) and the certainty table: which source each fact comes from and what it reads when unknown. Data from `/api/cnpg/workspace` (per-kind coverage); derivations in `packages/k8s-ui/src/components/cnpg/workspace.ts` + `relations.ts`; screens in `web/src/components/cnpg/` | | Working on **local per-cluster integration settings** (Metrics, Argo CD, Cost in `~/.radar/clusters.json`) | [docs/configuration.md](docs/configuration.md#local-integration-connections) — store `internal/config/profiles.go`, resolve/update `internal/connections`, activation `internal/connectionruntime`, routes `GET/PUT /api/integrations/connections`. In local mode the older `PUT /api/integrations/{prometheus,argocd,cost}` return 409 | | Working on **GitOps** (Argo CD / Flux detail pages, operations, Terminating lifecycle, drift, per-resource health, remote destinations) | [docs/gitops.md](docs/gitops.md) — detail-page tabs, operation semantics, the Terminating severity ramp, nested navigation, single-cluster scope. Engine in `pkg/gitops/`, handlers `internal/server/gitops_handlers.go` | diff --git a/DESIGN.md b/DESIGN.md index 0241ad5e62..8489e617e1 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 +62,7 @@ function DimensionChips({ dimensions, onSelect }: { dimensions: CNPGDimension[]; {dimensions.map((d) => { const body = ( <> - + {d.label} {d.text} @@ -127,7 +118,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 +150,7 @@ export function CNPGClusterSummary({ {dimensions && dimensions.length > 0 && } {top && ( 0 && !problemsLink && (
- +
)} @@ -181,18 +173,18 @@ export function CNPGClusterSummary({ {actions && actions.length > 0 && (
{actions.map((a) => ( - ))}
)} - State + State - + {row.controllerStatus.text} @@ -263,15 +255,15 @@ export function CNPGClusterSummary({ )} - {row.gitops && ( + {row.managedBy && managedByLabel(row.managedBy) && ( - {row.gitops.tool === 'argocd' ? 'Argo CD' : 'Flux'} {row.gitops.name} + )} {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..20057f9e3b 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGConnectSection.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGConnectSection.tsx @@ -3,7 +3,9 @@ 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 } from '../facts' +import { SectionHeading } from '../ui/FoldSection' function CopyButton({ text, label }: { text: string; label: string }) { const [copied, setCopied] = useState(false) @@ -57,8 +59,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 +70,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 +79,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..2f402c0646 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGDeclarativeSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGDeclarativeSummary.tsx @@ -1,16 +1,15 @@ import type { ReactNode } from 'react' import { getCNPGDeclarativeMessage, getCNPGReclaimPolicy } from '../resources/resource-utils-cnpg' -import type { CNPGFact, CNPGWorkspaceResponse } from './workspace' +import { cnpgManagedBy, type CNPGWorkspaceResponse } from './workspace' +import { type Fact } from '../facts' 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, clustersIn, databaseForDeclaration, - gitopsSourceOf, missingManagedRole, observedGenerationFact, refOf, @@ -19,11 +18,15 @@ import { targetCluster, workspaceList, } from './relations' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, FactValue, ManagedByText, managedByLabel } from '../facts' +import { SectionHeading } from '../ui/FoldSection' 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 @@ -65,14 +68,10 @@ function Reconciled({ resource, extra }: { resource: any; extra?: ReactNode }) { ) } -function DeclaredIn({ resource }: { resource: any }) { - const src = gitopsSourceOf(resource) - if (!src) return - return ( - - {src.tool === 'argocd' ? 'Argo CD application' : 'Flux'} {src.namespace ? `${src.namespace}/${src.name}` : src.name} - - ) +function DeclaredIn({ resource, workspace, onNavigate }: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: NavigateToRef }) { + const manager = cnpgManagedBy(workspace, resource) + if (!manager || !managedByLabel(manager)) return + return } function DatabaseRef({ resource, workspace, onNavigate }: SummaryProps) { @@ -95,7 +94,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 +116,7 @@ export function CNPGDatabaseSummary({ resource, workspace, onNavigate }: Summary - Declared + Declared {resource?.spec?.name ? {resource.spec.name} : } @@ -140,10 +139,10 @@ export function CNPGDatabaseSummary({ resource, workspace, onNavigate }: Summary } /> - Source and target + Source and target - + @@ -203,7 +202,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 +225,7 @@ export function CNPGPublicationSummary({ - Declared + Declared {resource?.spec?.name ? {resource.spec.name} : } @@ -240,13 +239,13 @@ export function CNPGPublicationSummary({ {publicationTargets(resource)} - + - 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 +270,7 @@ export function CNPGDatabaseRoleSummary({ resource, workspace, onNavigate }: Sum - Declared + Declared {f.pgName ? {f.pgName} : } @@ -310,7 +309,7 @@ export function CNPGDatabaseRoleSummary({ resource, workspace, onNavigate }: Sum - + @@ -350,7 +349,7 @@ export function CNPGSubscriptionSummary({ - Declared + Declared {resource?.spec?.name ? {resource.spec.name} : } @@ -378,7 +377,7 @@ export function CNPGSubscriptionSummary({ - + @@ -386,7 +385,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..a17740c498 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGImageCatalogSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGImageCatalogSummary.tsx @@ -1,8 +1,11 @@ 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 } from '../facts' +import { SectionHeading } from '../ui/FoldSection' export function CNPGImageCatalogSummary({ resource, @@ -11,7 +14,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 +30,7 @@ export function CNPGImageCatalogSummary({ - Images + Images {entries.length === 0 ? (
    @@ -42,7 +45,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..0364ad606a 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 '../facts' 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 '../facts' // 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..801ea1936d 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGObjectStoreSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGObjectStoreSummary.tsx @@ -8,9 +8,12 @@ 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 } from '../facts' +import { SectionHeading } from '../ui/FoldSection' function utc(at: string | undefined): string { if (!at || !Number.isFinite(Date.parse(at))) return 'unknown' @@ -24,7 +27,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 +49,7 @@ export function CNPGObjectStoreSummary({ onNavigate={onNavigate} /> - Upload health + Upload health {clustersUnavailable ? (
    @@ -89,7 +92,7 @@ export function CNPGObjectStoreSummary({ )} - Recovery window + Recovery window {windows.length === 0 ? (
    @@ -136,7 +139,7 @@ export function CNPGObjectStoreSummary({ )} - Destination + Destination {destination !== '-' ? {destination} : } {provider ?? } @@ -152,7 +155,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..60338eb781 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,10 @@ import { type CNPGPoolerLive, type CNPGPoolerPoolRow, } from './pooler' +import { type NavigateToRef, RefLink } from '../ui/RefLink' +import { toneTextClass } from '../ui/status-tone' +import { FactGrid, FactRow, FactValue } from '../facts' +import { SectionHeading } from '../ui/FoldSection' const TYPE_LABEL: Record = { rw: 'rw · routes to the primary', @@ -31,7 +34,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 +56,7 @@ export function CNPGPoolerSummary({ {lead} - State + State {readiness ? ( @@ -102,7 +105,7 @@ export function CNPGPoolerSummary({ - Connections + Connections {live?.pressure ? ( ) : ( @@ -113,7 +116,7 @@ export function CNPGPoolerSummary({ )} - Limits + Limits {resource?.spec?.pgbouncer?.poolMode ? getCNPGPoolerMode(resource) : session (default)} @@ -137,7 +140,7 @@ export function CNPGPoolerSummary({ })} - Routing + Routing @@ -153,7 +156,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..6bfba39fb4 100644 --- a/packages/k8s-ui/src/components/cnpg/CNPGSharedSummary.tsx +++ b/packages/k8s-ui/src/components/cnpg/CNPGSharedSummary.tsx @@ -3,8 +3,11 @@ 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 } from '../facts' +import { ProblemCallout } from '../problems' const MAX_PROBLEMS = 3 @@ -20,7 +23,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 +33,7 @@ export function ObjectProblems({
    {shown.map((p, i) => ( + {status.text} ) @@ -71,7 +75,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.test.ts b/packages/k8s-ui/src/components/cnpg/ha.test.ts index 569e522729..06ae643cee 100644 --- a/packages/k8s-ui/src/components/cnpg/ha.test.ts +++ b/packages/k8s-ui/src/components/cnpg/ha.test.ts @@ -70,7 +70,6 @@ function row(over: Partial = {}): CNPGFleetRow { problems: [], attention: false, categories: new Set(), - gitops: null, ...over, } } @@ -96,7 +95,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 +117,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 +265,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 4e905655b8..8e196fd827 100644 --- a/packages/k8s-ui/src/components/cnpg/ha.ts +++ b/packages/k8s-ui/src/components/cnpg/ha.ts @@ -4,14 +4,18 @@ // 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 { formatGrant, type Grant } from '../../utils/grant' +import { cnpgFormatLag, cnpgLagTone, cnpgReplicationTone, cnpgSustainedLagProblemId, type CNPGFleetRow } from './workspace' +import { type Fact } from '../facts' +import { type FoldSummary } from '../ui/FoldSection' +import { worseTone } from '../ui/status-tone' export type CNPGHASourceState = 'ok' | 'denied' | 'notFound' | 'notInstalled' | 'unavailable' | 'error' export interface CNPGHASource { state: CNPGHASourceState reason?: string - grant?: string + grant?: Grant } export interface CNPGHAInstance { @@ -143,7 +147,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': @@ -209,7 +213,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 +241,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 +288,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 +297,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 +347,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 +480,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..3e03e5ff8c 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 '../facts' /** 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/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/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.test.ts b/packages/k8s-ui/src/components/cnpg/relations.test.ts index 437dafbc70..9bffa5ec25 100644 --- a/packages/k8s-ui/src/components/cnpg/relations.test.ts +++ b/packages/k8s-ui/src/components/cnpg/relations.test.ts @@ -5,7 +5,6 @@ import { backupsForScheduledBackup, clustersUsingCatalog, databaseForDeclaration, - gitopsSourceOf, inferredObjectStoreHealth, isBackupFromSchedule, issuesForObject, @@ -254,16 +253,6 @@ describe('declarations', () => { expect(missingManagedRole({ status: { message: 'connection refused' } }, cluster('main'))).toBeNull() }) - it('reads the GitOps owner labels', () => { - expect(gitopsSourceOf({ metadata: { labels: { 'argocd.argoproj.io/instance': 'app' } } })).toEqual({ tool: 'argocd', name: 'app' }) - expect(gitopsSourceOf({ metadata: { labels: { 'kustomize.toolkit.fluxcd.io/name': 'k', 'kustomize.toolkit.fluxcd.io/namespace': 'flux' } } })).toEqual({ - tool: 'flux', - name: 'k', - namespace: 'flux', - }) - expect(gitopsSourceOf({ metadata: {} })).toBeNull() - }) - const decl = (kind: string, name: string, clusterName: string, dbname: string, ns = 'pg') => ({ apiVersion: PG, kind, diff --git a/packages/k8s-ui/src/components/cnpg/relations.ts b/packages/k8s-ui/src/components/cnpg/relations.ts index 3543c1b198..08c8009336 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, cnpgCoverageGap, coverageReadable, type CNPGProblem, type CNPGWorkspaceIssue, type CNPGWorkspaceKey, type CNPGWorkspaceResponse } from './workspace' +import { type Fact } from '../facts' 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 // --------------------------------------------------------------------------- @@ -94,17 +70,7 @@ export function relationUnavailable( if (!ws) return `${what} could not be read` const cov = ws.coverage?.[key] ?? { state: 'notInstalled' as const } if (coverageReadable(cov, namespace)) return null - switch (cov.state) { - case 'denied': - case 'partial': - return `No access to ${what}` - case 'syncing': - return 'Loading…' - case 'error': - return `Could not read ${what}` - default: - return `${what} are not installed` - } + return cnpgCoverageGap(cov, what, namespace, `${what} are not installed`) } export function clustersIn(ws: CNPGWorkspaceResponse | null | undefined): any[] { @@ -256,16 +222,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 +285,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' } @@ -351,7 +317,6 @@ export function missingManagedRole(obj: any, cluster: any | null): string | null return names.includes(m[1]) ? null : m[1] } -export { cnpgGitOpsSource as gitopsSourceOf } from './workspace' /** Publications and Subscriptions on the same Cluster and PostgreSQL database. */ export function replicationForDatabase( 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-problems.test.ts b/packages/k8s-ui/src/components/cnpg/workspace-problems.test.ts index dfdddf1b2a..e5d441536c 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace-problems.test.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace-problems.test.ts @@ -45,7 +45,7 @@ describe('cnpgIssueText', () => { message: 'The last WAL archival did not complete; recovery-point advancement is uncertain: unexpected failure invoking barman-cloud-wal-archive: exit status 4', }) - expect(t.title).toBe('WAL archiving is failing') + expect(t.title).toBe('WAL archiving failing') expect(t.detail).toContain('exit status 4') }) }) diff --git a/packages/k8s-ui/src/components/cnpg/workspace.test.ts b/packages/k8s-ui/src/components/cnpg/workspace.test.ts index 0d9cc6f3c0..c4391088aa 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace.test.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace.test.ts @@ -298,7 +298,18 @@ describe('buildCNPGFleet', () => { const named = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { backups: { state: 'partial', allowedNamespaces: ['db'] } } })) expect(named.rows[0].protection.lastSuccessfulBackup.text).toBe('None observed') const unnamed = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { backups: { state: 'partial' } } })) - expect(unnamed.rows[0].protection.lastSuccessfulBackup.text).toBe('No access to Backups') + expect(unnamed.rows[0].protection.lastSuccessfulBackup.text).toBe('Backups not read in db') + }) + + it('names the cause of a partial read only when the server names the namespace', () => { + const denied = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { backups: { state: 'partial', allowedNamespaces: ['other'], deniedNamespaces: ['db'] } } })) + expect(denied.rows[0].protection.lastSuccessfulBackup.text).toBe('No access to Backups') + const uncached = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'partial', allowedNamespaces: ['other'], uncachedNamespaces: ['db'] } } })) + expect(uncached.rows[0].replication.text).toBe('Radar does not cache Pods in db') + const none = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'uncached' } } })) + expect(none.rows[0].replication.text).toBe('Radar does not cache Pods') + const mixed = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'uncached', uncachedNamespaces: ['other'], deniedNamespaces: ['db'] } } })) + expect(mixed.rows[0].replication.text).toBe('No access to Pods') }) it('says no access instead of "no replica pods" when Pods are unreadable', () => { diff --git a/packages/k8s-ui/src/components/cnpg/workspace.ts b/packages/k8s-ui/src/components/cnpg/workspace.ts index 176e986299..cfac2d6c82 100644 --- a/packages/k8s-ui/src/components/cnpg/workspace.ts +++ b/packages/k8s-ui/src/components/cnpg/workspace.ts @@ -3,7 +3,13 @@ // 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 } from '../facts' +import { type ProblemOrigin, type WorkspaceProblem } from '../problems' +import type { ResourceRef } from '../../types/core' import { formatBytes } from '../../utils/format' +import { formatGrant, type Grant } from '../../utils/grant' +import { issueReasonTitle } from '../issues/severity' import { CNPG_BARMAN_PLUGIN_NAME, getCNPGClusterBackupConfig, @@ -32,12 +38,14 @@ export const CNPG_WORKSPACE_KEYS = [ export type CNPGWorkspaceKey = (typeof CNPG_WORKSPACE_KEYS)[number] -export type CNPGCoverageState = 'full' | 'partial' | 'denied' | 'notInstalled' | 'syncing' | 'error' +export type CNPGCoverageState = 'full' | 'partial' | 'denied' | 'notInstalled' | 'syncing' | 'uncached' | 'error' export interface CNPGKindCoverage { state: CNPGCoverageState /** Denied namespaces, named only when the caller supplied the candidate list. */ deniedNamespaces?: string[] + /** Namespaces the caller may read but Radar's cache does not hold, named under the same rule. */ + uncachedNamespaces?: string[] /** For partial coverage: the namespaces that were read. */ allowedNamespaces?: string[] } @@ -78,6 +86,8 @@ export interface CNPGWorkspaceResponse { backupsOmitted: number /** Each ScheduledBackup's schedule as the operator reads it, keyed "namespace/name"; absent when it cannot be parsed. */ scheduleReadings?: Record + /** The manager of each object that has one, keyed "Kind/namespace/name". */ + managedBy?: Record } export const CNPG_KIND_BY_KEY: Record = { @@ -99,22 +109,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 +118,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 +130,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 +162,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[] @@ -206,11 +175,12 @@ export interface CNPGFleetRow { attention: boolean categories: Set /** GitOps owner recorded on the Cluster, when it carries the standard labels. */ - gitops: CNPGGitOpsSource | null + /** The GitOps or Helm object that manages the Cluster, as the server detected it. */ + managedBy?: ResourceRef /** 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 { @@ -244,13 +214,11 @@ const CNPG_POD_REASON_SENTENCES: Record = { // Plain headlines for CNPG issues whose message carries the operator's own // condition text; that message becomes the detail beneath. +// Reasons the Issues page already titles come from issueReasonTitle, so a +// problem reads the same here and there; these are the rest. const CNPG_REASON_TITLES: Record = { - CNPGWALArchivingFailing: 'WAL archiving is failing', - CNPGLastBackupFailed: 'The last backup failed', CNPGBackupFailed: 'Backup failed', CNPGScheduledBackupMissed: 'A scheduled backup did not run', - // The detector sees no successful backup since the run; a failed one may exist. - CNPGScheduledRunNoBackup: 'No successful backup since a scheduled run', CNPGCertificateExpiring: 'A certificate expires soon', CNPGCertificateExpired: 'A certificate has expired', } @@ -266,7 +234,7 @@ export function cnpgIssueText(issue: Pick { 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 +336,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 +350,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) { @@ -454,23 +417,35 @@ export function coverageReadable(cov: CNPGKindCoverage, namespace?: string): boo if (cov.state === 'partial') { if (!namespace) return false if (cov.allowedNamespaces) return cov.allowedNamespaces.includes(namespace) - if (cov.deniedNamespaces) return !cov.deniedNamespaces.includes(namespace) + if (cov.deniedNamespaces || cov.uncachedNamespaces) return !cov.deniedNamespaces?.includes(namespace) && !cov.uncachedNamespaces?.includes(namespace) return false } return false } -function coverageUnavailableText(cov: CNPGKindCoverage, what: string): string { +/** + * Why a kind's objects were not read (in `namespace`, when given), worded for + * a fact. A namespace Radar's cache does not hold is never called "no access"; + * a partial read that names neither cause says only that it was not read. + */ +export function cnpgCoverageGap(cov: CNPGKindCoverage, what: string, namespace?: string, notInstalled = 'Not installed'): string { + // A namespace the server names wins over the kind's overall state: an + // uncached scope can still name namespaces the caller was denied. + if (namespace && cov.deniedNamespaces?.includes(namespace)) return `No access to ${what}` + if (namespace && cov.uncachedNamespaces?.includes(namespace)) return `Radar does not cache ${what} in ${namespace}` switch (cov.state) { case 'denied': - case 'partial': return `No access to ${what}` + case 'partial': + return namespace ? `${what} not read in ${namespace}` : `${what} not read` + case 'uncached': + return `Radar does not cache ${what}` case 'syncing': return 'Loading…' case 'error': return `Could not read ${what}` default: - return 'Not installed' + return notInstalled } } @@ -497,27 +472,12 @@ function podReady(pod: any): boolean | null { return ready.status === 'True' } -export type CNPGGitOpsSource = { tool: 'argocd' | 'flux'; name: string; namespace?: string } - -/** The GitOps owner recorded on an object's standard Argo CD / Flux labels. */ -export function cnpgGitOpsSource(obj: any): CNPGGitOpsSource | null { - const labels = obj?.metadata?.labels ?? {} - const annotations = obj?.metadata?.annotations ?? {} - const argo = labels['argocd.argoproj.io/instance'] - if (argo) return { tool: 'argocd', name: argo } - const tracking = annotations['argocd.argoproj.io/tracking-id'] - if (typeof tracking === 'string' && tracking.includes(':')) { - return { tool: 'argocd', name: tracking.split(':')[0] } - } - const fluxName = labels['kustomize.toolkit.fluxcd.io/name'] || labels['helm.toolkit.fluxcd.io/name'] - if (fluxName) { - return { - tool: 'flux', - name: fluxName, - namespace: labels['kustomize.toolkit.fluxcd.io/namespace'] || labels['helm.toolkit.fluxcd.io/namespace'], - } - } - return null +/** The manager the server detected for a workspace object (Argo CD, Flux or Helm), if any. */ +export function cnpgManagedBy(ws: Pick | null | undefined, obj: any): ResourceRef | undefined { + const kind = obj?.kind + const name = obj?.metadata?.name + if (!kind || !name) return undefined + return ws?.managedBy?.[`${kind}/${obj?.metadata?.namespace ?? ''}/${name}`] } function scheduleFact( @@ -528,7 +488,7 @@ function scheduleFact( ): CNPGProtectionFacts['schedule'] { const ns = cluster.metadata?.namespace if (!coverageReadable(cov, ns)) { - return { text: coverageUnavailableText(cov, 'ScheduledBackups'), tone: 'unknown', names: [] } + return { text: cnpgCoverageGap(cov, 'ScheduledBackups', ns), tone: 'unknown', names: [] } } const mine = schedules.filter((s) => s.metadata?.namespace === ns && specClusterName(s) === cluster.metadata?.name) if (mine.length === 0) return { text: 'No declarative schedule', tone: 'neutral', names: [] } @@ -612,9 +572,9 @@ function lastBackupFact( if (!cfg.plugin && cfg.lastSuccessfulBackup) candidates.push({ at: cfg.lastSuccessfulBackup, source: 'Cluster status' }) if (candidates.length === 0) { if (!coverageReadable(backupsCov, ns)) { - return { text: coverageUnavailableText(backupsCov, 'Backups'), tone: 'unknown' } + return { text: cnpgCoverageGap(backupsCov, 'Backups', ns), tone: 'unknown' } } - if (storesUnreadable) return { text: coverageUnavailableText(storesUnreadable, 'ObjectStores'), tone: 'unknown' } + if (storesUnreadable) return { text: cnpgCoverageGap(storesUnreadable, 'ObjectStores', ns), tone: 'unknown' } return { text: 'None observed', tone: 'unknown' } } const best = candidates.reduce((a, b) => (Date.parse(a.at) >= Date.parse(b.at) ? a : b)) @@ -624,7 +584,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 +709,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,12 +735,12 @@ 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' } if (!coverageReadable(podsCov, cluster?.metadata?.namespace)) { - return { text: coverageUnavailableText(podsCov, 'Pods'), tone: 'unknown' } + return { text: cnpgCoverageGap(podsCov, 'Pods', cluster?.metadata?.namespace), tone: 'unknown' } } const replicas = pods.filter((p) => p.role === 'replica') const readyReplicas = replicas.filter((p) => p.ready === true).length @@ -981,7 +941,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) { @@ -1038,7 +998,7 @@ export function buildCNPGFleet(resp: CNPGWorkspaceResponse): CNPGFleet { source: `ObjectStore ${window.store} status (earliest point)`, } : storesUnreadable - ? { text: coverageUnavailableText(storesCov, 'ObjectStores'), tone: 'unknown' } + ? { text: cnpgCoverageGap(storesCov, 'ObjectStores', ns), tone: 'unknown' } : { text: 'Not reported', tone: 'unknown' }, restoreValidation: restoreValidationFact(cluster, clusters, resp.objects.backups ?? [], coverageReadable(coverageOf(resp, 'backups'), ns)), } @@ -1084,7 +1044,7 @@ export function buildCNPGFleet(resp: CNPGWorkspaceResponse): CNPGFleet { problems, attention: problems.some((p) => p.severity !== 'posture'), categories, - gitops: cnpgGitOpsSource(cluster), + managedBy: cnpgManagedBy(resp, cluster), } }) @@ -1141,7 +1101,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 @@ -1182,7 +1142,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` : '' @@ -1194,7 +1154,7 @@ export function cnpgDiskFact(r: CNPGDiskReading | undefined): CNPGFact { } 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': @@ -1250,7 +1210,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 @@ -1261,7 +1221,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. */ @@ -1289,13 +1249,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 +1256,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 +1275,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 } @@ -1339,13 +1292,13 @@ function measuredReplication(base: CNPGFact, reading: CNPGFleetMetricsReading | 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' } } /** 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/facts/certainty.tsx b/packages/k8s-ui/src/components/facts/certainty.tsx new file mode 100644 index 0000000000..c33d2ffbfb --- /dev/null +++ b/packages/k8s-ui/src/components/facts/certainty.tsx @@ -0,0 +1,36 @@ +import { WithTooltip } from '../ui/Tooltip' + +/** + * How exactly a value is known. A value read only in part is a lower bound, + * never shown as if exact. + */ +export type Certainty = 'exact' | 'lower_bound' | 'upper_bound' | 'unknown' + +export function certaintyGlyph(certainty: Certainty): string { + if (certainty === 'exact') return '=' + if (certainty === 'lower_bound') return '≥' + if (certainty === 'upper_bound') return '≤' + return '?' +} + +export function certaintyValueLabel(certainty: Certainty): string { + if (certainty === 'exact') return 'Exact' + if (certainty === 'lower_bound') return 'Lower bound' + if (certainty === 'upper_bound') return 'Upper bound' + return 'Unknown certainty' +} + +export function CertaintyGlyph({ certainty, title }: { certainty: Certainty; title?: string }) { + return ( + + + {certaintyGlyph(certainty)} + + + ) +} diff --git a/packages/k8s-ui/src/components/cnpg/primitives.test.tsx b/packages/k8s-ui/src/components/facts/facts.test.tsx similarity index 94% rename from packages/k8s-ui/src/components/cnpg/primitives.test.tsx rename to packages/k8s-ui/src/components/facts/facts.test.tsx index 87ed2a49aa..a560d293f2 100644 --- a/packages/k8s-ui/src/components/cnpg/primitives.test.tsx +++ b/packages/k8s-ui/src/components/facts/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/facts/facts.tsx b/packages/k8s-ui/src/components/facts/facts.tsx new file mode 100644 index 0000000000..30c7bfcf94 --- /dev/null +++ b/packages/k8s-ui/src/components/facts/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/facts/index.ts b/packages/k8s-ui/src/components/facts/index.ts new file mode 100644 index 0000000000..ce2f304a7c --- /dev/null +++ b/packages/k8s-ui/src/components/facts/index.ts @@ -0,0 +1,6 @@ +// How a surface shows what the cluster reported: each value with its source, +// partial and unread values marked as such (see DESIGN.md, "Unknown, partial +// and denied values"). For any renderer, summary or workspace screen. +export * from './facts' +export * from './certainty' +export * from './managed-by' diff --git a/packages/k8s-ui/src/components/facts/managed-by.test.tsx b/packages/k8s-ui/src/components/facts/managed-by.test.tsx new file mode 100644 index 0000000000..842c51f1ef --- /dev/null +++ b/packages/k8s-ui/src/components/facts/managed-by.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { ManagedByText, managedByLabel } from './managed-by' +import { cnpgManagedBy } from '../cnpg/workspace' + +describe('ManagedByText', () => { + it('names the GitOps manager and links it when its namespace is recorded', () => { + const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'payments' } + const html = renderToStaticMarkup( {}} />) + expect(html).toContain('Argo CD application') + expect(html).toMatch(/]*>argocd\/payments<\/button>/) + expect(renderToStaticMarkup( {}} />)).not.toContain(' { + expect(renderToStaticMarkup()).toBe('') + expect(managedByLabel({ kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', namespace: 'flux-system', name: 'apps' })).toBe('Flux Kustomization flux-system/apps') + }) +}) + +describe('cnpgManagedBy', () => { + it('looks an object up by kind, namespace and name in the workspace answer', () => { + const ws = { managedBy: { 'Cluster/db/pg': { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'pg' } } } + expect(cnpgManagedBy(ws, { kind: 'Cluster', metadata: { namespace: 'db', name: 'pg' } })?.name).toBe('pg') + expect(cnpgManagedBy(ws, { kind: 'Pooler', metadata: { namespace: 'db', name: 'pg' } })).toBeUndefined() + }) +}) diff --git a/packages/k8s-ui/src/components/facts/managed-by.tsx b/packages/k8s-ui/src/components/facts/managed-by.tsx new file mode 100644 index 0000000000..c5ba1c1ec4 --- /dev/null +++ b/packages/k8s-ui/src/components/facts/managed-by.tsx @@ -0,0 +1,36 @@ +import type { ResourceRef } from '../../types/core' +import { gitOpsOwnerFromRef, type GitOpsOwnerRef } from '../../utils/gitops-owner' +import { RefLink, type NavigateToRef } from '../ui/RefLink' + +function managerLabel(owner: GitOpsOwnerRef): string { + if (owner.tool === 'argocd') return 'Argo CD application' + return owner.kind === 'helmreleases' ? 'Flux HelmRelease' : 'Flux Kustomization' +} + +/** + * The GitOps object that manages a resource, from the server's manager + * detection. Renders nothing for a manager that is not a GitOps controller, + * and plain text when the manager's namespace is not recorded. + */ +export function ManagedByText({ refTo, onNavigate }: { refTo: ResourceRef; onNavigate?: NavigateToRef }) { + const owner = gitOpsOwnerFromRef(refTo) + if (!owner) return null + return ( + + {managerLabel(owner)}{' '} + {refTo.namespace ? ( + + {`${refTo.namespace}/${refTo.name}`} + + ) : ( + {refTo.name} + )} + + ) +} + +/** The manager's label and name as text, for a table cell. */ +export function managedByLabel(refTo: ResourceRef | undefined): string | undefined { + const owner = refTo && gitOpsOwnerFromRef(refTo) + return owner ? `${managerLabel(owner)} ${refTo!.namespace ? `${refTo!.namespace}/` : ''}${refTo!.name}` : undefined +} diff --git a/packages/k8s-ui/src/components/issues/index.ts b/packages/k8s-ui/src/components/issues/index.ts index 898b442ba0..93fcb849b1 100644 --- a/packages/k8s-ui/src/components/issues/index.ts +++ b/packages/k8s-ui/src/components/issues/index.ts @@ -25,5 +25,6 @@ export { ISSUE_SEVERITY_RAIL_CLASS, categoryLabel, issueTitle, + issueReasonTitle, groupLabel, } from './severity'; diff --git a/packages/k8s-ui/src/components/issues/severity.ts b/packages/k8s-ui/src/components/issues/severity.ts index 397bab6253..6bb64597fc 100644 --- a/packages/k8s-ui/src/components/issues/severity.ts +++ b/packages/k8s-ui/src/components/issues/severity.ts @@ -144,9 +144,14 @@ const REASON_TITLE: Record = { CNPGScheduledRunNoBackup: "No successful backup since a scheduled run", }; +/** The title a reason has wherever it is shown, when its category's label would name the wrong operation. */ +export function issueReasonTitle(reason: string | undefined): string | undefined { + return reason ? REASON_TITLE[reason] : undefined; +} + /** An issue row's title: its category, unless the reason names it better. */ export function issueTitle(issue: { category: string; reason?: string }): string { - return (issue.reason && REASON_TITLE[issue.reason]) || categoryLabel(issue.category); + return issueReasonTitle(issue.reason) || categoryLabel(issue.category); } export function groupLabel(group: string): string { diff --git a/packages/k8s-ui/src/components/problems/index.ts b/packages/k8s-ui/src/components/problems/index.ts new file mode 100644 index 0000000000..94e9292d32 --- /dev/null +++ b/packages/k8s-ui/src/components/problems/index.ts @@ -0,0 +1 @@ +export * from './problems' diff --git a/packages/k8s-ui/src/components/problems/problems.test.tsx b/packages/k8s-ui/src/components/problems/problems.test.tsx new file mode 100644 index 0000000000..3ee59efc6c --- /dev/null +++ b/packages/k8s-ui/src/components/problems/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/problems/problems.tsx b/packages/k8s-ui/src/components/problems/problems.tsx new file mode 100644 index 0000000000..21ca4cdb1a --- /dev/null +++ b/packages/k8s-ui/src/components/problems/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/resources/ResourcesSidebar.test.tsx b/packages/k8s-ui/src/components/resources/ResourcesSidebar.test.tsx index f76df8d7f1..0410ccc6a2 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesSidebar.test.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesSidebar.test.tsx @@ -144,7 +144,7 @@ describe('ResourcesSidebar category workspaces', () => { }} /> ) - expect(html).toContain('Workspace') + expect(html).toContain('Views') expect(html).toContain('Overview') expect(html).toContain('pg-orders') expect(html).toContain('Counts for namespace payments') @@ -153,6 +153,21 @@ describe('ResourcesSidebar category workspaces', () => { expect(html).not.toContain('selection-strong selection-text">Pod') }) + it('marks a count taken over partly readable data as a lower bound, and never shows its zero as none', () => { + const render = (count: number) => + renderToString( + {}} + apiResources={[cnpgCluster]} + resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} + categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', count, countLowerBound: true, onSelect: () => {} }] } }} + />, + ) + expect(render(2)).toMatch(/≥()?2/) + expect(render(0)).toContain('–') + }) + it('keeps a workspace category visible when it has no resources', () => { const html = renderToString( /** Problem count. `undefined` renders no badge; `null` renders the unknown dash. */ count?: number | null + /** The count was taken over data read only in part: rendered "≥N", and a zero is unknown rather than none. */ + countLowerBound?: boolean countTitle?: string active?: boolean /** The object currently open under this destination, nested beneath it. */ @@ -789,7 +792,7 @@ function WorkspaceDestinations({ }) { return (
    -
    Workspace
    +
    Views
    {workspace.destinations.map((d) => { const Icon = d.icon return ( @@ -808,13 +811,16 @@ function WorkspaceDestinations({ > {Icon && } {d.label} - {d.count === null ? ( + {d.count === null || (d.count === 0 && d.countLowerBound) ? ( – ) : d.count !== undefined && d.count > 0 ? ( - {d.count} + + {d.countLowerBound && certaintyGlyph('lower_bound')} + {d.count} + ) : null} diff --git a/packages/k8s-ui/src/components/ui/FoldSection.tsx b/packages/k8s-ui/src/components/ui/FoldSection.tsx new file mode 100644 index 0000000000..c6c8748481 --- /dev/null +++ b/packages/k8s-ui/src/components/ui/FoldSection.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { clsx } from 'clsx' +import { toneTextClass } from './status-tone' +import { Collapse, CollapseChevron, useDisclosure } from './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/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..95e5a37d88 100644 --- a/packages/k8s-ui/src/components/ui/index.ts +++ b/packages/k8s-ui/src/components/ui/index.ts @@ -19,7 +19,11 @@ 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 { FoldSection, SectionHeading } from './FoldSection' +export type { FoldSummary } from './FoldSection' +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..73e5a8c39f 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,29 @@ 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 } + +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/index.ts b/packages/k8s-ui/src/index.ts index 7354c75027..9fb0f5b477 100644 --- a/packages/k8s-ui/src/index.ts +++ b/packages/k8s-ui/src/index.ts @@ -55,7 +55,11 @@ export * from './components/checks' // queue) export * from './components/issues' -// CloudNativePG workspace model (fleet derivation over /api/cnpg/workspace) +// Observed values with their sources, and problems with where they came from +export * from './components/facts' +export * from './components/problems' + +// 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/packages/k8s-ui/src/types/capacity.ts b/packages/k8s-ui/src/types/capacity.ts index 4490d86965..9231bdbef3 100644 --- a/packages/k8s-ui/src/types/capacity.ts +++ b/packages/k8s-ui/src/types/capacity.ts @@ -1,4 +1,5 @@ import type { Issue } from "../components/issues/types"; +import type { Certainty } from "../components/facts/certainty"; export const CAPACITY_SCHEMA_VERSION = "v1alpha1" as const; @@ -15,8 +16,7 @@ export interface CapacityResourceIdentity { uid?: string; } -export type CapacityCertainty = - "exact" | "lower_bound" | "upper_bound" | "unknown"; +export type CapacityCertainty = Certainty; export type CapacityGranularity = "aggregate" | "aggregate_not_binpacked" | "per_node"; export type CapacityResourceVector = Record; diff --git a/packages/k8s-ui/src/types/core.ts b/packages/k8s-ui/src/types/core.ts index 2feb82b4b7..677ae739ec 100644 --- a/packages/k8s-ui/src/types/core.ts +++ b/packages/k8s-ui/src/types/core.ts @@ -167,6 +167,8 @@ export interface FeatureCapabilities { podEnvironment?: boolean policyResource?: boolean workloadHistory?: boolean + cnpgWorkspace?: boolean + gitopsWriteEvidence?: boolean } // DeploymentMode is the closed set of topologies Radar can run in. diff --git a/packages/k8s-ui/src/utils/gitops-owner.ts b/packages/k8s-ui/src/utils/gitops-owner.ts index 5d2ce20829..766ba94a43 100644 --- a/packages/k8s-ui/src/utils/gitops-owner.ts +++ b/packages/k8s-ui/src/utils/gitops-owner.ts @@ -38,10 +38,11 @@ export function gitOpsOwnerFromRelationships( ): GitOpsOwnerRef | null { const refs = rel?.managedBy if (!refs || refs.length === 0) return null - return refToGitOpsOwner(refs[0]) + return gitOpsOwnerFromRef(refs[0]) } -function refToGitOpsOwner(ref: ResourceRef): GitOpsOwnerRef | null { +/** A manager ref (relationships.managedBy, a workspace's managedBy) as a GitOps owner, or null when the manager is not a GitOps controller. */ +export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null { switch (true) { case ref.kind === 'Application' && ref.group === ARGO_APPLICATION_GROUP: return { tool: 'argocd', kind: 'applications', namespace: ref.namespace, name: ref.name } 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/pkg/resourcecontext/summary.go b/pkg/resourcecontext/summary.go index d51d1fd187..cd6708418e 100644 --- a/pkg/resourcecontext/summary.go +++ b/pkg/resourcecontext/summary.go @@ -68,7 +68,7 @@ func BuildSummary(obj runtime.Object, opts SummaryOptions) *ResourceSummaryConte // - "argocd" for argoproj.io kinds (Application, ApplicationSet, Rollout) // - "flux" for *.fluxcd.io kinds (Kustomization, HelmRelease, GitRepository, …) // - "helm" for the native Helm release pseudo-owner (kind "HelmRelease" -// with no group — emitted by topology's detectManagedByFromMeta to +// with no group — emitted by topology's ManagedByFromMeta to // distinguish from Flux's HelmRelease CR in helm.toolkit.fluxcd.io) // - "native" for everything else (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, …) func ManagedByFromOwner(ownerKind, ownerGroup, ownerNamespace, ownerName string) *ManagedByRef { diff --git a/pkg/resourcecontext/summary_test.go b/pkg/resourcecontext/summary_test.go index c361feb99f..139606af91 100644 --- a/pkg/resourcecontext/summary_test.go +++ b/pkg/resourcecontext/summary_test.go @@ -371,7 +371,7 @@ func TestManagedByFromOwner(t *testing.T) { want: &ManagedByRef{Kind: "GitRepository", Source: "flux", Name: "repo", Namespace: "flux-system"}, }, { - // Native Helm release: topology's detectManagedByFromMeta emits + // Native Helm release: topology's ManagedByFromMeta emits // {Kind:"HelmRelease", Group:""} when it sees Helm's release-name // annotation (no Flux/GitOps signal). Must classify as "helm", // not "native" — distinguishes Helm-managed resources in the diff --git a/pkg/subject/overlay.go b/pkg/subject/overlay.go index a109adf93b..329d58ab72 100644 --- a/pkg/subject/overlay.go +++ b/pkg/subject/overlay.go @@ -103,7 +103,7 @@ type AppOverlay struct { // ResolveOverlay is the Tier-2 entrypoint. It COLLECTS ALL matching signals from // obj's labels/annotations, sorts by Tier, returns the winner + retained // conflicts, or nil when nothing reaches TierAppName (TierBareApp alone is opt-in via -// allowBareApp; default off => never silent). SUBSUMES detectManagedByFromMeta +// allowBareApp; default off => never silent). SUBSUMES topology.ManagedByFromMeta // (tiers 1-5, first-hit-return REPLACED by collect-all). The native-Helm // sentinel {Kind:"HelmRelease",Group:""} the Source classifier keys on is set // here; enrichRef MUST NOT be applied to tier 1-5 refs (Group is hand-set) — diff --git a/pkg/topology/managedby.go b/pkg/topology/managedby.go index 98658007f5..327d798b4a 100644 --- a/pkg/topology/managedby.go +++ b/pkg/topology/managedby.go @@ -49,8 +49,8 @@ const ( // // Returns nil when no meaningful manager is detectable. func SynthesizeManagedBy(obj metav1.Object, kind, namespace, name string, topo *Topology, dp DynamicProvider, idx *RelationshipsIndex) []ResourceRef { - if ref := detectManagedByFromMeta(obj); ref != nil { - // detectManagedByFromMeta hand-sets Group for GitOps/Flux managers; do + if ref := ManagedByFromMeta(obj); ref != nil { + // ManagedByFromMeta hand-sets Group for GitOps/Flux managers; do // NOT call enrichRef here — it would overwrite the deliberate group // with the result of a dp lookup keyed on the manager kind, which // resolves wrong for cross-group kinds like Flux's HelmRelease vs @@ -64,7 +64,7 @@ func SynthesizeManagedBy(obj metav1.Object, kind, namespace, name string, topo * } func synthesizeManagedByFromNode(obj metav1.Object, nodeID string, topo *Topology, dp DynamicProvider, idx *RelationshipsIndex) []ResourceRef { - if ref := detectManagedByFromMeta(obj); ref != nil { + if ref := ManagedByFromMeta(obj); ref != nil { return []ResourceRef{*ref} } if top := walkTopmostOwnerFromNodeID(nodeID, topo, dp, idx); top != nil { @@ -73,10 +73,14 @@ func synthesizeManagedByFromNode(obj metav1.Object, nodeID string, topo *Topolog return nil } -// detectManagedByFromMeta inspects labels/annotations on obj for GitOps / Helm -// ownership signals and returns the implied manager ref. Returns nil if no -// signal is present. Mirrors detectGitOpsOwner precedence in the web package. -func detectManagedByFromMeta(obj metav1.Object) *ResourceRef { +// ManagedByFromMeta inspects labels/annotations on obj for GitOps / Helm +// ownership signals and returns the implied manager ref, or nil when there is +// none. It reads metadata only, so it needs no topology. A native Helm release +// has Kind "HelmRelease" and no Group (Flux's HelmRelease carries its group); +// an Argo CD Application named only by the instance label, or by a tracking ID +// without an app namespace, has no Namespace. Mirrors detectGitOpsOwner +// precedence in the web package. +func ManagedByFromMeta(obj metav1.Object) *ResourceRef { if obj == nil { return nil } diff --git a/pkg/topology/managedby_test.go b/pkg/topology/managedby_test.go index 2bc9d53e99..75c83ada7a 100644 --- a/pkg/topology/managedby_test.go +++ b/pkg/topology/managedby_test.go @@ -423,3 +423,48 @@ func TestParseArgoTrackingID(t *testing.T) { }) } } + +func TestManagedByFromMeta(t *testing.T) { + obj := func(labels, annos map[string]string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetAPIVersion("postgresql.cnpg.io/v1") + u.SetKind("Cluster") + u.SetNamespace("db") + u.SetName("pg") + u.SetLabels(labels) + u.SetAnnotations(annos) + return u + } + cases := []struct { + name string + obj *unstructured.Unstructured + want *ResourceRef + }{ + {"argo tracking-id, apps in any namespace", obj(nil, map[string]string{argoTrackingIDAnnotation: "team-a_orders:postgresql.cnpg.io/Cluster:db/pg"}), + &ResourceRef{Kind: "Application", Group: argoApplicationGroup, Namespace: "team-a", Name: "orders"}}, + {"argo tracking-id, default form names no namespace", obj(nil, map[string]string{argoTrackingIDAnnotation: "orders:postgresql.cnpg.io/Cluster:db/pg"}), + &ResourceRef{Kind: "Application", Group: argoApplicationGroup, Name: "orders"}}, + {"argo instance label", obj(map[string]string{argoInstanceLabel: "orders"}, nil), + &ResourceRef{Kind: "Application", Group: argoApplicationGroup, Name: "orders"}}, + {"flux kustomization", obj(map[string]string{fluxKustomizeNameLabel: "databases", fluxKustomizeNSLabel: "flux-system"}, nil), + &ResourceRef{Kind: "Kustomization", Group: fluxKustomizeGroup, Namespace: "flux-system", Name: "databases"}}, + {"flux kustomization without its namespace label is not a signal", obj(map[string]string{fluxKustomizeNameLabel: "databases"}, nil), nil}, + {"native helm has no group", obj(nil, map[string]string{helmReleaseNameAnno: "pg", helmReleaseNSAnno: "db"}), + &ResourceRef{Kind: "HelmRelease", Namespace: "db", Name: "pg"}}, + {"none", obj(map[string]string{"app": "pg"}, nil), nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := ManagedByFromMeta(c.obj) + switch { + case c.want == nil && got != nil: + t.Errorf("got %+v, want none", *got) + case c.want != nil && (got == nil || *got != *c.want): + t.Errorf("got %+v, want %+v", got, *c.want) + } + }) + } + if ManagedByFromMeta(nil) != nil { + t.Error("nil object has a manager") + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index fba17fd1d6..13c21fe95b 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' @@ -59,7 +59,7 @@ import { UsageDataPrompt } from './components/usage-data/UsageDataPrompt' import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay' import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay' import { useEventSource } from './hooks/useEventSource' -import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client' +import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit, useRadarFeature } from './api/client' import { buildAuditSeverityMap } from './utils/auditBadges' import { isInNamespaceScope, scopeNodesToNamespaces } from './utils/topology-namespace' import { routePath, apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from './api/config' @@ -357,6 +357,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL const navigationType = useNavigationType() const [searchParams, setSearchParams] = useSearchParams() const capabilities = useCapabilitiesContext() + // Expanding a CloudNativePG object opens its workspace page only on a Radar that serves it. + const cnpgWorkspaceSupported = useRadarFeature('cnpgWorkspace').support === 'supported' const openLocalTerminal = useOpenLocalTerminal() const navCustomization = useNavCustomization() // The AI panel is an absolute slot in the body frame (the column under the header): @@ -2423,7 +2425,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL onNavigate={(res) => navigateToResource(res)} canCollapseToDrawer={!isMobile} onExpand={(res, opts) => { - const cnpgPlural = cnpgDetailKindFor(res.kind, res.group) + const cnpgPlural = cnpgWorkspaceSupported ? cnpgDetailKindFor(res.kind, res.group) : null if (cnpgPlural) { navigate( cnpgDetailPath({ plural: cnpgPlural, namespace: res.namespace, name: res.name }, connection.context || undefined, opts?.yaml ? 'yaml' : undefined), diff --git a/web/src/api/cnpg.errorCode.test.ts b/web/src/api/actions.errorCode.test.ts similarity index 74% rename from web/src/api/cnpg.errorCode.test.ts rename to web/src/api/actions.errorCode.test.ts index 31ada3528f..2080136a82 100644 --- a/web/src/api/cnpg.errorCode.test.ts +++ b/web/src/api/actions.errorCode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { ApiError } from './client' -import { cnpgActionCompleted, cnpgActionErrorCode, cnpgActionOutcomeLocked } from './cnpg' +import { cnpgActionErrorCode } from './cnpg' +import { actionCompleted, actionOutcomeLocked } from './actions' describe('cnpgActionErrorCode', () => { it('keeps the server code when there is one', () => { @@ -22,11 +23,11 @@ describe('partial outcomes', () => { const partial = new ApiError('stopped', 403, { code: 'partial', completed: ['deleted PVC pg-2', 'deleted PVC pg-2-wal'] }) it('locks confirm and lists what already happened, whatever the status', () => { expect(cnpgActionErrorCode(partial)).toBe('partial') - expect(cnpgActionOutcomeLocked(partial)).toBe(true) - expect(cnpgActionCompleted(partial)).toEqual(['deleted PVC pg-2', 'deleted PVC pg-2-wal']) + expect(actionOutcomeLocked(partial)).toBe(true) + expect(actionCompleted(partial)).toEqual(['deleted PVC pg-2', 'deleted PVC pg-2-wal']) }) it('leaves known refusals unlocked', () => { - expect(cnpgActionOutcomeLocked(new ApiError('changed', 409, { code: 'changed' }))).toBe(false) - expect(cnpgActionCompleted(new ApiError('changed', 409, { code: 'changed' }))).toEqual([]) + expect(actionOutcomeLocked(new ApiError('changed', 409, { code: 'changed' }))).toBe(false) + expect(actionCompleted(new ApiError('changed', 409, { code: 'changed' }))).toEqual([]) }) }) diff --git a/web/src/api/actions.test.ts b/web/src/api/actions.test.ts new file mode 100644 index 0000000000..f4d1df9466 --- /dev/null +++ b/web/src/api/actions.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { capabilityReason } from './actions' + +describe('capabilityReason', () => { + it('says nothing for an allowed action, and the server reason when there is one', () => { + expect(capabilityReason({ allowed: true, permission: 'allowed' })).toBeUndefined() + expect(capabilityReason({ allowed: false, permission: 'allowed', reason: 'The Cluster is hibernated' })).toBe('The Cluster is hibernated') + }) + it('names the missing grant when the reason is absent', () => { + expect(capabilityReason({ allowed: false, permission: 'denied', grant: { verb: 'create', group: 'postgresql.cnpg.io', resource: 'backups', namespace: 'db' } })).toBe( + 'Your account may not do this (create backups (postgresql.cnpg.io) in namespace db)', + ) + expect(capabilityReason({ allowed: false, permission: 'unknown' })).toBe('Not available') + }) +}) diff --git a/web/src/api/actions.ts b/web/src/api/actions.ts new file mode 100644 index 0000000000..bbec0cc33d --- /dev/null +++ b/web/src/api/actions.ts @@ -0,0 +1,64 @@ +// The client half of the reviewed-action contract shared by workspace +// integrations (server: internal/server/actions.go). A capability says +// whether an action is offered and why not; a request carries the context, +// object UID and facts the user confirmed, and the server refuses it with a +// stable code when any of them no longer holds. + +import { formatGrant, type Grant } from '@skyhook-io/k8s-ui' +import { ApiError } from './client' + +export interface ActionCapability { + allowed: boolean + reason?: string + /** `unknown`: the permission check itself failed; the apiserver decides on submit. */ + permission: 'allowed' | 'denied' | 'unknown' + grant?: Grant +} + +export interface ActionRequest { + reviewedContext: string + uid: string + facts: Record + params?: Record +} + +/** + * Refusals every action family shares. An integration adds its own codes by + * widening the type parameter of actionErrorCode. + */ +export type ActionErrorCode = 'context_changed' | 'changed' | 'blocked' | 'partial' | 'outcome_unknown' + +// A request that timed out or lost its connection may have been applied by the +// apiserver anyway. 503 without a code is Radar refusing before any write. +const AMBIGUOUS_STATUSES = new Set([500, 502, 504]) + +export function actionErrorCode(err: unknown): C | ActionErrorCode | undefined { + if (!err) return undefined + if (!(err instanceof ApiError)) return 'outcome_unknown' + const code = err.data?.code + if (typeof code === 'string') return code as C + return AMBIGUOUS_STATUSES.has(err.status) ? 'outcome_unknown' : undefined +} + +/** + * Confirm stays locked when the last attempt may have taken effect (unknown) + * or partly did (partial): repeating it would act on a target that moved. + */ +export function actionOutcomeLocked(err: unknown): boolean { + const code = actionErrorCode(err) + return code === 'outcome_unknown' || code === 'partial' +} + +/** The mutations a `partial` refusal reports as already done. */ +export function actionCompleted(err: unknown): string[] { + if (!(err instanceof ApiError) || err.data?.code !== 'partial') return [] + const done = err.data.completed + return Array.isArray(done) ? done.filter((d): d is string => typeof d === 'string') : [] +} + +/** Why an action is not offered, or undefined when it is. */ +export function capabilityReason(cap: ActionCapability): string | undefined { + if (cap.allowed) return undefined + if (cap.reason) return cap.reason + return cap.permission === 'denied' ? `Your account may not do this (${formatGrant(cap.grant) ?? 'permission denied'})` : 'Not available' +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1747d4ad74..cf5bb884f7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,6 @@ import { canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources' import { knownKindForPluralWithGroup, pluralToKind } from '@skyhook-io/k8s-ui/utils/navigation' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useCallback, useMemo } from 'react' import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling' import type { GitOpsWriteEvidence } from '@skyhook-io/k8s-ui/utils/gitops-write-guard' import type { @@ -1808,13 +1808,14 @@ export function useRadarFeature(feature: RadarFeature) { const currentVersion = versionInfo?.currentVersion || radarVersion const latestVersion = versionInfo?.latestVersion const support = radarFeatureSupport(feature, capabilities, currentVersion) + // Stable across renders, so a callback built on guard keeps its identity. + const guard = useCallback( + (request: () => Promise): Promise => guardRadarFeature(feature, support, { currentVersion, latestVersion }, request), + [feature, support, currentVersion, latestVersion], + ) + const gatedKey = useMemo(() => (support === 'unsupported' ? ['radar-feature-unsupported'] : []), [support]) - return { - support, - gatedKey: support === 'unsupported' ? ['radar-feature-unsupported'] : [], - guard: (request: () => Promise): Promise => - guardRadarFeature(feature, support, { currentVersion, latestVersion }, request), - } + return { support, gatedKey, guard } } // ============================================================================ 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..6ac600f3dd 100644 --- a/web/src/api/cnpg-ha.ts +++ b/web/src/api/cnpg-ha.ts @@ -1,15 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { cnpgFormatLag, cnpgReplicationTone, type CNPGClusterHA, type CNPGFleetRow, type CNPGInstanceLive, type CNPGReplicationLive } from '@skyhook-io/k8s-ui' -import { fetchJSON } from './client' +import { cnpgFormatLag, cnpgReplicationTone, formatGrant, type CNPGClusterHA, type CNPGFleetRow, type CNPGInstanceLive, type CNPGReplicationLive } from '@skyhook-io/k8s-ui' +import { fetchJSON, useRadarFeature } from './client' import type { CNPGRuntimeResponse } from './cnpg' // /api/cnpg/clusters/{ns}/{name}/ha — quorum, disruption budgets, Leases, // failure domains, Jobs, image drift and certificate renewal ownership. Each // part is authorized on its own and says when it could not be read. export function useCNPGClusterHA(namespace: string, name: string, options?: { enabled?: boolean; refetchInterval?: number | false }) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'ha', namespace, name], - queryFn: ({ signal }) => fetchJSON(`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/ha`, signal), + queryKey: ['cnpg', 'ha', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/ha`, signal)), enabled: (options?.enabled ?? true) && !!name, staleTime: 10_000, refetchInterval: options?.refetchInterval ?? 30_000, @@ -36,7 +37,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..2701d14eaf 100644 --- a/web/src/api/cnpg-history.ts +++ b/web/src/api/cnpg-history.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' -import type { CNPGFleetMetricsReading } from '@skyhook-io/k8s-ui' -import { fetchJSON } from './client' +import type { CNPGFleetMetricsReading, Grant } from '@skyhook-io/k8s-ui' +import { fetchJSON, useRadarFeature } from './client' +import { shouldRetryRadarQuery } from './radarFeatures' import type { CNPGClusterActivityResponse } from './cnpg' export type CNPGHistoryRange = '15m' | '1h' | '6h' | '24h' @@ -33,7 +34,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 @@ -63,13 +64,14 @@ export interface CNPGClusterHistoryResponse { // /api/cnpg/clusters/{ns}/{name}/history — server-built Prometheus range // queries for the Runtime Trends section. export function useCNPGClusterHistory(namespace: string, name: string, range: CNPGHistoryRange, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'history', namespace, name, range], - queryFn: ({ signal }) => + queryKey: ['cnpg', 'history', namespace, name, range, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON( `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/history?range=${range}`, signal, - ), + )), enabled: enabled && !!name, refetchInterval: range === '15m' ? 30_000 : 60_000, refetchIntervalInBackground: false, @@ -91,10 +93,11 @@ export interface CNPGFleetMetricsResponse { // /api/cnpg/fleet-metrics — each visible Cluster's largest standby replay lag // and volume growth, read with the workspace's namespace set. export function useCNPGFleetMetrics(namespaces: string[], enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') const ns = [...namespaces].sort().join(',') return useQuery({ - queryKey: ['cnpg', 'fleet-metrics', ns], - queryFn: ({ signal }) => fetchJSON(`/cnpg/fleet-metrics${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal), + queryKey: ['cnpg', 'fleet-metrics', ns, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/fleet-metrics${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal)), enabled, staleTime: 20_000, refetchInterval: 60_000, @@ -105,14 +108,16 @@ export function useCNPGFleetMetrics(namespaces: string[], enabled = true) { // The Cluster's activity inside one interval, both bounds applied server-side. export function useCNPGClusterActivityWindow(namespace: string, name: string, since: string, until: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'activity-window', namespace, name, since, until], - queryFn: ({ signal }) => + queryKey: ['cnpg', 'activity-window', namespace, name, since, until, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON( `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/activity?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&limit=500`, signal, - ), + )), enabled: enabled && !!name, staleTime: 30_000, + retry: shouldRetryRadarQuery, }) } diff --git a/web/src/api/cnpg-recovery.ts b/web/src/api/cnpg-recovery.ts index f34575c26e..89e22167e9 100644 --- a/web/src/api/cnpg-recovery.ts +++ b/web/src/api/cnpg-recovery.ts @@ -1,13 +1,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { apiFetch, ApiError, fetchJSON } from './client' -import type { CNPGActionCapability } from './cnpg' +import type { Grant } from '@skyhook-io/k8s-ui' +import { apiFetch, ApiError, fetchJSON, useRadarFeature } from './client' +import type { ActionCapability } from './actions' import { getApiBase } from './config' +import { shouldRetryRadarQuery } from './radarFeatures' export type CNPGReadState = 'ok' | 'denied' | 'notFound' | 'error' | 'skipped' | 'partial' export interface CNPGReadCoverage { state: CNPGReadState - grant?: string + grant?: Grant reason?: string } @@ -108,16 +110,17 @@ const clusterPath = (namespace: string, name: string) => `/cnpg/clusters/${encod // phase, the recovery Job's Pods and their init containers, the instances and // Warning events about them. Polled every 5s until the Cluster reports healthy. export function useCNPGRecovery(namespace: string, name: string, options?: { enabled?: boolean }) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'recovery', namespace, name], - queryFn: ({ signal }) => fetchJSON(`${clusterPath(namespace, name)}/recovery`, signal), + queryKey: ['cnpg', 'recovery', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`${clusterPath(namespace, name)}/recovery`, signal)), enabled: options?.enabled ?? true, staleTime: 3_000, refetchInterval: (query) => { const d = query.state.data return d?.recovery && d.cluster.phase !== 'Cluster in healthy state' ? 5_000 : 30_000 }, - retry: (count, err) => !(err instanceof ApiError && (err.status === 403 || err.status === 404)) && count < 2, + retry: (count, err) => shouldRetryRadarQuery(count, err) && !(err instanceof ApiError && (err.status === 403 || err.status === 404)) && count < 2, }) } @@ -128,14 +131,15 @@ export interface CNPGRestoreValidationRequest { } export function useRecordCNPGRestoreValidation(namespace: string, name: string) { + const { guard } = useRadarFeature('cnpgWorkspace') const queryClient = useQueryClient() return useMutation({ - mutationFn: (body: CNPGRestoreValidationRequest) => + mutationFn: (body: CNPGRestoreValidationRequest) => guard(() => fetchJSON(`${clusterPath(namespace, name)}/restore-validation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }), + })), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cnpg', 'recovery', namespace, name] }) queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace'] }) @@ -236,9 +240,10 @@ export interface CNPGOperatorDiagnosis { // Cluster in `namespace` (create clusters, and the operator's webhook admits // writes), for every way into the restore dialog. export function useCNPGRestoreCapability(namespace: string) { - return useQuery({ - queryKey: ['cnpg', 'restore-capability', namespace], - queryFn: ({ signal }) => fetchJSON(`/cnpg/restore/capability?namespace=${encodeURIComponent(namespace)}`, signal), + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') + return useQuery({ + queryKey: ['cnpg', 'restore-capability', namespace, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/restore/capability?namespace=${encodeURIComponent(namespace)}`, signal)), enabled: !!namespace, staleTime: 15_000, retry: false, diff --git a/web/src/api/cnpg-sessions.ts b/web/src/api/cnpg-sessions.ts index 747bb9d2ea..0beab9c1de 100644 --- a/web/src/api/cnpg-sessions.ts +++ b/web/src/api/cnpg-sessions.ts @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJSON } from './client' -import type { CNPGActionCapability, CNPGClusterFacts, CNPGRuntimeSourceState } from './cnpg' +import type { Grant } from '@skyhook-io/k8s-ui' +import { fetchJSON, useRadarFeature } from './client' +import type { CNPGClusterFacts, CNPGRuntimeSourceState } from './cnpg' +import type { ActionCapability } from './actions' const enc = encodeURIComponent @@ -40,7 +42,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 @@ -58,10 +60,11 @@ export interface CNPGSessionsResponse { // /api/cnpg/clusters/{ns}/{name}/sessions — who blocks whom on one instance, // read with fixed SQL over the caller's pods/exec. Denied is a state, not an error. export function useCNPGSessions(namespace: string, name: string, pod?: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'sessions', namespace, name, pod ?? ''], - queryFn: ({ signal }) => - fetchJSON(`/cnpg/clusters/${enc(namespace)}/${enc(name)}/sessions${pod ? `?pod=${enc(pod)}` : ''}`, signal), + queryKey: ['cnpg', 'sessions', namespace, name, pod ?? '', ...gatedKey], + queryFn: ({ signal }) => guard(() => + fetchJSON(`/cnpg/clusters/${enc(namespace)}/${enc(name)}/sessions${pod ? `?pod=${enc(pod)}` : ''}`, signal)), enabled: enabled && !!name, refetchInterval: (q) => (q.state.data?.state === 'denied' ? false : 10_000), refetchIntervalInBackground: false, @@ -93,13 +96,14 @@ export interface CNPGDestroyPlan { pvcs: CNPGDestroyPVC[] jobsReadable: boolean jobs: string[] - actions: { delete: CNPGActionCapability; keep: CNPGActionCapability } + actions: { delete: ActionCapability; keep: ActionCapability } } export function useCNPGDestroyPlan(namespace: string, name: string, pod: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'destroy-plan', namespace, name, pod], - queryFn: ({ signal }) => fetchJSON(`/cnpg/clusters/${enc(namespace)}/${enc(name)}/instances/${enc(pod)}/destroy-plan`, signal), + queryKey: ['cnpg', 'destroy-plan', namespace, name, pod, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/clusters/${enc(namespace)}/${enc(name)}/instances/${enc(pod)}/destroy-plan`, signal)), enabled: enabled && !!pod, staleTime: 0, retry: false, @@ -132,13 +136,14 @@ export interface CNPGPoolerCapabilities { resourceVersion: string context: string facts: CNPGPoolerFacts - actions: { pause: CNPGActionCapability; resume: CNPGActionCapability; observeState: CNPGActionCapability } + actions: { pause: ActionCapability; resume: ActionCapability; observeState: ActionCapability } } export function useCNPGPoolerCapabilities(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'capabilities', 'poolers', namespace, name], - queryFn: ({ signal }) => fetchJSON(`/cnpg/poolers/${enc(namespace)}/${enc(name)}/capabilities`, signal), + queryKey: ['cnpg', 'capabilities', 'poolers', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/poolers/${enc(namespace)}/${enc(name)}/capabilities`, signal)), enabled: enabled && !!name, staleTime: 5_000, refetchInterval: 30_000, @@ -149,16 +154,17 @@ 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 }[] } // /api/cnpg/poolers/{ns}/{name}/pgbouncer-state — each PgBouncer's own SHOW // STATE, the only place "paused" is observed rather than requested. export function useCNPGPgBouncerState(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'pgbouncer-state', namespace, name], - queryFn: ({ signal }) => fetchJSON(`/cnpg/poolers/${enc(namespace)}/${enc(name)}/pgbouncer-state`, signal), + queryKey: ['cnpg', 'pgbouncer-state', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/poolers/${enc(namespace)}/${enc(name)}/pgbouncer-state`, signal)), enabled: enabled && !!name, refetchInterval: (q) => (q.state.data?.permission.exec === 'denied' ? false : 15_000), refetchIntervalInBackground: false, diff --git a/web/src/api/cnpg-storage.ts b/web/src/api/cnpg-storage.ts index 0bccaa9f49..cee85f8239 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 { fetchJSON } from './client' +import type { CNPGDiskReading, CNPGMetricIsolation, Grant } from '@skyhook-io/k8s-ui' +import { fetchJSON, useRadarFeature } 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 @@ -100,9 +100,10 @@ export interface CNPGClusterStorageResponse { // /api/cnpg/clusters/{ns}/{name}/storage — the Cluster's claims, their // measured use and each instance's WAL, every source with its own coverage. export function useCNPGClusterStorage(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'storage', namespace, name], - queryFn: ({ signal }) => fetchJSON(`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/storage`, signal), + queryKey: ['cnpg', 'storage', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/storage`, signal)), enabled: enabled && !!name, refetchInterval: 30_000, refetchIntervalInBackground: false, @@ -121,10 +122,11 @@ export interface CNPGFleetDiskResponse { // /api/cnpg/disk — the fullest measured volume of each visible Cluster. Read // with the same namespace set as the workspace so the two join row for row. export function useCNPGFleetDisk(namespaces: string[], enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') const ns = [...namespaces].sort().join(',') return useQuery({ - queryKey: ['cnpg', 'disk', ns], - queryFn: ({ signal }) => fetchJSON(`/cnpg/disk${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal), + queryKey: ['cnpg', 'disk', ns, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/disk${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal)), enabled, staleTime: 20_000, refetchInterval: 60_000, diff --git a/web/src/api/cnpg.ts b/web/src/api/cnpg.ts index 2278ad8899..b1be7d72e5 100644 --- a/web/src/api/cnpg.ts +++ b/web/src/api/cnpg.ts @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { CNPGSchedulePreview, CNPGWorkspaceResponse, TimelineEvent } from '@skyhook-io/k8s-ui' -import { ApiError, fetchJSON } from './client' +import type { CNPGKindCoverage, CNPGSchedulePreview, CNPGWorkspaceResponse, Grant, TimelineEvent } from '@skyhook-io/k8s-ui' +import { fetchJSON, useRadarFeature } from './client' +import { shouldRetryRadarQuery } from './radarFeatures' import type { CNPGOperatorDiagnosis } from './cnpg-recovery' +import { actionErrorCode, type ActionCapability, type ActionErrorCode, type ActionRequest } from './actions' // /api/cnpg/workspace // @@ -10,21 +12,21 @@ import type { CNPGOperatorDiagnosis } from './cnpg-recovery' // workspace screens, the Resources sidebar counts and the composed summaries, // so they can never disagree about what they count. export function useCNPGWorkspace(namespaces: string[], options?: { enabled?: boolean }) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') const ns = [...namespaces].sort().join(',') return useQuery({ - queryKey: ['cnpg', 'workspace', ns], - queryFn: ({ signal }) => fetchJSON(`/cnpg/workspace${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal), + queryKey: ['cnpg', 'workspace', ns, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/workspace${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal)), enabled: options?.enabled ?? true, staleTime: 10_000, refetchInterval: 30_000, placeholderData: (prev) => prev, + retry: shouldRetryRadarQuery, }) } -export interface CNPGOperatorCoverage { - state: 'full' | 'partial' | 'denied' | 'syncing' | 'error' - deniedNamespaces?: string[] -} +/** The operator's Deployments and Services are read with the same per-kind coverage as the workspace. */ +export type CNPGOperatorCoverage = CNPGKindCoverage export interface CNPGOperatorComponent { role: 'operator' | 'plugin' @@ -61,12 +63,14 @@ export interface CNPGOperatorResponse { // Deliberately not filtered by the namespace view filter: the operator runs in // its own namespace, which users rarely have selected. export function useCNPGOperator(options?: { enabled?: boolean }) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'operator'], - queryFn: ({ signal }) => fetchJSON('/cnpg/operator', signal), + queryKey: ['cnpg', 'operator', ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON('/cnpg/operator', signal)), enabled: options?.enabled ?? true, staleTime: 30_000, refetchInterval: 60_000, + retry: shouldRetryRadarQuery, }) } @@ -88,14 +92,16 @@ export interface CNPGOperatorVerdict { // // The cheap operator verdict for the fleet and cluster pages, keyed by namespace. export function useCNPGOperatorStatus(namespaces: string[], options?: { enabled?: boolean }) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') const ns = [...new Set(namespaces)].sort().join(',') return useQuery<{ namespaces: Record }>({ - queryKey: ['cnpg', 'operator-status', ns], - queryFn: ({ signal }) => fetchJSON<{ namespaces: Record }>(`/cnpg/operator/status?namespaces=${encodeURIComponent(ns)}`, signal), + queryKey: ['cnpg', 'operator-status', ns, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON<{ namespaces: Record }>(`/cnpg/operator/status?namespaces=${encodeURIComponent(ns)}`, signal)), enabled: (options?.enabled ?? true) && ns !== '', staleTime: 10_000, refetchInterval: 30_000, placeholderData: (prev) => prev, + retry: shouldRetryRadarQuery, }) } @@ -111,27 +117,22 @@ export interface CNPGClusterActivityResponse { // The Cluster's history together with its instance Pods and every CNPG object // attributed to it, including ones since deleted. export function useCNPGClusterActivity(namespace: string, name: string, sinceHours = 24) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'activity', namespace, name, sinceHours], - queryFn: ({ signal }) => { + queryKey: ['cnpg', 'activity', namespace, name, sinceHours, ...gatedKey], + queryFn: ({ signal }) => guard(() => { const since = new Date(Date.now() - sinceHours * 3600_000).toISOString() return fetchJSON( `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/activity?since=${encodeURIComponent(since)}&limit=500`, signal, ) - }, + }), staleTime: 10_000, refetchInterval: 30_000, + retry: shouldRetryRadarQuery, }) } -export interface CNPGActionCapability { - allowed: boolean - reason?: string - permission: 'allowed' | 'denied' | 'unknown' - grant?: string -} - export interface CNPGInstanceFact { pod: string podUID: string @@ -188,8 +189,8 @@ export interface CNPGClusterCapabilities { context: string facts: CNPGClusterFacts /** `restore` is creating a new Cluster in this namespace from this one's backups (`create clusters`). */ - actions: Record & { psql: CNPGActionCapability; destroyInstance: CNPGActionCapability; restore: CNPGActionCapability } - instanceActions: Record + actions: Record & { psql: ActionCapability; destroyInstance: ActionCapability; restore: ActionCapability } + instanceActions: Record restartPlan?: { primaryUpdateStrategy?: string primaryUpdateMethod?: string @@ -233,7 +234,7 @@ export interface CNPGScheduleCapabilities { schedule: string preview: CNPGSchedulePreview } - actions: Record + actions: Record operator?: CNPGOperatorVerdict } @@ -242,9 +243,10 @@ function cnpgPath(kind: 'clusters' | 'scheduledbackups' | 'poolers', namespace: } export function useCNPGClusterCapabilities(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'capabilities', 'clusters', namespace, name], - queryFn: ({ signal }) => fetchJSON(`${cnpgPath('clusters', namespace, name)}/capabilities`, signal), + queryKey: ['cnpg', 'capabilities', 'clusters', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`${cnpgPath('clusters', namespace, name)}/capabilities`, signal)), enabled: enabled && !!name, staleTime: 5_000, retry: false, @@ -254,10 +256,11 @@ export function useCNPGClusterCapabilities(namespace: string, name: string, enab // Parsed server-side with the operator's cron semantics; runs counted from the // schedule's own lastCheckTime. Pure computation: writes nothing. export function useCNPGSchedulePreview(namespace: string, name: string, schedule: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'schedule-preview', namespace, name, schedule], - queryFn: ({ signal }) => - fetchJSON(`${cnpgPath('scheduledbackups', namespace, name)}/schedule-preview?schedule=${encodeURIComponent(schedule)}`, signal), + queryKey: ['cnpg', 'schedule-preview', namespace, name, schedule, ...gatedKey], + queryFn: ({ signal }) => guard(() => + fetchJSON(`${cnpgPath('scheduledbackups', namespace, name)}/schedule-preview?schedule=${encodeURIComponent(schedule)}`, signal)), enabled: enabled && !!name, staleTime: 30_000, retry: false, @@ -266,22 +269,16 @@ export function useCNPGSchedulePreview(namespace: string, name: string, schedule } export function useCNPGScheduleCapabilities(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'capabilities', 'scheduledbackups', namespace, name], - queryFn: ({ signal }) => fetchJSON(`${cnpgPath('scheduledbackups', namespace, name)}/capabilities`, signal), + queryKey: ['cnpg', 'capabilities', 'scheduledbackups', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`${cnpgPath('scheduledbackups', namespace, name)}/capabilities`, signal)), enabled: enabled && !!name, staleTime: 5_000, retry: false, }) } -export interface CNPGActionRequest { - reviewedContext: string - uid: string - facts: Record - params?: Record -} - export interface CNPGActionResult { action: string message: string @@ -295,14 +292,15 @@ export interface CNPGActionResult { // No mutation meta: errors stay with the dialog (shown inline so the user can // adjust and retry), and the caller toasts success worded from the result. export function useCNPGAction(kind: 'clusters' | 'scheduledbackups' | 'poolers', namespace: string, name: string) { + const { guard } = useRadarFeature('cnpgWorkspace') const queryClient = useQueryClient() - return useMutation({ - mutationFn: ({ action, request }) => + return useMutation({ + mutationFn: ({ action, request }) => guard(() => fetchJSON(`${cnpgPath(kind, namespace, name)}/actions/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), - }), + })), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cnpg'] }) queryClient.invalidateQueries({ queryKey: ['resource'] }) @@ -315,34 +313,11 @@ export function useCNPGAction(kind: 'clusters' | 'scheduledbackups' | 'poolers', }) } -export type CNPGActionErrorCode = 'context_changed' | 'changed' | 'blocked' | 'all_fenced' | 'operator_webhook_unavailable' | 'outcome_unknown' | 'partial' | 'invalid_schedule' - -// A request that timed out or lost its connection may have been applied by the -// apiserver anyway. 503 without a code is Radar refusing before any write. -const CNPG_AMBIGUOUS_STATUSES = new Set([500, 502, 504]) +/** The action refusals the CloudNativePG handlers add to the shared ones. */ +export type CNPGActionErrorCode = ActionErrorCode | 'all_fenced' | 'operator_webhook_unavailable' | 'invalid_schedule' export function cnpgActionErrorCode(err: unknown): CNPGActionErrorCode | undefined { - if (!err) return undefined - if (!(err instanceof ApiError)) return 'outcome_unknown' - const code = err.data?.code - if (typeof code === 'string') return code as CNPGActionErrorCode - return CNPG_AMBIGUOUS_STATUSES.has(err.status) ? 'outcome_unknown' : undefined -} - -/** - * Confirm stays locked when the last attempt may have taken effect (unknown) - * or partly did (partial): repeating it would act on a target that moved. - */ -export function cnpgActionOutcomeLocked(err: unknown): boolean { - const code = cnpgActionErrorCode(err) - return code === 'outcome_unknown' || code === 'partial' -} - -/** The mutations a `partial` refusal reports as already done. */ -export function cnpgActionCompleted(err: unknown): string[] { - if (!(err instanceof ApiError) || err.data?.code !== 'partial') return [] - const done = err.data.completed - return Array.isArray(done) ? done.filter((d): d is string => typeof d === 'string') : [] + return actionErrorCode(err) } export type CNPGRuntimeSourceState = 'ok' | 'denied' | 'unreachable' | 'error' | 'partial' @@ -455,16 +430,17 @@ 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[] } // /api/cnpg/clusters/{ns}/{name}/runtime — live instance-manager status and // exporter metrics read through pods/proxy. Polled only while visible. export function useCNPGRuntime(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'runtime', namespace, name], - queryFn: ({ signal }) => fetchJSON(`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/runtime`, signal), + queryKey: ['cnpg', 'runtime', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/runtime`, signal)), enabled: enabled && !!name, refetchInterval: (q) => (q.state.data?.permission.proxy === 'denied' ? false : 5_000), refetchIntervalInBackground: false, @@ -476,7 +452,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 @@ -488,9 +464,10 @@ export interface CNPGPoolerRuntimeResponse { } export function useCNPGPoolerRuntime(namespace: string, name: string, enabled = true) { + const { guard, gatedKey } = useRadarFeature('cnpgWorkspace') return useQuery({ - queryKey: ['cnpg', 'pooler-runtime', namespace, name], - queryFn: ({ signal }) => fetchJSON(`/cnpg/poolers/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/runtime`, signal), + queryKey: ['cnpg', 'pooler-runtime', namespace, name, ...gatedKey], + queryFn: ({ signal }) => guard(() => fetchJSON(`/cnpg/poolers/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/runtime`, signal)), enabled: enabled && !!name, refetchInterval: (q) => (q.state.data?.permission.proxy === 'denied' ? false : 30_000), refetchIntervalInBackground: false, diff --git a/web/src/api/radarFeatures.ts b/web/src/api/radarFeatures.ts index 0d92149b61..3dbbc0ec71 100644 --- a/web/src/api/radarFeatures.ts +++ b/web/src/api/radarFeatures.ts @@ -35,6 +35,8 @@ export const RADAR_FEATURES = { drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' }, applications: { label: 'Applications', minimumVersion: 'v1.7.7' }, workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true }, + cnpgWorkspace: { label: 'CloudNativePG views', flag: 'cnpgWorkspace', flagShippedWithEndpoint: true }, + gitopsWriteEvidence: { label: 'GitOps revert warnings', flag: 'gitopsWriteEvidence', flagShippedWithEndpoint: true }, } as const satisfies Record export type RadarFeature = keyof typeof RADAR_FEATURES 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 ( - - - {certaintyGlyph(certainty)} - - - ); -} - // ============================================================================ // Quantity / resource formatting // ============================================================================ @@ -1250,15 +1217,6 @@ export function TokenGroup({ ); } -export function Notice({ children }: { children: ReactNode }) { - return ( -
    - -
    {children}
    -
    - ); -} - export function RefreshError({ message }: { message: string }) { return ( @@ -1336,31 +1294,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 +1402,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.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 12f372dcde..22f23edb86 100644 --- a/web/src/components/cnpg/CNPGBlockingSessions.tsx +++ b/web/src/components/cnpg/CNPGBlockingSessions.tsx @@ -2,14 +2,15 @@ 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' +import { useCNPGAction, useCNPGClusterCapabilities } from '../../api/cnpg' +import { actionOutcomeLocked } from '../../api/actions' 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 +54,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 && }
    @@ -82,7 +83,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,11 +342,11 @@ 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} - outcomeUnknown={cnpgActionOutcomeLocked(mutation.error)} + outcomeUnknown={actionOutcomeLocked(mutation.error)} /> ) } 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/CNPGClusterLogs.tsx b/web/src/components/cnpg/CNPGClusterLogs.tsx index afda7930a9..d8055771b8 100644 --- a/web/src/components/cnpg/CNPGClusterLogs.tsx +++ b/web/src/components/cnpg/CNPGClusterLogs.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { WorkloadLogsViewer, type WorkloadLogsFetchParams, type WorkloadLogsResult } from '@skyhook-io/k8s-ui' -import { fetchJSON } from '../../api/client' +import { fetchJSON, useRadarFeature } from '../../api/client' import { getApiBase, getCredentialsMode } from '../../api/config' import { useDesktopDownload } from '../../hooks/useDesktopDownload' import { useTheme } from '../../context/ThemeContext' @@ -43,18 +43,20 @@ export function CNPGClusterLogs({ namespace, name }: { namespace: string; name: const since = interval?.since const until = interval?.until + const { guard, support } = useRadarFeature('cnpgWorkspace') const fetchAll = useCallback( (params: WorkloadLogsFetchParams) => - fetchJSON(`${logsPath(namespace, name)}${since && until ? intervalQuery(params, since, until) : query(params)}`, { signal: params.signal }), - [namespace, name, since, until], + guard(() => fetchJSON(`${logsPath(namespace, name)}${since && until ? intervalQuery(params, since, until) : query(params)}`, { signal: params.signal })), + [guard, namespace, name, since, until], ) - const createStream = useCallback( + const stream = useCallback( (params: WorkloadLogsFetchParams) => new EventSource(`${getApiBase()}${logsPath(namespace, name)}/stream${query(params, 50)}`, { withCredentials: getCredentialsMode() === 'include', }), [namespace, name], ) + const createStream = support === 'unsupported' ? undefined : stream return (

    diff --git a/web/src/components/cnpg/CNPGClusterRuntime.tsx b/web/src/components/cnpg/CNPGClusterRuntime.tsx index 3262f317c9..235445244c 100644 --- a/web/src/components/cnpg/CNPGClusterRuntime.tsx +++ b/web/src/components/cnpg/CNPGClusterRuntime.tsx @@ -2,12 +2,10 @@ 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' -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' @@ -86,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 @@ -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..c89054e54f 100644 --- a/web/src/components/cnpg/CNPGDeclarations.tsx +++ b/web/src/components/cnpg/CNPGDeclarations.tsx @@ -6,7 +6,9 @@ import { CNPGLogicalPathView, cnpgDatabaseRoleFacts, cnpgDatabaseRoleMeta, - cnpgGitOpsSource, + cnpgManagedBy, + managedByLabel, + type CNPGWorkspaceResponse, cnpgLogicalPaths, cnpgLogicalSlotFact, isApiGroup, @@ -18,22 +20,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' @@ -64,10 +53,8 @@ function stateOf(obj: any): State { return 'pending' } -function gitopsSource(obj: any): string | undefined { - const src = cnpgGitOpsSource(obj) - if (!src) return undefined - return `${src.tool === 'argocd' ? 'Argo CD' : 'Flux'} ${src.name}` +function gitopsSource(managedBy: CNPGWorkspaceResponse['managedBy'], obj: any): string | undefined { + return managedByLabel(cnpgManagedBy({ managedBy }, obj)) } const STATE_BADGE: Record = { @@ -118,7 +105,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP state: st, meta: d.spec?.owner ? `owner ${d.spec.owner}` : undefined, error: st === 'failed' ? d.status?.message : undefined, - source: gitopsSource(d), + source: gitopsSource(data.managedBy, d), resource: cnpgResource('databases', ns, d.metadata?.name), isField: false, }) @@ -138,7 +125,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP ? p.spec?.target?.allTables ? 'all tables' : 'selected objects' : `from ${p.spec?.publicationName ?? 'an unnamed publication'} on ${p.spec?.externalClusterName ?? 'an unnamed external cluster'}`, error: pst === 'failed' ? p.status?.message : undefined, - source: gitopsSource(p), + source: gitopsSource(data.managedBy, p), resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name), isField: false, }) @@ -159,7 +146,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP state: pst, meta: p.spec?.dbname ? `database ${p.spec.dbname}` : undefined, error: pst === 'failed' ? p.status?.message : undefined, - source: gitopsSource(p), + source: gitopsSource(data.managedBy, p), resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name), isField: false, }) @@ -178,7 +165,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP state: f.state, meta: cnpgDatabaseRoleMeta(f), error: f.state === 'failed' ? f.message : undefined, - source: gitopsSource(r), + source: gitopsSource(data.managedBy, r), resource: cnpgResource('databaseroles', ns, r.metadata?.name), isField: false, }) @@ -213,7 +200,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP const fb = b.items.some((i) => i.state === 'failed') ? 0 : 1 return fa - fb || a.namespace.localeCompare(b.namespace) || a.cluster.localeCompare(b.cluster) }) - }, [data.objects.databases, data.objects.publications, data.objects.subscriptions, data.objects.databaseRoles, fleet.rows, clusterFilter, show]) + }, [data.objects.databases, data.objects.publications, data.objects.subscriptions, data.objects.databaseRoles, data.managedBy, fleet.rows, clusterFilter, show]) const totals = useMemo(() => { let failed = 0 @@ -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 29748aff55..35887b29c8 100644 --- a/web/src/components/cnpg/CNPGDetailPage.tsx +++ b/web/src/components/cnpg/CNPGDetailPage.tsx @@ -4,19 +4,19 @@ import { Activity, ArrowLeft, Database, Gauge, ShieldCheck, Unplug } from 'lucid import type { WorkloadExtraTab } from '@skyhook-io/k8s-ui' import type { SelectedResource } from '../../types' import { useConnection } from '../../context/ConnectionContext' -import { useContexts } from '../../api/client' +import { useContexts, useRadarFeature } 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 @@ -103,8 +103,10 @@ export function CNPGDetailPage({ [navigate, activeContext, onOpenResource], ) + // A Radar without the workspace endpoints shows the standard detail. + const cnpgWorkspace = useRadarFeature('cnpgWorkspace').support !== 'unsupported' const extraTabs = useMemo(() => { - if (target.plural !== 'clusters') return undefined + if (target.plural !== 'clusters' || !cnpgWorkspace) return undefined return [ { id: 'runtime', @@ -141,7 +143,7 @@ export function CNPGDetailPage({ ), }, ] - }, [target.plural, target.namespace, target.name, onOpenResource, openRelated, searchParams, setSearchParams, location.state]) + }, [target.plural, target.namespace, target.name, cnpgWorkspace, onOpenResource, openRelated, searchParams, setSearchParams, location.state]) if (pinnedContext && activeContext && pinnedContext !== activeContext) { return @@ -228,7 +230,7 @@ function NotInContext({ const pinned = contexts?.find((c) => c.name === pinnedContext) return ( <> - navigate(homePath)} - className="rounded-lg border border-theme-border bg-theme-surface px-3 py-1.5 text-sm text-theme-text-primary hover:bg-theme-hover" + className="btn-secondary px-3 py-1.5 text-sm" > Go to {homeLabel === 'Overview' ? 'CloudNativePG Overview' : homeLabel} diff --git a/web/src/components/cnpg/CNPGDrawerTrail.tsx b/web/src/components/cnpg/CNPGDrawerTrail.tsx index 55d3d9dfe7..a7dc47fa5b 100644 --- a/web/src/components/cnpg/CNPGDrawerTrail.tsx +++ b/web/src/components/cnpg/CNPGDrawerTrail.tsx @@ -2,7 +2,7 @@ import { useLocation, useSearchParams } from 'react-router-dom' import { ArrowLeft } from 'lucide-react' import { CNPG_KIND_BY_KEY } from '@skyhook-io/k8s-ui' import type { SelectedResource } from '../../types' -import { decodeDrawerTrail, encodeDrawerTrail, sameResource } from './routes' +import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail' const KIND_BY_PLURAL: Record = 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..526ec864ee 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, coverageLabel, worstCoverage, cnpgResource, type CNPGScreenProps } from './shared' +import { Mono, Notice, ScreenBody, SectionTable, Sub } from '../workspace/layout' interface CatalogRow { key: string @@ -80,7 +69,7 @@ export function CNPGOperator({ data, fleet, onInspect, inspected }: CNPGScreenPr <> {coverageGaps.length > 0 && ( - Some workloads are not readable ({coverageGaps.map((k) => `${k}: ${op.coverage[k].state}`).join(', ')}), so an operator or plugin running in those namespaces may be missing below. + Some workloads are not readable ({coverageGaps.map((k) => `${k === 'deployments' ? 'Deployments' : 'Services'}: ${coverageLabel(op.coverage[k])}`).join(', ')}), so an operator or plugin running in those namespaces may be missing below. )} return (
    -
    {headline}
    +
    {headline}
    {top.unverifiedMatch && (
    measured by {top.measuredBy}
    @@ -124,7 +126,7 @@ function AttentionCell({ row, onOpenAll }: { row: CNPGFleetRow; onOpenAll: () => {others.map((p) => (
  • - + {problemTip(p)}
  • @@ -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 9f7be11cec..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' @@ -27,13 +29,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' @@ -318,7 +319,7 @@ function InstanceCard({ inst, walCoverage, stated }: { inst: CNPGStorageInstance walCoverage.state === 'ok' ? (
    No running instance to read
    ) : ( - +
    Unknown
    ) @@ -329,11 +330,11 @@ function InstanceCard({ inst, walCoverage, stated }: { inst: CNPGStorageInstance } // "