diff --git a/apps/console/src/components/BackupClassWidget.tsx b/apps/console/src/components/BackupClassWidget.tsx index 1781a0f..fd03acc 100644 --- a/apps/console/src/components/BackupClassWidget.tsx +++ b/apps/console/src/components/BackupClassWidget.tsx @@ -19,28 +19,34 @@ export function BackupClassWidget(props: WidgetProps) { }) const backupClasses = classList?.items || [] + const currentValue = typeof value === "string" ? value : "" + const hasCurrentInList = backupClasses.some((bc) => bc.metadata.name === currentValue) return ( from losing the parent's selection on + async re-renders of useK8sList (loading → loaded → refetch). */} + {currentValue && !hasCurrentInList && ( + + )} + {backupClasses.map((bc) => ( + + ))} + {!isLoading && backupClasses.length === 0 && !currentValue && ( - ) : ( - backupClasses.map((bc) => ( - - )) )} ) diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index af6a47d..e396863 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -51,6 +51,14 @@ function addBackupClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiS for (const [key, value] of Object.entries(properties)) { if (key === "backupClassName" && typeof value === "object" && (value as any).type === "string") { + // Skip attaching the custom widget when an explicit enum is already + // present — the parent supplies the option list, RJSF's native + // SelectWidget handles binding correctly. Auto-attaching here would + // override the select with our BackupClassWidget whose internal + // useK8sList state can drop the user's selection on async re-renders. + if (Array.isArray((value as any).enum)) { + continue + } // Found a backupClassName field - add widget result[key] = { ...result[key], @@ -169,18 +177,21 @@ export function SchemaForm({ const onChangeRef = useRef(onChange) onChangeRef.current = onChange - const initialFormDataRef = useRef(formData) + const formDataRef = useRef(formData) + formDataRef.current = formData const emittedSchemaRef = useRef(null) // Emit defaults to parent once per schema so spec is never empty on first submit. - // Uses initialFormDataRef so edit-mode existing values are preserved as base. - // emittedSchemaRef prevents re-running on unrelated re-renders and avoids - // overwriting user data if the schema object changes identity unexpectedly. + // Uses formDataRef (current parent state, not the initial mount snapshot) so + // user input is preserved when the parent recomputes openAPISchema due to + // async sibling data (e.g. plansData/backupClassesData loading) — without + // this, getDefaultFormState would re-emit defaults computed from the stale + // initial formData and wipe whatever the user already typed. useEffect(() => { if (!schema || Object.keys(schema).length === 0) return if (emittedSchemaRef.current === schema) return emittedSchemaRef.current = schema - const defaults = getDefaultFormState(validator, schema, initialFormDataRef.current ?? {}, schema) + const defaults = getDefaultFormState(validator, schema, formDataRef.current ?? {}, schema) onChangeRef.current(defaults) // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema]) diff --git a/apps/console/src/routes/BackupJobCreatePage.tsx b/apps/console/src/routes/BackupJobCreatePage.tsx new file mode 100644 index 0000000..b1ecfd3 --- /dev/null +++ b/apps/console/src/routes/BackupJobCreatePage.tsx @@ -0,0 +1,247 @@ +import { useState, useMemo } from "react" +import { useNavigate } from "react-router" +import { Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sCreate, useK8sList } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" +import { enrichSchemaWithEnums } from "../lib/backup-utils.ts" + +export function BackupJobCreatePage() { + const navigate = useNavigate() + const { tenantNamespace } = useTenantContext() + const { data: appDefs } = useApplicationDefinitions() + const [formData, setFormData] = useState({}) + const [name, setName] = useState("") + + // Get base schema from CRD + const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema( + "backupjobs.backups.cozystack.io" + ) + + // Get BackupClasses (cluster-scoped) + const { data: backupClassesData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }) + + // Get Plans in the tenant namespace (optional reference) + const { data: plansData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "plans", + namespace: tenantNamespace ?? "", + }, { enabled: !!tenantNamespace }) + + // Resolve instances for the selected application kind. + // Mirrors BackupRestoreJobCreatePage: kind dropdown is gated to + // apps.cozystack.io (the only apiGroup ApplicationDefinitions cover). + // Strict undefined check so an explicit empty string from the user means + // "no group" — clearing the field opts out of the cozystack defaults. + const selectedKind = formData?.applicationRef?.kind + const rawApiGroup = formData?.applicationRef?.apiGroup + const selectedApiGroup = rawApiGroup === undefined ? "apps.cozystack.io" : rawApiGroup + const selectedAppDef = useMemo( + () => appDefs?.items.find(d => d.spec?.application.kind === selectedKind), + [appDefs, selectedKind] + ) + + const { data: instancesData } = useK8sList({ + apiGroup: "apps.cozystack.io", + apiVersion: "v1alpha1", + plural: selectedAppDef?.spec?.application.plural ?? "", + namespace: tenantNamespace ?? "", + }, { enabled: !!selectedAppDef && !!tenantNamespace && selectedApiGroup === "apps.cozystack.io" }) + + const createMutation = useK8sCreate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupjobs", + namespace: tenantNamespace ?? "", + }) + + const schema = useMemo(() => { + if (!baseSchema) return null + + const base = JSON.parse(baseSchema) + const kinds: string[] = selectedApiGroup === "apps.cozystack.io" + ? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] + : [] + const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] + const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] + const plans = plansData?.items.map((p: any) => p.metadata.name) ?? [] + + const enumMap: Record = {} + if (kinds.length > 0) { + enumMap["applicationRef.kind"] = kinds + } + if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) { + enumMap["applicationRef.name"] = instances + } + if (backupClasses.length > 0) { + enumMap["backupClassName"] = backupClasses + } + if (plans.length > 0) { + // planRef is optional in the CRD (default ""). Prepend an empty value + // so the dropdown opens with no plan selected — matches the CRD default + // and avoids accidentally pinning the BackupJob to the first listed Plan. + enumMap["planRef.name"] = ["", ...plans] + } + + const enriched = enrichSchemaWithEnums(base, [], enumMap) + + // Default the optional apiGroup to apps.cozystack.io so the cozystack- + // managed kinds match without the user typing the group manually. + if (enriched.properties?.applicationRef?.properties?.apiGroup) { + enriched.properties.applicationRef.properties.apiGroup.default = "apps.cozystack.io" + } + + return JSON.stringify(enriched) + }, [baseSchema, appDefs, backupClassesData, plansData, instancesData, selectedKind, selectedApiGroup]) + + const handleSubmit = async () => { + if (!tenantNamespace) { + alert("Tenant namespace is not available. Please refresh.") + return + } + + if (!name.trim()) { + alert("Name is required") + return + } + + if (!formData.applicationRef?.kind || !formData.applicationRef?.name) { + alert("Application reference is required") + return + } + + if (!formData.backupClassName) { + alert("Backup class name is required") + return + } + + // planRef is optional metadata recording which Plan triggered the job. The + // dropdown ships an empty sentinel; strip it so the API never receives + // `planRef: { name: "" }`, which would otherwise round-trip as a malformed + // LocalObjectReference. + const spec = { ...formData } + if (!spec.planRef?.name) { + delete spec.planRef + } + + const resource = { + apiVersion: "backups.cozystack.io/v1alpha1", + kind: "BackupJob", + metadata: { + name: name.trim(), + namespace: tenantNamespace ?? undefined, + }, + spec, + } + + try { + await createMutation.mutateAsync(resource) + navigate("/console/backups/backupjobs") + } catch (err) { + alert(`Failed to create BackupJob: ${(err as Error).message}`) + } + } + + const handleCancel = () => { + navigate("/console/backups/backupjobs") + } + + if (schemaLoading) { + return ( +
+ Loading schema... +
+ ) + } + + if (!schema) { + return ( +
+ Failed to load BackupJob schema. Please refresh the page. +
+ ) + } + + return ( +
+
+
+ +
+
+

Create Backup Job

+

+ Trigger a backup of an application instance +

+
+
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="my-backup-job" + 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/BackupPlanCreatePage.tsx b/apps/console/src/routes/BackupPlanCreatePage.tsx index b4e7076..4ee79d4 100644 --- a/apps/console/src/routes/BackupPlanCreatePage.tsx +++ b/apps/console/src/routes/BackupPlanCreatePage.tsx @@ -28,8 +28,12 @@ export function BackupPlanCreatePage() { plural: "backupclasses", }) - // Get instances for selected kind + // Get instances for selected kind. + // Strict undefined check so an explicit empty string from the user means + // "no group" — clearing the field opts out of the cozystack defaults. const selectedKind = formData?.applicationRef?.kind + const rawApiGroup = formData?.applicationRef?.apiGroup + const selectedApiGroup = rawApiGroup === undefined ? "apps.cozystack.io" : rawApiGroup const selectedAppDef = useMemo( () => appDefs?.items.find(d => d.spec?.application.kind === selectedKind), [appDefs, selectedKind] @@ -40,7 +44,7 @@ export function BackupPlanCreatePage() { apiVersion: "v1alpha1", plural: selectedAppDef?.spec?.application.plural ?? "", namespace: tenantNamespace ?? "", - }, { enabled: !!selectedAppDef && !!tenantNamespace }) + }, { enabled: !!selectedAppDef && !!tenantNamespace && selectedApiGroup === "apps.cozystack.io" }) const createMutation = useK8sCreate({ apiGroup: "backups.cozystack.io", @@ -53,7 +57,12 @@ export function BackupPlanCreatePage() { if (!baseSchema) return null const base = JSON.parse(baseSchema) - const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] + // ApplicationDefinitions are exclusive to apps.cozystack.io — show the + // Kind dropdown only when the selected apiGroup matches; otherwise leave + // it as a free-text input (no enum hint). + const kinds: string[] = selectedApiGroup === "apps.cozystack.io" + ? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] + : [] const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] @@ -63,7 +72,7 @@ export function BackupPlanCreatePage() { if (kinds.length > 0) { enumMap["applicationRef.kind"] = kinds } - if (selectedKind && instances.length > 0) { + if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) { enumMap["applicationRef.name"] = instances } if (backupClasses.length > 0) { @@ -79,7 +88,7 @@ export function BackupPlanCreatePage() { } return JSON.stringify(enriched) - }, [baseSchema, appDefs, backupClassesData, instancesData, selectedKind]) + }, [baseSchema, appDefs, backupClassesData, instancesData, selectedKind, selectedApiGroup]) const handleSubmit = async () => { if (!tenantNamespace) { diff --git a/apps/console/src/routes/BackupResourceCreatePage.tsx b/apps/console/src/routes/BackupResourceCreatePage.tsx deleted file mode 100644 index dcd53d2..0000000 --- a/apps/console/src/routes/BackupResourceCreatePage.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useState } from "react" -import { useNavigate } from "react-router" -import { Archive, Save } from "lucide-react" -import { Button, Section, Spinner } from "@cozystack/ui" -import { useK8sCreate } from "@cozystack/k8s-client" -import { useTenantContext } from "../lib/tenant-context.tsx" -import { useCRDSchema } from "../lib/use-crd-schema.ts" -import { SchemaForm } from "../components/SchemaForm.tsx" - -interface BackupResourceCreatePageProps { - resourceType: "plans" | "backupjobs" | "backups" | "restorejobs" - title: string - overrideSchema?: string // Optional schema override (e.g., with enum values) -} - -export function BackupResourceCreatePage({ - resourceType, - title, - overrideSchema, -}: BackupResourceCreatePageProps) { - const navigate = useNavigate() - const { tenantNamespace } = useTenantContext() - const [formData, setFormData] = useState({}) - const [name, setName] = useState("") - - // Map resourceType to CRD name - const crdNameMap = { - plans: "plans.backups.cozystack.io", - backupjobs: "backupjobs.backups.cozystack.io", - backups: "backups.backups.cozystack.io", - restorejobs: "restorejobs.backups.cozystack.io", - } - - const { schema: crdSchema, isLoading: schemaLoading } = useCRDSchema(crdNameMap[resourceType]) - - // Use override schema if provided, otherwise use CRD schema - const schema = overrideSchema || crdSchema - - const createMutation = useK8sCreate({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: resourceType, - namespace: tenantNamespace ?? "", - }) - - const handleSubmit = async () => { - if (!name.trim()) { - alert("Name is required") - return - } - - const kindMap: Record = { - plans: "Plan", - backupjobs: "BackupJob", - backups: "Backup", - restorejobs: "RestoreJob", - } - - const resource = { - apiVersion: "backups.cozystack.io/v1alpha1", - kind: kindMap[resourceType], - metadata: { - name: name.trim(), - namespace: tenantNamespace ?? undefined, - }, - spec: formData, - } - - try { - await createMutation.mutateAsync(resource) - navigate(`/console/backups/${resourceType}`) - } catch (err) { - alert(`Failed to create ${title}: ${(err as Error).message}`) - } - } - - const handleCancel = () => { - navigate(`/console/backups/${resourceType}`) - } - - if (schemaLoading) { - return ( -
- Loading schema... -
- ) - } - - return ( -
-
-
- -
-
-

- Create {title.slice(0, -1)} -

-

- Fill in the details to create a new {title.toLowerCase().slice(0, -1)} -

-
-
- -
-
-
-
- - setName(e.target.value)} - placeholder="my-resource-name" - 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 - /> -
- - {schema && ( -
- -
- -
- )} -
- -
- - -
-
-
-
- ) -} diff --git a/apps/console/src/routes/BackupResourceCreatePageWithData.tsx b/apps/console/src/routes/BackupResourceCreatePageWithData.tsx deleted file mode 100644 index 24021fb..0000000 --- a/apps/console/src/routes/BackupResourceCreatePageWithData.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useMemo } from "react" -import { useK8sList } from "@cozystack/k8s-client" -import { useTenantContext } from "../lib/tenant-context.tsx" -import { useApplicationDefinitions } from "../lib/app-definitions.ts" -import { useCRDSchema } from "../lib/use-crd-schema.ts" -import { BackupResourceCreatePage } from "./BackupResourceCreatePage.tsx" -import { enrichSchemaWithEnums } from "../lib/backup-utils.ts" - -interface BackupResourceCreatePageWithDataProps { - resourceType: "plans" | "backupjobs" | "backups" | "restorejobs" - title: string -} - -export function BackupResourceCreatePageWithData({ - resourceType, - title, -}: BackupResourceCreatePageWithDataProps) { - const { tenantNamespace } = useTenantContext() - const { data: appDefs } = useApplicationDefinitions() - - // Map resourceType to CRD name - const crdNameMap = { - plans: "plans.backups.cozystack.io", - backupjobs: "backupjobs.backups.cozystack.io", - backups: "backups.backups.cozystack.io", - restorejobs: "restorejobs.backups.cozystack.io", - } - - const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema( - crdNameMap[resourceType] - ) - - // Get Plans - const { data: plansData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "plans", - namespace: tenantNamespace ?? "", - }, { enabled: !!tenantNamespace && resourceType === "backupjobs" }) - - // Get Backups - const { data: backupsData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "backups", - namespace: tenantNamespace ?? "", - }, { enabled: !!tenantNamespace && resourceType === "restorejobs" }) - - // Get BackupClasses - const { data: backupClassesData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "backupclasses", - }, { enabled: resourceType === "plans" }) - - const enrichedSchema = useMemo(() => { - if (!baseSchema) return null - - const base = JSON.parse(baseSchema) - const enumMap: Record = {} - - // Add enum values based on resource type - if (resourceType === "plans") { - const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] - const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] - - if (kinds.length > 0) { - enumMap["applicationRef.kind"] = kinds - } - if (backupClasses.length > 0) { - enumMap["backupClassName"] = backupClasses - } - } - - if (resourceType === "backupjobs") { - const plans = plansData?.items.map((p: any) => p.metadata.name) ?? [] - if (plans.length > 0) { - enumMap["planRef.name"] = plans - } - } - - if (resourceType === "backups") { - const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] - const strategies: string[] = [] // TODO: Get from BackupStrategy resources if needed - - if (kinds.length > 0) { - enumMap["applicationRef.kind"] = kinds - } - if (strategies.length > 0) { - enumMap["strategyRef.name"] = strategies - } - } - - if (resourceType === "restorejobs") { - const backups = backupsData?.items.map((b: any) => b.metadata.name) ?? [] - const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] - - if (backups.length > 0) { - enumMap["backupRef.name"] = backups - } - if (kinds.length > 0) { - enumMap["targetRef.kind"] = kinds - } - } - - // Enrich schema with enum values - const enriched = enrichSchemaWithEnums(base, [], enumMap) - return JSON.stringify(enriched) - }, [baseSchema, resourceType, appDefs, plansData, backupsData, backupClassesData]) - - if (schemaLoading) { - return ( -
- Loading schema... -
- ) - } - - return ( - - ) -} diff --git a/apps/console/src/routes/BackupRestoreJobCreatePage.tsx b/apps/console/src/routes/BackupRestoreJobCreatePage.tsx index 26a4b52..c473873 100644 --- a/apps/console/src/routes/BackupRestoreJobCreatePage.tsx +++ b/apps/console/src/routes/BackupRestoreJobCreatePage.tsx @@ -29,8 +29,12 @@ export function BackupRestoreJobCreatePage() { namespace: tenantNamespace ?? "", }, { enabled: !!tenantNamespace }) - // Get instances for selected target kind - const selectedKind = formData?.targetRef?.kind + // Get instances for selected target kind. + // Strict undefined check so an explicit empty string from the user means + // "no group" — clearing the field opts out of the cozystack defaults. + const selectedKind = formData?.targetApplicationRef?.kind + const rawApiGroup = formData?.targetApplicationRef?.apiGroup + const selectedApiGroup = rawApiGroup === undefined ? "apps.cozystack.io" : rawApiGroup const selectedAppDef = useMemo( () => appDefs?.items.find(d => d.spec?.application.kind === selectedKind), [appDefs, selectedKind] @@ -41,7 +45,7 @@ export function BackupRestoreJobCreatePage() { apiVersion: "v1alpha1", plural: selectedAppDef?.spec?.application.plural ?? "", namespace: tenantNamespace ?? "", - }, { enabled: !!selectedAppDef && !!tenantNamespace }) + }, { enabled: !!selectedAppDef && !!tenantNamespace && selectedApiGroup === "apps.cozystack.io" }) const createMutation = useK8sCreate({ apiGroup: "backups.cozystack.io", @@ -55,7 +59,13 @@ export function BackupRestoreJobCreatePage() { const base = JSON.parse(baseSchema) const backups = backupsData?.items.map((b: any) => b.metadata.name) ?? [] - const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] + // ApplicationDefinitions live under apps.cozystack.io exclusively, so the + // Kind dropdown is populated only when the selected apiGroup matches. + // For any other apiGroup the user is on their own (no enum hint), which + // matches the free-text fallback behavior of plain CRD fields. + const kinds: string[] = selectedApiGroup === "apps.cozystack.io" + ? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] + : [] const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] const enumMap: Record = {} @@ -65,23 +75,45 @@ export function BackupRestoreJobCreatePage() { enumMap["backupRef.name"] = backups } if (kinds.length > 0) { - enumMap["targetRef.kind"] = kinds + enumMap["targetApplicationRef.kind"] = kinds } - // Add instances enum only after kind is selected - if (selectedKind && instances.length > 0) { - enumMap["targetRef.name"] = instances + // Add instances enum only after kind is selected and apiGroup matches + // (ApplicationDefinitions cover apps.cozystack.io only — for any other + // apiGroup the user is on free-text fallback). + if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) { + enumMap["targetApplicationRef.name"] = instances } // Enrich schema with enum values const enriched = enrichSchemaWithEnums(base, [], enumMap) // Add default value for apiGroup - if (enriched.properties?.targetRef?.properties?.apiGroup) { - enriched.properties.targetRef.properties.apiGroup.default = "apps.cozystack.io" + if (enriched.properties?.targetApplicationRef?.properties?.apiGroup) { + enriched.properties.targetApplicationRef.properties.apiGroup.default = "apps.cozystack.io" + } + + // The CRD ships backupRef.name with `default: ""` (k8s LocalObjectReference + // convention). Combined with an enum injected here, RJSF's SelectWidget + // can lose the user's selection on re-render — strip the default so the + // widget starts empty and the chosen value is the single source of truth. + if (enriched.properties?.backupRef?.properties?.name?.default !== undefined) { + delete enriched.properties.backupRef.properties.name.default + } + + // spec.options is a driver-specific blob — the CRD declares it as + // `type: object` + `x-kubernetes-preserve-unknown-fields: true`, which + // sanitizeSchema flattens to `additionalProperties: true`. RJSF then has + // no widget for it. Rewrite to a typed map so AdditionalPropertiesField + // auto-attaches and the user gets a key/value editor. + if (enriched.properties?.options) { + delete enriched.properties.options["x-kubernetes-preserve-unknown-fields"] + enriched.properties.options.type = "object" + enriched.properties.options.additionalProperties = { type: "string" } + enriched.properties.options.properties = enriched.properties.options.properties ?? {} } return JSON.stringify(enriched) - }, [baseSchema, backupsData, appDefs, instancesData, selectedKind]) + }, [baseSchema, backupsData, appDefs, instancesData, selectedKind, selectedApiGroup]) const handleSubmit = async () => { if (!tenantNamespace) { @@ -99,11 +131,24 @@ export function BackupRestoreJobCreatePage() { return } - if (!formData.targetRef?.kind || !formData.targetRef?.name) { - alert("Target reference is required") + // targetApplicationRef is optional in the CRD — when omitted, the driver + // restores into the same application referenced by the backup. Reject + // partial input (kind without name or vice versa), but accept an empty ref. + const target = formData.targetApplicationRef + const hasTargetKind = !!target?.kind + const hasTargetName = !!target?.name + if (hasTargetKind !== hasTargetName) { + alert("Target reference requires both Kind and Name, or leave both empty to restore into the source application") return } + // Strip an empty targetApplicationRef so the API does not receive an empty + // object that the API server would reject as malformed. + const spec = { ...formData } + if (!hasTargetKind && !hasTargetName) { + delete spec.targetApplicationRef + } + const resource = { apiVersion: "backups.cozystack.io/v1alpha1", kind: "RestoreJob", @@ -111,7 +156,7 @@ export function BackupRestoreJobCreatePage() { name: name.trim(), namespace: tenantNamespace ?? undefined, }, - spec: formData, + spec, } try { diff --git a/apps/console/src/routes/ConsolePage.tsx b/apps/console/src/routes/ConsolePage.tsx index 9a04a56..dfb4781 100644 --- a/apps/console/src/routes/ConsolePage.tsx +++ b/apps/console/src/routes/ConsolePage.tsx @@ -8,9 +8,9 @@ import { ApplicationListPage } from "./ApplicationListPage.tsx" import { ApplicationDetailPage } from "./detail/ApplicationDetailPage.tsx" import { ApplicationEditRoute } from "./detail/ApplicationEditRoute.tsx" import { BackupResourceListPage } from "./BackupResourceListPage.tsx" -import { BackupResourceCreatePageWithData } from "./BackupResourceCreatePageWithData.tsx" import { BackupResourceEditPage } from "./BackupResourceEditPage.tsx" import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx" +import { BackupJobCreatePage } from "./BackupJobCreatePage.tsx" import { BackupCreatePage } from "./BackupCreatePage.tsx" import { BackupRestoreJobCreatePage } from "./BackupRestoreJobCreatePage.tsx" import { ApplicationOrderPage } from "./ApplicationOrderPage.tsx" @@ -41,7 +41,7 @@ export function ConsolePage() { /> } + element={} />