Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit 5bbda8f

Browse files
committed
fix(backup): several fixes around backup-pages
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
1 parent fa41573 commit 5bbda8f

9 files changed

Lines changed: 355 additions & 469 deletions

apps/console/src/components/BackupClassWidget.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,34 @@ export function BackupClassWidget(props: WidgetProps) {
1919
})
2020

2121
const backupClasses = classList?.items || []
22+
const currentValue = typeof value === "string" ? value : ""
23+
const hasCurrentInList = backupClasses.some((bc) => bc.metadata.name === currentValue)
2224

2325
return (
2426
<select
25-
value={value || ""}
27+
value={currentValue}
2628
onChange={(e) => onChange(e.target.value || undefined)}
27-
disabled={disabled || readonly || isLoading}
29+
disabled={disabled || readonly}
2830
required={required}
2931
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"
3032
>
3133
{!required && <option value="">-- None --</option>}
32-
{isLoading ? (
33-
<option value="">Loading...</option>
34-
) : backupClasses.length === 0 ? (
34+
{/* Render the parent's value as a stable option even when the list is
35+
still loading or the value isn't present in the loaded results. This
36+
keeps the controlled <select> from losing the parent's selection on
37+
async re-renders of useK8sList (loading → loaded → refetch). */}
38+
{currentValue && !hasCurrentInList && (
39+
<option value={currentValue}>{currentValue}</option>
40+
)}
41+
{backupClasses.map((bc) => (
42+
<option key={bc.metadata.name} value={bc.metadata.name}>
43+
{bc.metadata.name}
44+
</option>
45+
))}
46+
{!isLoading && backupClasses.length === 0 && !currentValue && (
3547
<option value="" disabled>
3648
No backup classes available
3749
</option>
38-
) : (
39-
backupClasses.map((bc) => (
40-
<option key={bc.metadata.name} value={bc.metadata.name}>
41-
{bc.metadata.name}
42-
</option>
43-
))
4450
)}
4551
</select>
4652
)

apps/console/src/components/SchemaForm.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ function addBackupClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiS
5151

5252
for (const [key, value] of Object.entries(properties)) {
5353
if (key === "backupClassName" && typeof value === "object" && (value as any).type === "string") {
54+
// Skip attaching the custom widget when an explicit enum is already
55+
// present — the parent supplies the option list, RJSF's native
56+
// SelectWidget handles binding correctly. Auto-attaching here would
57+
// override the select with our BackupClassWidget whose internal
58+
// useK8sList state can drop the user's selection on async re-renders.
59+
if (Array.isArray((value as any).enum)) {
60+
continue
61+
}
5462
// Found a backupClassName field - add widget
5563
result[key] = {
5664
...result[key],
@@ -169,18 +177,21 @@ export function SchemaForm({
169177

170178
const onChangeRef = useRef(onChange)
171179
onChangeRef.current = onChange
172-
const initialFormDataRef = useRef(formData)
180+
const formDataRef = useRef(formData)
181+
formDataRef.current = formData
173182
const emittedSchemaRef = useRef<RJSFSchema | null>(null)
174183

175184
// Emit defaults to parent once per schema so spec is never empty on first submit.
176-
// Uses initialFormDataRef so edit-mode existing values are preserved as base.
177-
// emittedSchemaRef prevents re-running on unrelated re-renders and avoids
178-
// overwriting user data if the schema object changes identity unexpectedly.
185+
// Uses formDataRef (current parent state, not the initial mount snapshot) so
186+
// user input is preserved when the parent recomputes openAPISchema due to
187+
// async sibling data (e.g. plansData/backupClassesData loading) — without
188+
// this, getDefaultFormState would re-emit defaults computed from the stale
189+
// initial formData and wipe whatever the user already typed.
179190
useEffect(() => {
180191
if (!schema || Object.keys(schema).length === 0) return
181192
if (emittedSchemaRef.current === schema) return
182193
emittedSchemaRef.current = schema
183-
const defaults = getDefaultFormState(validator, schema, initialFormDataRef.current ?? {}, schema)
194+
const defaults = getDefaultFormState(validator, schema, formDataRef.current ?? {}, schema)
184195
onChangeRef.current(defaults)
185196
// eslint-disable-next-line react-hooks/exhaustive-deps
186197
}, [schema])
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { useState, useMemo } from "react"
2+
import { useNavigate } from "react-router"
3+
import { Archive, Save } from "lucide-react"
4+
import { Button, Section, Spinner } from "@cozystack/ui"
5+
import { useK8sCreate, useK8sList } from "@cozystack/k8s-client"
6+
import { useTenantContext } from "../lib/tenant-context.tsx"
7+
import { useApplicationDefinitions } from "../lib/app-definitions.ts"
8+
import { useCRDSchema } from "../lib/use-crd-schema.ts"
9+
import { SchemaForm } from "../components/SchemaForm.tsx"
10+
import { enrichSchemaWithEnums } from "../lib/backup-utils.ts"
11+
12+
export function BackupJobCreatePage() {
13+
const navigate = useNavigate()
14+
const { tenantNamespace } = useTenantContext()
15+
const { data: appDefs } = useApplicationDefinitions()
16+
const [formData, setFormData] = useState<any>({})
17+
const [name, setName] = useState("")
18+
19+
// Get base schema from CRD
20+
const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema(
21+
"backupjobs.backups.cozystack.io"
22+
)
23+
24+
// Get BackupClasses (cluster-scoped)
25+
const { data: backupClassesData } = useK8sList<any>({
26+
apiGroup: "backups.cozystack.io",
27+
apiVersion: "v1alpha1",
28+
plural: "backupclasses",
29+
})
30+
31+
// Get Plans in the tenant namespace (optional reference)
32+
const { data: plansData } = useK8sList<any>({
33+
apiGroup: "backups.cozystack.io",
34+
apiVersion: "v1alpha1",
35+
plural: "plans",
36+
namespace: tenantNamespace ?? "",
37+
}, { enabled: !!tenantNamespace })
38+
39+
// Resolve instances for the selected application kind.
40+
// Mirrors BackupRestoreJobCreatePage: kind dropdown is gated to
41+
// apps.cozystack.io (the only apiGroup ApplicationDefinitions cover).
42+
// Strict undefined check so an explicit empty string from the user means
43+
// "no group" — clearing the field opts out of the cozystack defaults.
44+
const selectedKind = formData?.applicationRef?.kind
45+
const rawApiGroup = formData?.applicationRef?.apiGroup
46+
const selectedApiGroup = rawApiGroup === undefined ? "apps.cozystack.io" : rawApiGroup
47+
const selectedAppDef = useMemo(
48+
() => appDefs?.items.find(d => d.spec?.application.kind === selectedKind),
49+
[appDefs, selectedKind]
50+
)
51+
52+
const { data: instancesData } = useK8sList<any>({
53+
apiGroup: "apps.cozystack.io",
54+
apiVersion: "v1alpha1",
55+
plural: selectedAppDef?.spec?.application.plural ?? "",
56+
namespace: tenantNamespace ?? "",
57+
}, { enabled: !!selectedAppDef && !!tenantNamespace && selectedApiGroup === "apps.cozystack.io" })
58+
59+
const createMutation = useK8sCreate({
60+
apiGroup: "backups.cozystack.io",
61+
apiVersion: "v1alpha1",
62+
plural: "backupjobs",
63+
namespace: tenantNamespace ?? "",
64+
})
65+
66+
const schema = useMemo(() => {
67+
if (!baseSchema) return null
68+
69+
const base = JSON.parse(baseSchema)
70+
const kinds: string[] = selectedApiGroup === "apps.cozystack.io"
71+
? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? []
72+
: []
73+
const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? []
74+
const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? []
75+
const plans = plansData?.items.map((p: any) => p.metadata.name) ?? []
76+
77+
const enumMap: Record<string, string[]> = {}
78+
if (kinds.length > 0) {
79+
enumMap["applicationRef.kind"] = kinds
80+
}
81+
if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) {
82+
enumMap["applicationRef.name"] = instances
83+
}
84+
if (backupClasses.length > 0) {
85+
enumMap["backupClassName"] = backupClasses
86+
}
87+
if (plans.length > 0) {
88+
// planRef is optional in the CRD (default ""). Prepend an empty value
89+
// so the dropdown opens with no plan selected — matches the CRD default
90+
// and avoids accidentally pinning the BackupJob to the first listed Plan.
91+
enumMap["planRef.name"] = ["", ...plans]
92+
}
93+
94+
const enriched = enrichSchemaWithEnums(base, [], enumMap)
95+
96+
// Default the optional apiGroup to apps.cozystack.io so the cozystack-
97+
// managed kinds match without the user typing the group manually.
98+
if (enriched.properties?.applicationRef?.properties?.apiGroup) {
99+
enriched.properties.applicationRef.properties.apiGroup.default = "apps.cozystack.io"
100+
}
101+
102+
return JSON.stringify(enriched)
103+
}, [baseSchema, appDefs, backupClassesData, plansData, instancesData, selectedKind, selectedApiGroup])
104+
105+
const handleSubmit = async () => {
106+
if (!tenantNamespace) {
107+
alert("Tenant namespace is not available. Please refresh.")
108+
return
109+
}
110+
111+
if (!name.trim()) {
112+
alert("Name is required")
113+
return
114+
}
115+
116+
if (!formData.applicationRef?.kind || !formData.applicationRef?.name) {
117+
alert("Application reference is required")
118+
return
119+
}
120+
121+
if (!formData.backupClassName) {
122+
alert("Backup class name is required")
123+
return
124+
}
125+
126+
// planRef is optional metadata recording which Plan triggered the job. The
127+
// dropdown ships an empty sentinel; strip it so the API never receives
128+
// `planRef: { name: "" }`, which would otherwise round-trip as a malformed
129+
// LocalObjectReference.
130+
const spec = { ...formData }
131+
if (!spec.planRef?.name) {
132+
delete spec.planRef
133+
}
134+
135+
const resource = {
136+
apiVersion: "backups.cozystack.io/v1alpha1",
137+
kind: "BackupJob",
138+
metadata: {
139+
name: name.trim(),
140+
namespace: tenantNamespace ?? undefined,
141+
},
142+
spec,
143+
}
144+
145+
try {
146+
await createMutation.mutateAsync(resource)
147+
navigate("/console/backups/backupjobs")
148+
} catch (err) {
149+
alert(`Failed to create BackupJob: ${(err as Error).message}`)
150+
}
151+
}
152+
153+
const handleCancel = () => {
154+
navigate("/console/backups/backupjobs")
155+
}
156+
157+
if (schemaLoading) {
158+
return (
159+
<div className="flex items-center gap-2 p-8 text-slate-500">
160+
<Spinner /> Loading schema...
161+
</div>
162+
)
163+
}
164+
165+
if (!schema) {
166+
return (
167+
<div className="p-8 text-red-600">
168+
Failed to load BackupJob schema. Please refresh the page.
169+
</div>
170+
)
171+
}
172+
173+
return (
174+
<div className="p-6">
175+
<div className="mb-5 flex items-center gap-3">
176+
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
177+
<Archive className="size-6 text-slate-600" />
178+
</div>
179+
<div>
180+
<h1 className="text-lg font-semibold text-slate-900">Create Backup Job</h1>
181+
<p className="text-xs text-slate-500">
182+
Trigger a backup of an application instance
183+
</p>
184+
</div>
185+
</div>
186+
187+
<div>
188+
<Section>
189+
<div className="space-y-4 p-5">
190+
<div>
191+
<label className="block text-sm font-medium text-slate-700 mb-1">
192+
Backup Job Name <span className="text-red-500">*</span>
193+
</label>
194+
<input
195+
type="text"
196+
value={name}
197+
onChange={(e) => setName(e.target.value)}
198+
placeholder="my-backup-job"
199+
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"
200+
required
201+
/>
202+
</div>
203+
204+
<div>
205+
<SchemaForm
206+
openAPISchema={schema}
207+
formData={formData}
208+
onChange={setFormData}
209+
>
210+
<div className="hidden" />
211+
</SchemaForm>
212+
</div>
213+
</div>
214+
215+
<div className="flex items-center gap-2 border-t border-slate-200 px-5 py-3">
216+
<Button
217+
type="button"
218+
variant="primary"
219+
size="sm"
220+
onClick={handleSubmit}
221+
disabled={createMutation.isPending}
222+
>
223+
{createMutation.isPending ? (
224+
<>
225+
<Spinner /> Creating...
226+
</>
227+
) : (
228+
<>
229+
<Save className="size-3.5" /> Create
230+
</>
231+
)}
232+
</Button>
233+
<Button
234+
type="button"
235+
variant="outline"
236+
size="sm"
237+
onClick={handleCancel}
238+
disabled={createMutation.isPending}
239+
>
240+
Cancel
241+
</Button>
242+
</div>
243+
</Section>
244+
</div>
245+
</div>
246+
)
247+
}

apps/console/src/routes/BackupPlanCreatePage.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ export function BackupPlanCreatePage() {
2828
plural: "backupclasses",
2929
})
3030

31-
// Get instances for selected kind
31+
// Get instances for selected kind.
32+
// Strict undefined check so an explicit empty string from the user means
33+
// "no group" — clearing the field opts out of the cozystack defaults.
3234
const selectedKind = formData?.applicationRef?.kind
35+
const rawApiGroup = formData?.applicationRef?.apiGroup
36+
const selectedApiGroup = rawApiGroup === undefined ? "apps.cozystack.io" : rawApiGroup
3337
const selectedAppDef = useMemo(
3438
() => appDefs?.items.find(d => d.spec?.application.kind === selectedKind),
3539
[appDefs, selectedKind]
@@ -40,7 +44,7 @@ export function BackupPlanCreatePage() {
4044
apiVersion: "v1alpha1",
4145
plural: selectedAppDef?.spec?.application.plural ?? "",
4246
namespace: tenantNamespace ?? "",
43-
}, { enabled: !!selectedAppDef && !!tenantNamespace })
47+
}, { enabled: !!selectedAppDef && !!tenantNamespace && selectedApiGroup === "apps.cozystack.io" })
4448

4549
const createMutation = useK8sCreate({
4650
apiGroup: "backups.cozystack.io",
@@ -53,7 +57,12 @@ export function BackupPlanCreatePage() {
5357
if (!baseSchema) return null
5458

5559
const base = JSON.parse(baseSchema)
56-
const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? []
60+
// ApplicationDefinitions are exclusive to apps.cozystack.io — show the
61+
// Kind dropdown only when the selected apiGroup matches; otherwise leave
62+
// it as a free-text input (no enum hint).
63+
const kinds: string[] = selectedApiGroup === "apps.cozystack.io"
64+
? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? []
65+
: []
5766
const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? []
5867
const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? []
5968

@@ -63,7 +72,7 @@ export function BackupPlanCreatePage() {
6372
if (kinds.length > 0) {
6473
enumMap["applicationRef.kind"] = kinds
6574
}
66-
if (selectedKind && instances.length > 0) {
75+
if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) {
6776
enumMap["applicationRef.name"] = instances
6877
}
6978
if (backupClasses.length > 0) {
@@ -79,7 +88,7 @@ export function BackupPlanCreatePage() {
7988
}
8089

8190
return JSON.stringify(enriched)
82-
}, [baseSchema, appDefs, backupClassesData, instancesData, selectedKind])
91+
}, [baseSchema, appDefs, backupClassesData, instancesData, selectedKind, selectedApiGroup])
8392

8493
const handleSubmit = async () => {
8594
if (!tenantNamespace) {

0 commit comments

Comments
 (0)