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