Files
floci-ui/packages/frontend/src/components/CreateRdsInstanceForm.tsx
T
2026-10-11 14:19:00 -04:00

348 lines
11 KiB
TypeScript

import { type FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import {
createCloudResource,
listDatabaseOrderableClasses,
} from "@/api/cloudProxyClient";
import {
listEc2SecurityGroups,
type Ec2SecurityGroup,
} from "@/api/aws/ec2.api";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource } from "@/types/resource";
import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const DATABASE_CREATION_MINIMUM_MS = 5_000;
interface CreateRdsInstanceFormProps {
cloud: CloudProvider;
onSuccess: (resource: CloudResource) => void;
onCancel: () => void;
}
export function CreateRdsInstanceForm({
cloud,
onSuccess,
onCancel,
}: CreateRdsInstanceFormProps) {
const qc = useQueryClient();
// Instance configuration
const [dbInstanceIdentifier, setDbInstanceIdentifier] = useState("");
const [engine, setEngine] = useState("postgres");
const [dbInstanceClass, setDbInstanceClass] = useState("");
const [masterUserPassword, setMasterUserPassword] = useState("");
// Networking settings
const [selectedSgs, setSelectedSgs] = useState<string[]>([]);
// Additional settings
const [masterUsername, setMasterUsername] = useState("root");
const [allocatedStorage, setAllocatedStorage] = useState("20");
const [dbName, setDbName] = useState("");
const [engineVersion, setEngineVersion] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
// Fetch available instance classes from AWS SDK via backend
const classesQuery = useQuery({
queryKey: ["cloud-database-orderable-classes", cloud, engine],
queryFn: ({ signal }) => listDatabaseOrderableClasses(cloud, engine, signal),
enabled: Boolean(engine),
});
const availableClasses = classesQuery.data ?? [];
const selectedClass =
dbInstanceClass && availableClasses.includes(dbInstanceClass)
? dbInstanceClass
: (availableClasses[0] ?? "");
// Fetch available security groups (AWS)
const sgsQuery = useQuery({
queryKey: ["ec2", "security-groups"],
queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal),
enabled: cloud === "aws",
});
function toggleSg(groupId: string, checked: boolean) {
setSelectedSgs((prev) =>
checked ? [...prev, groupId] : prev.filter((id) => id !== groupId),
);
}
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const startedAt = Date.now();
const created = await createCloudResource(cloud, "database", values);
const remaining = DATABASE_CREATION_MINIMUM_MS - (Date.now() - startedAt);
if (remaining > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, remaining));
}
return created;
},
onSuccess: (created) => {
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, "database"],
});
onSuccess(created);
},
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
setValidationError(null);
const trimmedIdentifier = dbInstanceIdentifier.trim();
if (!trimmedIdentifier) {
setValidationError("DB Instance Identifier is required.");
return;
}
if (!IDENTIFIER_PATTERN.test(trimmedIdentifier)) {
setValidationError(
"DB Instance Identifier must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and cannot end with a hyphen or contain consecutive hyphens.",
);
return;
}
if (!selectedClass) {
setValidationError("DB Instance Class is required.");
return;
}
if (masterUserPassword.length < 8) {
setValidationError("Master User Password must be at least 8 characters.");
return;
}
createMut.mutate({
dbInstanceIdentifier: trimmedIdentifier,
engine,
dbInstanceClass: selectedClass,
masterUserPassword,
masterUsername: masterUsername.trim() || "root",
allocatedStorage: allocatedStorage.trim() || "20",
dbName: dbName.trim() || undefined,
engineVersion: engineVersion.trim() || undefined,
securityGroupIds: selectedSgs.length ? selectedSgs : undefined,
});
}
const errorMessage =
validationError ??
(createMut.error instanceof Error ? createMut.error.message : null);
return (
<form className="launch-form" onSubmit={handleSubmit} noValidate={false}>
{/* ── Required Instance Settings ─────────────────────────────── */}
<div className="launch-form-group">Instance Configuration</div>
<label className="launch-field">
<span>
DB Instance Identifier <em className="field-required">*</em>
</span>
<input
className="input"
value={dbInstanceIdentifier}
onChange={(e) => setDbInstanceIdentifier(e.target.value)}
placeholder="e.g. orders-database"
required
maxLength={63}
/>
</label>
<label className="launch-field">
<span>
Engine <em className="field-required">*</em>
</span>
<select
className="input"
value={engine}
onChange={(e) => {
const nextEngine = e.target.value;
setEngine(nextEngine);
setDbInstanceClass("");
}}
required
>
<option value="postgres">PostgreSQL</option>
<option value="mysql">MySQL</option>
<option value="mariadb">MariaDB</option>
</select>
</label>
<label className="launch-field">
<span>
DB Instance Class <em className="field-required">*</em>
{classesQuery.isFetching && (
<Loader2
size={11}
className="spin"
style={{ marginLeft: 6, display: "inline-block" }}
/>
)}
</span>
<select
className="input"
value={selectedClass}
onChange={(e) => setDbInstanceClass(e.target.value)}
disabled={classesQuery.isLoading || availableClasses.length === 0}
required
>
{classesQuery.isLoading && (
<option value="" disabled>
Loading available instance classes…
</option>
)}
{classesQuery.isError && (
<option value="" disabled>
Failed to load instance classes
</option>
)}
{!classesQuery.isLoading &&
!classesQuery.isError &&
availableClasses.length === 0 && (
<option value="" disabled>
No instance classes available
</option>
)}
{availableClasses.map((cls) => (
<option key={cls} value={cls}>
{cls}
</option>
))}
</select>
</label>
<label className="launch-field">
<span>
Master User Password <em className="field-required">*</em>
</span>
<input
type="password"
className="input"
value={masterUserPassword}
onChange={(e) => setMasterUserPassword(e.target.value)}
placeholder="Min 8 characters"
required
minLength={8}
maxLength={128}
/>
</label>
{/* ── Networking — optional ─────────────────────────────────── */}
{cloud === "aws" && (
<>
<div className="launch-form-group">Networking — optional</div>
<div className="launch-field launch-field--span">
<span style={{ fontSize: "12px", color: "var(--text-2)" }}>
Security Groups
</span>
<div className="sg-checklist" style={{ marginTop: "4px" }}>
{sgsQuery.isLoading && <span className="muted">Loading…</span>}
{!sgsQuery.isLoading && (sgsQuery.data ?? []).length === 0 && (
<span className="muted">No security groups found.</span>
)}
{(sgsQuery.data ?? []).map((sg: Ec2SecurityGroup) => (
<label key={sg.groupId} className="sg-check-item">
<input
type="checkbox"
checked={selectedSgs.includes(sg.groupId)}
onChange={(e) => toggleSg(sg.groupId, e.target.checked)}
/>
<span>{sg.groupName || sg.groupId}</span>
{sg.groupName && <code>{sg.groupId}</code>}
</label>
))}
</div>
</div>
</>
)}
{/* ── Additional Database Settings ───────────────────────────── */}
<div className="launch-form-group">Database Settings — optional</div>
<label className="launch-field">
<span>Master Username</span>
<input
className="input"
value={masterUsername}
onChange={(e) => setMasterUsername(e.target.value)}
placeholder="root"
maxLength={63}
/>
</label>
<label className="launch-field">
<span>Allocated Storage (GiB)</span>
<input
type="number"
className="input"
value={allocatedStorage}
onChange={(e) => setAllocatedStorage(e.target.value)}
placeholder="20"
min={5}
max={65536}
/>
</label>
<label className="launch-field">
<span>Database Name</span>
<input
className="input"
value={dbName}
onChange={(e) => setDbName(e.target.value)}
placeholder="e.g. myapp"
maxLength={64}
/>
</label>
<label className="launch-field">
<span>Engine Version</span>
<input
className="input"
value={engineVersion}
onChange={(e) => setEngineVersion(e.target.value)}
placeholder="e.g. 16.3"
/>
</label>
{/* ── Actions ────────────────────────────────────────────────── */}
{createMut.isPending && <div className="launch-field--span"><DatabaseLaunchProgress name={dbInstanceIdentifier} description="Starting the database container and applying its configuration." /></div>}
<div className="launch-form-actions">
<button
className="button"
type="button"
onClick={onCancel}
disabled={createMut.isPending}
>
Cancel
</button>
<button
className="button primary"
type="submit"
disabled={createMut.isPending}
>
{createMut.isPending ? (
<Loader2 className="spin" size={13} />
) : (
<Plus size={13} />
)}
{createMut.isPending ? "Creating…" : "Create DB Instance"}
</button>
</div>
{errorMessage && (
<div
className="field-error launch-field--span"
role="alert"
style={{ marginTop: "4px" }}
>
{errorMessage}
</div>
)}
</form>
);
}