feat(components): add shared ConfigImportDialog for config import
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
buildImageImportBody,
|
||||
buildRegistryImportBody,
|
||||
IMAGE_EXPORT_KIND,
|
||||
imageMissingSecrets,
|
||||
MAX_IMPORT_BYTES,
|
||||
parseImageExport,
|
||||
parseRegistryExport,
|
||||
REGISTRY_EXPORT_KIND,
|
||||
registryMissingSecrets,
|
||||
type ImageExportEnvelope,
|
||||
type ParseErrorCode,
|
||||
type RegistryExportEnvelope,
|
||||
} from "@/lib/configTransfer";
|
||||
import type { GetModelRegistryResponse } from "@/lib/modelRegistry";
|
||||
import {
|
||||
getImageGeneration,
|
||||
putImageGeneration,
|
||||
type ImageGenerationSettings,
|
||||
} from "@/lib/imageGeneration";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60";
|
||||
|
||||
type Kind = "model-registry" | "image-generation";
|
||||
type Mode = "replace" | "merge";
|
||||
|
||||
interface ConfigImportDialogProps {
|
||||
kind: Kind;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImported: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ParsedState {
|
||||
registry: RegistryExportEnvelope | null;
|
||||
image: ImageExportEnvelope | null;
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
const EMPTY_IMAGE_SETTINGS: ImageGenerationSettings = {
|
||||
default_model: "",
|
||||
timeout_seconds: 0,
|
||||
models: [],
|
||||
};
|
||||
|
||||
export function ConfigImportDialog({
|
||||
kind,
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: ConfigImportDialogProps) {
|
||||
const t = useTranslations("configTransfer");
|
||||
const [text, setText] = useState("");
|
||||
const [parseError, setParseError] = useState<ParseErrorCode | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedState | null>(null);
|
||||
const [mode, setMode] = useState<Mode>("replace");
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setText("");
|
||||
setParseError(null);
|
||||
setParsed(null);
|
||||
setMode("replace");
|
||||
setSubmitError(null);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const analyze = useCallback(
|
||||
async (raw: string) => {
|
||||
setText(raw);
|
||||
setSubmitError(null);
|
||||
if (!raw.trim()) {
|
||||
setParseError(null);
|
||||
setParsed(null);
|
||||
return;
|
||||
}
|
||||
if (kind === "model-registry") {
|
||||
const result = parseRegistryExport(raw);
|
||||
if (!result.ok) {
|
||||
setParseError(result.code);
|
||||
setParsed(null);
|
||||
return;
|
||||
}
|
||||
setParseError(null);
|
||||
setParsed({
|
||||
registry: result.value,
|
||||
image: null,
|
||||
missing: registryMissingSecrets(result.value),
|
||||
});
|
||||
} else {
|
||||
const result = parseImageExport(raw);
|
||||
if (!result.ok) {
|
||||
setParseError(result.code);
|
||||
setParsed(null);
|
||||
return;
|
||||
}
|
||||
// Current stored keys decide which empty file keys are real gaps.
|
||||
let current = EMPTY_IMAGE_SETTINGS;
|
||||
try {
|
||||
current = await getImageGeneration();
|
||||
} catch {
|
||||
// Conservative: every empty key is flagged.
|
||||
}
|
||||
setParseError(null);
|
||||
setParsed({
|
||||
registry: null,
|
||||
image: result.value,
|
||||
missing: imageMissingSecrets(result.value, current),
|
||||
});
|
||||
}
|
||||
},
|
||||
[kind]
|
||||
);
|
||||
|
||||
const onFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMPORT_BYTES) {
|
||||
setText("");
|
||||
setParsed(null);
|
||||
setParseError("too_large");
|
||||
return;
|
||||
}
|
||||
await analyze(await file.text());
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!parsed) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
if (kind === "model-registry" && parsed.registry) {
|
||||
const currentResponse = await fetch("/api/model-registry", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!currentResponse.ok) {
|
||||
throw new Error(
|
||||
t("dialog.loadCurrentFailed", {
|
||||
status: String(currentResponse.status),
|
||||
})
|
||||
);
|
||||
}
|
||||
const current = (await currentResponse.json()) as GetModelRegistryResponse;
|
||||
const body = buildRegistryImportBody(parsed.registry, current, mode);
|
||||
const putResponse = await fetch("/api/model-registry", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!putResponse.ok) {
|
||||
const errorBody = await putResponse.json().catch(() => null);
|
||||
const message =
|
||||
typeof errorBody?.message === "string"
|
||||
? errorBody.message
|
||||
: `(${putResponse.status})`;
|
||||
throw new Error(t("dialog.failed", { message }));
|
||||
}
|
||||
} else if (kind === "image-generation" && parsed.image) {
|
||||
const current = await getImageGeneration();
|
||||
await putImageGeneration(buildImageImportBody(parsed.image, current, mode));
|
||||
}
|
||||
toast.success(t("dialog.success"));
|
||||
onOpenChange(false);
|
||||
await onImported();
|
||||
} catch (reason) {
|
||||
setSubmitError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseErrorMessages: Record<ParseErrorCode, string> = {
|
||||
too_large: t("dialog.errors.too_large"),
|
||||
invalid_json: t("dialog.errors.invalid_json"),
|
||||
wrong_kind: t("dialog.errors.wrong_kind", {
|
||||
expected: kind === "model-registry" ? REGISTRY_EXPORT_KIND : IMAGE_EXPORT_KIND,
|
||||
}),
|
||||
unsupported_version: t("dialog.errors.unsupported_version"),
|
||||
invalid_shape: t("dialog.errors.invalid_shape"),
|
||||
};
|
||||
|
||||
const preview =
|
||||
parsed?.registry != null
|
||||
? t("dialog.previewRegistry", {
|
||||
providers: String(parsed.registry.payload.registry.providers.length),
|
||||
models: String(
|
||||
parsed.registry.payload.registry.providers.reduce(
|
||||
(count, provider) => count + provider.models.length,
|
||||
0
|
||||
)
|
||||
),
|
||||
})
|
||||
: parsed?.image != null
|
||||
? t("dialog.previewImage", {
|
||||
models: String(parsed.image.payload.models.length),
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{kind === "model-registry"
|
||||
? t("dialog.titleRegistry")
|
||||
: t("dialog.titleImage")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("dialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.fileLabel")}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="text-sm"
|
||||
onChange={(event) => void onFileChange(event)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.pasteLabel")}
|
||||
</span>
|
||||
<textarea
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
rows={6}
|
||||
value={text}
|
||||
placeholder={t("dialog.pastePlaceholder")}
|
||||
spellCheck={false}
|
||||
onChange={(event) => void analyze(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{parseError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{parseErrorMessages[parseError]}
|
||||
</p>
|
||||
)}
|
||||
{parsed && (
|
||||
<div className="space-y-2 rounded-md border border-border p-3 text-xs">
|
||||
<p className="font-medium">{preview}</p>
|
||||
{parsed.missing.length > 0 && (
|
||||
<div className="space-y-0.5 text-[var(--color-warning)]">
|
||||
<p>{t("dialog.missingSecretsTitle")}</p>
|
||||
<ul className="list-inside list-disc">
|
||||
{parsed.missing.map((id) => (
|
||||
<li key={id} className="font-mono">
|
||||
{id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground">{t("dialog.secretsNotice")}</p>
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.modeLabel")}
|
||||
</legend>
|
||||
{(["replace", "merge"] as const).map((value) => (
|
||||
<label key={value} className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-mode"
|
||||
className="mt-1"
|
||||
checked={mode === value}
|
||||
onChange={() => setMode(value)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
{value === "replace"
|
||||
? t("dialog.modeReplace")
|
||||
: t("dialog.modeMerge")}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{value === "replace"
|
||||
? t("dialog.modeReplaceHint")
|
||||
: t("dialog.modeMergeHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
{submitError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("dialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!parsed || submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? t("dialog.submitting") : t("dialog.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user