Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions apps/console/src/components/BackupClassWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<select
value={value || ""}
value={currentValue}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled || readonly || isLoading}
disabled={disabled || readonly}
required={required}
className="w-full rounded-lg border border-slate-300 bg-white pl-3 pr-8 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
>
{!required && <option value="">-- None --</option>}
{isLoading ? (
<option value="">Loading...</option>
) : backupClasses.length === 0 ? (
{/* Render the parent's value as a stable option even when the list is
still loading or the value isn't present in the loaded results. This
keeps the controlled <select> from losing the parent's selection on
async re-renders of useK8sList (loading → loaded → refetch). */}
{currentValue && !hasCurrentInList && (
<option value={currentValue}>{currentValue}</option>
)}
{backupClasses.map((bc) => (
<option key={bc.metadata.name} value={bc.metadata.name}>
{bc.metadata.name}
</option>
))}
{!isLoading && backupClasses.length === 0 && !currentValue && (
<option value="" disabled>
No backup classes available
</option>
) : (
backupClasses.map((bc) => (
<option key={bc.metadata.name} value={bc.metadata.name}>
{bc.metadata.name}
</option>
))
)}
</select>
)
Expand Down
21 changes: 16 additions & 5 deletions apps/console/src/components/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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<RJSFSchema | null>(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])
Expand Down
247 changes: 247 additions & 0 deletions apps/console/src/routes/BackupJobCreatePage.tsx
Original file line number Diff line number Diff line change
@@ -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<any>({})
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<any>({
apiGroup: "backups.cozystack.io",
apiVersion: "v1alpha1",
plural: "backupclasses",
})

// Get Plans in the tenant namespace (optional reference)
const { data: plansData } = useK8sList<any>({
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<any>({
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The JSON.parse(baseSchema) call is unsafe and will crash the component if baseSchema is invalid or empty. Consider wrapping it in a try-catch block to handle parsing errors gracefully, similar to the implementation in SchemaForm.tsx.

Suggested change
const base = JSON.parse(baseSchema)
let base;
try {
base = JSON.parse(baseSchema);
} catch (e) {
console.error("Failed to parse BackupJob schema:", e);
return null;
}

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<string, string[]> = {}
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}`)
Comment on lines +107 to +149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using alert() for validation and error reporting is generally discouraged as it provides a poor user experience and blocks the main thread. Consider using a toast notification system or inline validation messages. Additionally, these strings are hardcoded and should be moved to a localization system if the project supports i18n.

}
}

const handleCancel = () => {
navigate("/console/backups/backupjobs")
}

if (schemaLoading) {
return (
<div className="flex items-center gap-2 p-8 text-slate-500">
<Spinner /> Loading schema...
</div>
)
}

if (!schema) {
return (
<div className="p-8 text-red-600">
Failed to load BackupJob schema. Please refresh the page.
</div>
)
}

return (
<div className="p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
<Archive className="size-6 text-slate-600" />
</div>
<div>
<h1 className="text-lg font-semibold text-slate-900">Create Backup Job</h1>
<p className="text-xs text-slate-500">
Trigger a backup of an application instance
</p>
</div>
</div>

<div>
<Section>
<div className="space-y-4 p-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Backup Job Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => 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
/>
Comment on lines +191 to +201
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'Backup Job Name' input field is missing an id, and its label is missing a htmlFor attribute. Adding these improves accessibility for screen readers and allows users to focus the input by clicking the label.

Suggested change
<label className="block text-sm font-medium text-slate-700 mb-1">
Backup Job Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => 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
/>
<div>
<label htmlFor="backup-job-name" className="block text-sm font-medium text-slate-700 mb-1">
Backup Job Name <span className="text-red-500">*</span>
</label>
<input
id="backup-job-name"
type="text"
value={name}
onChange={(e) => 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
/>
</div>

</div>

<div>
<SchemaForm
openAPISchema={schema}
formData={formData}
onChange={setFormData}
>
<div className="hidden" />
</SchemaForm>
</div>
</div>

<div className="flex items-center gap-2 border-t border-slate-200 px-5 py-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Spinner /> Creating...
</>
) : (
<>
<Save className="size-3.5" /> Create
</>
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancel}
disabled={createMutation.isPending}
>
Cancel
</Button>
</div>
</Section>
</div>
</div>
)
}
19 changes: 14 additions & 5 deletions apps/console/src/routes/BackupPlanCreatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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",
Expand All @@ -53,7 +57,12 @@ export function BackupPlanCreatePage() {
if (!baseSchema) return null

const base = JSON.parse(baseSchema)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The JSON.parse(baseSchema) call is unsafe and will crash the component if baseSchema is invalid. Consider wrapping it in a try-catch block to handle potential parsing errors.

Suggested change
const base = JSON.parse(baseSchema)
let base;
try {
base = JSON.parse(baseSchema);
} catch (e) {
console.error("Failed to parse Plan schema:", e);
return null;
}

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) ?? []

Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Loading
Loading