From a00cefb6b556fc33c6edef790c630e56d2cc20df Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Wed, 27 May 2026 11:20:47 +0400 Subject: [PATCH 1/2] feat(backups): add administration page about backups Signed-off-by: Andrey Kolkov --- .../src/components/SchemaForm.test.tsx | 34 +++ apps/console/src/components/SchemaForm.tsx | 68 +++-- .../src/hooks/useBackupClassAdminAccess.ts | 20 ++ .../src/routes/BackupClassAdminGuard.test.tsx | 70 ++++++ .../src/routes/BackupClassAdminGuard.tsx | 39 +++ .../src/routes/BackupClassCreatePage.tsx | 142 +++++++++++ .../src/routes/BackupClassDetailPage.tsx | 141 +++++++++++ .../src/routes/BackupClassEditPage.tsx | 162 ++++++++++++ .../src/routes/BackupClassListPage.tsx | 234 ++++++++++++++++++ apps/console/src/routes/ConsolePage.tsx | 11 + .../src/routes/sidebar-sections.test.tsx | 55 ++++ apps/console/src/routes/sidebar-sections.tsx | 9 +- 12 files changed, 964 insertions(+), 21 deletions(-) create mode 100644 apps/console/src/hooks/useBackupClassAdminAccess.ts create mode 100644 apps/console/src/routes/BackupClassAdminGuard.test.tsx create mode 100644 apps/console/src/routes/BackupClassAdminGuard.tsx create mode 100644 apps/console/src/routes/BackupClassCreatePage.tsx create mode 100644 apps/console/src/routes/BackupClassDetailPage.tsx create mode 100644 apps/console/src/routes/BackupClassEditPage.tsx create mode 100644 apps/console/src/routes/BackupClassListPage.tsx diff --git a/apps/console/src/components/SchemaForm.test.tsx b/apps/console/src/components/SchemaForm.test.tsx index ea416cc..174722d 100644 --- a/apps/console/src/components/SchemaForm.test.tsx +++ b/apps/console/src/components/SchemaForm.test.tsx @@ -102,6 +102,40 @@ describe("SchemaForm immutableMode", () => { expect(screen.getByRole("button", { name: /add/i })).toBeDisabled() }) + it("binds the key/value editor to an additionalProperties map nested in array items", () => { + // spec.strategies[].parameters shape: an array of objects each carrying a + // free-form string map. Native rendering shows no Add control here (the + // custom ObjectFieldTemplate drops it), so an empty map would be + // uneditable — the walker must reach into items and attach the field. + const arrayMapSchema = JSON.stringify({ + type: "object", + properties: { + strategies: { + type: "array", + items: { + type: "object", + properties: { + parameters: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }, + }, + }, + }) + render( + , + ) + // AdditionalPropertiesField exposes an explicit add-key input even when the + // map is empty; native additionalProperties rendering would not. + expect(screen.getByPlaceholderText("Enter key name...")).toBeInTheDocument() + }) + it("greys out immutable nested fields inside array items", () => { const arraySchema = JSON.stringify({ type: "object", diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index bc0048d..ce69caf 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -79,7 +79,11 @@ function addBackupClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiS } /** - * Recursively find all fields with additionalProperties schema and add widget + * Recursively find all fields with additionalProperties schema and add widget. + * Walks nested objects AND array items, so a map nested inside array elements + * (e.g. spec.strategies[].parameters) gets the key/value editor too — without + * this, such maps fall back to native rendering whose Add control the custom + * ObjectFieldTemplate omits, leaving empty maps with no way to add entries. */ function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { if (!schema || typeof schema !== "object") return uiSchema @@ -91,31 +95,55 @@ function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema = for (const [key, value] of Object.entries(properties)) { if (typeof value === "object" && value !== null) { - const fieldSchema = value as any - // Check if this field has additionalProperties with a schema - const hasAdditionalPropertiesSchema = - fieldSchema.type === "object" && - (!fieldSchema.properties || Object.keys(fieldSchema.properties).length === 0) && - typeof fieldSchema.additionalProperties === "object" && - fieldSchema.additionalProperties !== null && - fieldSchema.additionalProperties !== true - - if (hasAdditionalPropertiesSchema) { - // Found a field with additionalProperties schema - use custom field - result[key] = { - ...result[key], - "ui:field": "AdditionalPropertiesField", - } - } else if (fieldSchema.properties) { - // Recursively process nested objects - result[key] = addAdditionalPropertiesWidgets(fieldSchema, result[key] as UiSchema) - } + const bound = bindAdditionalProperties(value as RJSFSchema, result[key] as UiSchema | undefined) + if (bound !== undefined) result[key] = bound } } return result } +/** + * Resolve the uiSchema fragment for one schema node: bind the custom field to + * an additionalProperties map, recurse into nested objects, or recurse into + * array `items`. Returns the (possibly unchanged) ui fragment. + */ +function bindAdditionalProperties( + fieldSchema: RJSFSchema, + uiNode: UiSchema | undefined, +): UiSchema | undefined { + const node = fieldSchema as any + + const isAdditionalPropertiesMap = + node.type === "object" && + (!node.properties || Object.keys(node.properties).length === 0) && + typeof node.additionalProperties === "object" && + node.additionalProperties !== null && + node.additionalProperties !== true + + if (isAdditionalPropertiesMap) { + return { ...uiNode, "ui:field": "AdditionalPropertiesField" } + } + + if (node.properties) { + return addAdditionalPropertiesWidgets(fieldSchema, uiNode as UiSchema) + } + + if ( + node.type === "array" && + node.items && + typeof node.items === "object" && + !Array.isArray(node.items) + ) { + const itemsUi = bindAdditionalProperties(node.items as RJSFSchema, (uiNode as any)?.items) + if (itemsUi !== undefined) { + return { ...uiNode, items: itemsUi } + } + } + + return uiNode +} + /** * Add VMDiskWidget to the "name" field inside "disks" array items */ diff --git a/apps/console/src/hooks/useBackupClassAdminAccess.ts b/apps/console/src/hooks/useBackupClassAdminAccess.ts new file mode 100644 index 0000000..890fb5b --- /dev/null +++ b/apps/console/src/hooks/useBackupClassAdminAccess.ts @@ -0,0 +1,20 @@ +import { useSelfSubjectAccessReview } from "@cozystack/k8s-client" + +// BackupClass is cluster-scoped. Tenants already hold get/list/watch on it +// (that's what powers the BackupClassWidget dropdown), so a read gate would +// not exclude them — only write does. Gating on `update` is what makes the +// Backup Classes area admin-only. Fail closed: loading and error states +// resolve as "not allowed" so the sidebar entry never flickers in then out. +export function useBackupClassAdminAccess(): { allowed: boolean; isLoading: boolean } { + const review = useSelfSubjectAccessReview({ + resourceAttributes: { + group: "backups.cozystack.io", + resource: "backupclasses", + verb: "update", + }, + }) + return { + isLoading: review.isLoading, + allowed: !review.isLoading && !review.error && review.allowed, + } +} diff --git a/apps/console/src/routes/BackupClassAdminGuard.test.tsx b/apps/console/src/routes/BackupClassAdminGuard.test.tsx new file mode 100644 index 0000000..d45b465 --- /dev/null +++ b/apps/console/src/routes/BackupClassAdminGuard.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest" +import { screen, waitFor } from "@testing-library/react" +import { Route, Routes } from "react-router" +import { K8sClient, K8sApiError } from "@cozystack/k8s-client" +import { renderWithK8sProvider } from "../test-utils/render.tsx" +import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx" + +type SsarOutcome = { allowed: boolean } | "pending" | K8sApiError + +function makeClient(outcome: SsarOutcome): K8sClient { + const client = new K8sClient({ baseUrl: "/mock" }) + vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => { + if (outcome === "pending") return new Promise(() => {}) as never + if (outcome instanceof K8sApiError) throw outcome + return { ...(body as object), status: { allowed: outcome.allowed } } + }) + return client +} + +function renderGuard(client: K8sClient) { + return renderWithK8sProvider( + + }> + BACKUP CLASSES CONTENT} /> + + , + { client, initialRoute: "/bc" }, + ) +} + +describe("BackupClassAdminGuard", () => { + it("renders the child route when update is allowed", async () => { + renderGuard(makeClient({ allowed: true })) + await waitFor(() => + expect(screen.getByText("BACKUP CLASSES CONTENT")).toBeInTheDocument(), + ) + }) + + it("renders permission-denied instead of the child route when update is denied", async () => { + renderGuard(makeClient({ allowed: false })) + await waitFor(() => + expect( + screen.getByText(/do not have permission to manage backup classes/i), + ).toBeInTheDocument(), + ) + expect(screen.queryByText("BACKUP CLASSES CONTENT")).not.toBeInTheDocument() + expect(screen.getByRole("link", { name: /back to console/i })).toHaveAttribute( + "href", + "/console", + ) + }) + + it("fails closed (denied) on SSAR error", async () => { + renderGuard(makeClient(new K8sApiError(500, "boom"))) + await waitFor(() => + expect( + screen.getByText(/do not have permission to manage backup classes/i), + ).toBeInTheDocument(), + ) + expect(screen.queryByText("BACKUP CLASSES CONTENT")).not.toBeInTheDocument() + }) + + it("shows neither content nor denial while the review is loading", () => { + renderGuard(makeClient("pending")) + expect(screen.queryByText("BACKUP CLASSES CONTENT")).not.toBeInTheDocument() + expect( + screen.queryByText(/do not have permission to manage backup classes/i), + ).not.toBeInTheDocument() + }) +}) diff --git a/apps/console/src/routes/BackupClassAdminGuard.tsx b/apps/console/src/routes/BackupClassAdminGuard.tsx new file mode 100644 index 0000000..afb711d --- /dev/null +++ b/apps/console/src/routes/BackupClassAdminGuard.tsx @@ -0,0 +1,39 @@ +import { Link, Outlet } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +import { useBackupClassAdminAccess } from "../hooks/useBackupClassAdminAccess.ts" + +/** + * Layout route guard for the Backup Classes pages. Renders the matched child + * route only for users who may update backup classes; everyone else gets a + * permission-denied message with a link back to the console instead of the + * page (and instead of a browser 403 on direct URL navigation). + */ +export function BackupClassAdminGuard() { + const { allowed, isLoading } = useBackupClassAdminAccess() + + if (isLoading) { + return ( +
+ Loading… +
+ ) + } + + if (!allowed) { + return ( +
+
+
+ You do not have permission to manage backup classes.{" "} + + Back to console + + . +
+
+
+ ) + } + + return +} diff --git a/apps/console/src/routes/BackupClassCreatePage.tsx b/apps/console/src/routes/BackupClassCreatePage.tsx new file mode 100644 index 0000000..d58244b --- /dev/null +++ b/apps/console/src/routes/BackupClassCreatePage.tsx @@ -0,0 +1,142 @@ +import { useState } from "react" +import { Link, useNavigate } from "react-router" +import { ArrowLeft, Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sCreate, type K8sResource } from "@cozystack/k8s-client" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +// BackupClass is cluster-scoped; the guard wrapping this route already +// enforces that only an SSAR-allowed admin reaches it. +export function BackupClassCreatePage() { + const navigate = useNavigate() + const [formData, setFormData] = useState({}) + const [name, setName] = useState("") + + const { schema, isLoading: schemaLoading } = useCRDSchema( + "backupclasses.backups.cozystack.io", + ) + + const createMutation = useK8sCreate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }) + + const listPath = "/console/backups/backupclasses" + + const handleSubmit = async () => { + if (!name.trim()) { + alert("Name is required") + return + } + const resource: K8sResource = { + apiVersion: "backups.cozystack.io/v1alpha1", + kind: "BackupClass", + metadata: { name: name.trim() }, + spec: formData, + } + try { + await createMutation.mutateAsync(resource) + navigate(`${listPath}/${encodeURIComponent(name.trim())}`) + } catch (err) { + alert(`Failed to create Backup Class: ${(err as Error).message}`) + } + } + + if (schemaLoading) { + return ( +
+ Loading schema… +
+ ) + } + + if (!schema) { + return ( +
+ Failed to load Backup Class schema. Please refresh the page. +
+ ) + } + + return ( +
+ + Backups + + +
+
+ +
+
+

Create Backup Class

+

+ Define a backup strategy for one or more application kinds +

+
+
+ +
+
+
+ + setName(e.target.value)} + placeholder="my-backup-class" + className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400" + required + /> +
+ +
+ +
+ +
+
+ +
+ + +
+
+
+ ) +} diff --git a/apps/console/src/routes/BackupClassDetailPage.tsx b/apps/console/src/routes/BackupClassDetailPage.tsx new file mode 100644 index 0000000..2abd1c1 --- /dev/null +++ b/apps/console/src/routes/BackupClassDetailPage.tsx @@ -0,0 +1,141 @@ +import { Link, useNavigate, useParams } from "react-router" +import yaml from "js-yaml" +import { ArrowLeft, Archive, Edit, Trash2 } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sDelete, useK8sGet, type K8sResource } from "@cozystack/k8s-client" +import { formatAge } from "../lib/status.ts" + +export function BackupClassDetailPage() { + const { name } = useParams<{ name: string }>() + const navigate = useNavigate() + + // BackupClass is cluster-scoped — not tenant-namespaced. + const { data: backupClass, isLoading, error } = useK8sGet( + { + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + name: name ?? "", + }, + { enabled: !!name }, + ) + + const deleteMutation = useK8sDelete({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }) + + const handleDelete = async () => { + if (!name) return + if (!confirm(`Delete Backup Class "${name}"? This cannot be undone.`)) return + try { + await deleteMutation.mutateAsync(name) + navigate("/console/backups/backupclasses") + } catch (err) { + alert(`Failed to delete Backup Class: ${(err as Error).message}`) + } + } + + if (isLoading) { + return ( +
+ Loading… +
+ ) + } + + if (error) { + return ( +
+ Failed to load Backup Class: {(error as Error).message} +
+ ) + } + + if (!backupClass) { + return
Backup Class not found.
+ } + + const labelEntries = Object.entries(backupClass.metadata.labels ?? {}) + + return ( +
+ + Backups + + +
+
+
+ +
+
+

+ {backupClass.metadata.name} +

+

Backup Class

+
+
+
+ + + + +
+
+ +
+
+
+
+
Name
+
+ {backupClass.metadata.name} +
+
+
+
Age
+
+ {formatAge(backupClass.metadata.creationTimestamp)} +
+
+
+ {labelEntries.length > 0 && ( +
+

Labels

+
+ {labelEntries.map(([k, v]) => ( + + {k}={v} + + ))} +
+
+ )} +
+ +
+
+            {yaml.dump(backupClass.spec ?? {})}
+          
+
+
+
+ ) +} diff --git a/apps/console/src/routes/BackupClassEditPage.tsx b/apps/console/src/routes/BackupClassEditPage.tsx new file mode 100644 index 0000000..f24eacf --- /dev/null +++ b/apps/console/src/routes/BackupClassEditPage.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from "react" +import { Link, useNavigate, useParams } from "react-router" +import { ArrowLeft, Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sGet, useK8sUpdate, type K8sResource } from "@cozystack/k8s-client" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { prepareUpdateSpec } from "../lib/prepare-update.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +// BackupClass is cluster-scoped — editable by cluster admins, not tenant +// users. The API server's RBAC is the real gate; a tenant-scoped user's PUT +// here simply 403s and surfaces in the error alert. +export function BackupClassEditPage() { + const { name } = useParams<{ name: string }>() + const navigate = useNavigate() + const [formData, setFormData] = useState({}) + const initializedRef = useRef(false) + // Snapshot of spec at form-init time, used as the immutable-overlay source + // in prepareUpdateSpec so refetches can't change what gets PUT. + const initialSpecRef = useRef(null) + + const { schema, isLoading: schemaLoading } = useCRDSchema( + "backupclasses.backups.cozystack.io", + ) + + const { + data: resource, + isLoading: resourceLoading, + error, + } = useK8sGet( + { + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + name: name ?? "", + }, + { enabled: !!name }, + ) + + const updateMutation = useK8sUpdate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }) + + // Initialise form data from the resource once, so a refetch mid-edit + // doesn't clobber in-progress changes. + useEffect(() => { + if (resource?.spec && !initializedRef.current) { + initializedRef.current = true + setFormData(resource.spec) + initialSpecRef.current = resource.spec + } + }, [resource]) + + const detailPath = `/console/backups/backupclasses/${name}` + + const handleSubmit = async () => { + if (!resource || !schema) return + const updated: K8sResource = { + ...resource, + spec: prepareUpdateSpec(formData, initialSpecRef.current, schema), + } + try { + await updateMutation.mutateAsync(updated) + navigate(detailPath) + } catch (err) { + alert(`Failed to update Backup Class: ${(err as Error).message}`) + } + } + + if (schemaLoading || resourceLoading) { + return ( +
+ Loading… +
+ ) + } + + if (error) { + return ( +
+ Failed to load Backup Class: {(error as Error).message} +
+ ) + } + + if (!resource) { + return
Backup Class not found.
+ } + + if (!schema) { + return ( +
+ Failed to load schema. Please refresh the page. +
+ ) + } + + return ( +
+ + {name} + + +
+
+ +
+
+

Edit Backup Class

+

{name}

+
+
+ +
+
+ +
+ +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/console/src/routes/BackupClassListPage.tsx b/apps/console/src/routes/BackupClassListPage.tsx new file mode 100644 index 0000000..aa22b51 --- /dev/null +++ b/apps/console/src/routes/BackupClassListPage.tsx @@ -0,0 +1,234 @@ +import { Link } from "react-router" +import { Archive, ChevronRight, Plus } from "lucide-react" +import { Button, Section, Spinner, StatusBadge } from "@cozystack/ui" +import { useK8sGet, useK8sList, type K8sResource } from "@cozystack/k8s-client" +import type { ApplicationInstance } from "@cozystack/types" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useApplicationDefinitions, iconDataUrl } from "../lib/app-definitions.ts" +import { formatAge, readyCondition } from "../lib/status.ts" + +// Hardcoded: cozy-backups in tenant-root is the cluster's system backup +// bucket. Swap this for a discovery hook once cozystack exposes a canonical +// reference (e.g. via a status field on BackupClass or a known label). +function SystemBackupBucketPanel() { + const { selectTenant } = useTenantContext() + const { data: bucket, isLoading } = useK8sGet({ + apiGroup: "apps.cozystack.io", + apiVersion: "v1alpha1", + plural: "buckets", + name: "cozy-backups", + namespace: "tenant-root", + }) + // Reuse the Bucket ApplicationDefinition's own icon (the same red S3 + // glyph rendered on deployed-app cards) so the bucket here is visually + // recognisable as a Bucket app. + const { data: appDefs } = useApplicationDefinitions() + const bucketAd = appDefs?.items.find((d) => d.spec?.application.kind === "Bucket") + const bucketIcon = bucketAd ? iconDataUrl(bucketAd) : undefined + + if (isLoading) { + return ( +
+
+ Loading system backup bucket… +
+
+ ) + } + + // Bucket missing (or unreachable) — surface as an attention-grabbing + // placeholder rather than hiding, so an admin can't miss the gap. + if (!bucket) { + return ( +
+
+
+
+ +
+
+
cozy-backups
+
+ Not deployed on this cluster +
+
+
+ Not configured +
+
+ ) + } + + const ready = readyCondition(bucket) + const tone = ready?.status === "True" ? "ok" : ready ? "warn" : "muted" + const label = + ready?.status === "True" ? "Ready" : (ready?.reason ?? "Unknown") + + return ( +
+ selectTenant("root")} + className="flex h-full items-center justify-between gap-4 p-4 hover:bg-slate-50" + > +
+
+ {bucketIcon ? ( + + ) : ( + + )} +
+
+
cozy-backups
+
+ System backup bucket · {formatAge(bucket.metadata.creationTimestamp)} +
+
+
+
+ {label} + +
+ +
+ ) +} + +// Cluster-wide count of backup artifacts. List call omits the namespace so it +// hits the all-namespaces endpoint; the click target is the per-tenant list +// page, which scopes to whatever tenant the admin has selected. +function BackupArtifactsCountPanel() { + const { data, isLoading } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backups", + }) + + if (isLoading) { + return ( +
+
+ Loading backup artifacts… +
+
+ ) + } + + const count = data?.items?.length ?? 0 + + return ( +
+ +
+
+ +
+
+
{count}
+
Backup artifacts count
+
+
+ + +
+ ) +} + +export function BackupClassListPage() { + // BackupClass is cluster-scoped — not tenant-namespaced. + const { data, isLoading, error } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }) + + const items = data?.items ?? [] + + if (isLoading) { + return ( +
+ Loading… +
+ ) + } + + if (error) { + return ( +
+ Failed to load Backup Classes: {(error as Error).message} +
+ ) + } + + return ( +
+
+

Backups

+

System storage and classes configuration

+
+ +
+ + +
+ +
+
+
+

Backup Classes

+

+ {items.length} {items.length === 1 ? "item" : "items"} +

+
+ + + +
+ {items.length === 0 ? ( +
+ No backup classes found. +
+ ) : ( +
+ + + + + + + + + {items.map((item) => ( + + + + + ))} + +
NameAge
+ + {item.metadata.name} + + + {item.metadata.creationTimestamp + ? formatAge(item.metadata.creationTimestamp) + : "-"} +
+
+ )} +
+
+ ) +} diff --git a/apps/console/src/routes/ConsolePage.tsx b/apps/console/src/routes/ConsolePage.tsx index 5e0a249..3e9a750 100644 --- a/apps/console/src/routes/ConsolePage.tsx +++ b/apps/console/src/routes/ConsolePage.tsx @@ -9,6 +9,11 @@ import { ApplicationListPage } from "./ApplicationListPage.tsx" import { ApplicationDetailPage } from "./detail/ApplicationDetailPage.tsx" import { ApplicationEditRoute } from "./detail/ApplicationEditRoute.tsx" import { BackupResourceListPage } from "./BackupResourceListPage.tsx" +import { BackupClassListPage } from "./BackupClassListPage.tsx" +import { BackupClassDetailPage } from "./BackupClassDetailPage.tsx" +import { BackupClassEditPage } from "./BackupClassEditPage.tsx" +import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" +import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx" import { BackupResourceEditPage } from "./BackupResourceEditPage.tsx" import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx" import { BackupJobCreatePage } from "./BackupJobCreatePage.tsx" @@ -73,6 +78,12 @@ export function ConsolePage() { path="backups/restorejobs/:name/edit" element={} /> + }> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/console/src/routes/sidebar-sections.test.tsx b/apps/console/src/routes/sidebar-sections.test.tsx index 783d3e2..a553d70 100644 --- a/apps/console/src/routes/sidebar-sections.test.tsx +++ b/apps/console/src/routes/sidebar-sections.test.tsx @@ -115,3 +115,58 @@ describe("useConsoleSidebarSections — Cluster Usage gate", () => { }) }) }) + +// The sidebar issues two SSARs (nodes/list for Cluster Usage, and +// backupclasses/update for Backup Classes); this client answers each by the +// requested resource so the two gates can be exercised independently. +function makeResourceClient(allow: Record): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockResolvedValue(emptyAppDefList as K8sList) + vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => { + const resource = + (body as SelfSubjectAccessReview).spec?.resourceAttributes?.resource ?? "" + return { + ...(body as object), + status: { allowed: allow[resource] ?? false }, + } as unknown + }) + return client +} + +// The admin "Backups" entry collides by label with the per-tenant "Backups" +// item in the Backups group, so locate the admin one by section + URL. +function findAdminBackupsItem( + sections: ReturnType, +) { + const admin = sections.find((s) => s.title === "Administration") + return admin?.items.find((i) => i.to === "/console/backups/backupclasses") +} + +describe("useConsoleSidebarSections — Backup Classes gate", () => { + it("shows the admin Backups entry when update on backupclasses is allowed", async () => { + const client = makeResourceClient({ backupclasses: true }) + const { result } = renderHook(() => useConsoleSidebarSections(), { + wrapper: makeWrapper(client), + }) + await waitFor(() => { + const item = findAdminBackupsItem(result.current) + expect(item).toBeDefined() + expect(item?.label).toBe("Backups") + }) + }) + + it("hides the admin Backups entry when update on backupclasses is denied (read-only tenant)", async () => { + // list allowed, update denied — the read a tenant actually has must NOT + // be enough to surface the admin entry. + const client = makeResourceClient({ backupclasses: false }) + const { result } = renderHook(() => useConsoleSidebarSections(), { + wrapper: makeWrapper(client), + }) + await waitFor(() => { + expect(client.create).toHaveBeenCalled() + expect(findAdminBackupsItem(result.current)).toBeUndefined() + }) + // The per-tenant "Backups" group item (different URL) remains visible. + expect(findItem(result.current, "Plans")).toBeDefined() + }) +}) diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index a37326a..4e7b69a 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react" import type { SidebarSection } from "@cozystack/ui" import { useSelfSubjectAccessReview } from "@cozystack/k8s-client" +import { useBackupClassAdminAccess } from "../hooks/useBackupClassAdminAccess.ts" import { useApplicationDefinitions, groupByCategory } from "../lib/app-definitions.ts" import { humanizeKind } from "../lib/humanize.ts" import { @@ -82,6 +83,9 @@ export function useConsoleSidebarSections(): SidebarSection[] { !clusterUsageReview.isLoading && !clusterUsageReview.error && clusterUsageReview.allowed + // Backup Classes is admin-only: tenants have cluster-wide read on + // backupclasses, so the entry is gated on write (update), not list. + const { allowed: canManageBackupClasses } = useBackupClassAdminAccess() return useMemo(() => { const sorted = [...grouped] @@ -125,6 +129,9 @@ export function useConsoleSidebarSections(): SidebarSection[] { ...(canSeeClusterUsage ? [{ label: "Cluster Usage", to: "/console/cluster-usage", icon: Gauge }] : []), + ...(canManageBackupClasses + ? [{ label: "Backups", to: "/console/backups/backupclasses", icon: Archive }] + : []), { label: "Info", to: "/console/info", icon: Info }, { label: "Modules", to: "/console/modules", icon: ToyBrick }, { label: "External IPs", to: "/console/external-ips", icon: Globe }, @@ -133,5 +140,5 @@ export function useConsoleSidebarSections(): SidebarSection[] { } return [...categorySections, backupsSection, administrationSection] - }, [grouped, canSeeClusterUsage]) + }, [grouped, canSeeClusterUsage, canManageBackupClasses]) } From 19c4a0fbca3b8116a69256cb902bdf2e9aff9ce7 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 29 May 2026 11:11:35 +0400 Subject: [PATCH 2/2] add column about application on lists of backup pages Signed-off-by: Andrey Kolkov --- .../src/routes/BackupResourceListPage.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/console/src/routes/BackupResourceListPage.tsx b/apps/console/src/routes/BackupResourceListPage.tsx index c00f33d..eacdc51 100644 --- a/apps/console/src/routes/BackupResourceListPage.tsx +++ b/apps/console/src/routes/BackupResourceListPage.tsx @@ -13,6 +13,18 @@ interface BackupResource { namespace?: string creationTimestamp?: string } + spec?: { + applicationRef?: { + apiGroup?: string + kind?: string + name?: string + } + targetApplicationRef?: { + apiGroup?: string + kind?: string + name?: string + } + } status?: { phase?: string conditions?: Array<{ @@ -110,6 +122,7 @@ export function BackupResourceListPage({ resourceType, title }: BackupResourceLi Name + Application Namespace Status Age @@ -126,6 +139,13 @@ export function BackupResourceListPage({ resourceType, title }: BackupResourceLi statusText === "Failed" || statusText === "False" ? "error" : "warn" + const appRef = resourceType === "restorejobs" + ? item.spec?.targetApplicationRef + : item.spec?.applicationRef + const appRefText = appRef?.kind && appRef?.name + ? `${appRef.kind}/${appRef.name}` + : appRef?.name || "-" + return ( {item.metadata.name} + + {appRefText} + {item.metadata.namespace}