348 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|