-
Notifications
You must be signed in to change notification settings - Fork 0
fix(backup): several fixes around backup-pages #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| 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 | ||
|
|
||
| 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}`) | ||
| } | ||
| } | ||
|
|
||
| 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 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> | ||
| <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> | ||
| ) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.