283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
import { useState } from "react";
|
|
import { AlertTriangle, ArrowRight, Check, ChevronDown, FileJson, ShieldCheck } from "lucide-react";
|
|
import Button from "./ui/Button";
|
|
import Badge from "./ui/Badge";
|
|
import { sampleJson } from "../lib/api";
|
|
import { parseJson } from "../lib/format";
|
|
|
|
function buildPayload(server, parsed) {
|
|
const entries = Array.isArray(parsed) ? parsed : parsed.servers || [];
|
|
const services = [];
|
|
const errors = [];
|
|
|
|
entries.forEach((entry, index) => {
|
|
const domain = String(entry?.domain || "").trim();
|
|
const ports = Array.isArray(entry?.ports) ? entry.ports : [];
|
|
if (!domain || ports.length === 0) {
|
|
errors.push({ index, message: "Each entry needs a domain and at least one port." });
|
|
return;
|
|
}
|
|
ports.forEach((port, portIndex) => {
|
|
const value = Number(port);
|
|
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
errors.push({ index, serviceIndex: portIndex, message: `Port "${port}" is not valid.` });
|
|
return;
|
|
}
|
|
services.push({
|
|
name: domain,
|
|
port: value,
|
|
endpoint: "/",
|
|
method: "POST",
|
|
notes: entry.is_backup ? "backup" : null,
|
|
});
|
|
});
|
|
});
|
|
|
|
return {
|
|
payload: {
|
|
servers: [
|
|
{
|
|
name: server.name,
|
|
ipAddress: server.ipAddress,
|
|
description: server.description,
|
|
proxyPort: server.proxyPort ?? null,
|
|
proxyPath: server.proxyPort ? server.proxyPath || "/api/nginx/app" : null,
|
|
services,
|
|
},
|
|
],
|
|
},
|
|
errors,
|
|
};
|
|
}
|
|
|
|
function ConflictSide({ title, data, highlight = false }) {
|
|
return (
|
|
<div className={`rounded-lg p-2.5 ${highlight ? "border border-amber-500/30 bg-amber-500/10" : "bg-surface-3/60"}`}>
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-muted">{title}</p>
|
|
{data?.name && <p className="mt-0.5 truncate text-xs font-semibold text-ink">{data.name}</p>}
|
|
{data?.description && <p className="truncate text-[11px] text-ink-muted">{data.description}</p>}
|
|
{data?.port !== undefined && data?.port !== null && (
|
|
<p className="mono mt-0.5 text-[10px] text-ink-muted">
|
|
port {data.port}
|
|
{data.method ? ` · ${data.method}` : ""}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ImportPanel({ server, onPreview, onApply }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [jsonInput, setJsonInput] = useState(sampleJson);
|
|
const [preview, setPreview] = useState(null);
|
|
const [jsonError, setJsonError] = useState("");
|
|
const [shapeErrors, setShapeErrors] = useState([]);
|
|
const [busy, setBusy] = useState("");
|
|
|
|
function prepare() {
|
|
setJsonError("");
|
|
setShapeErrors([]);
|
|
let parsed;
|
|
try {
|
|
parsed = parseJson(jsonInput);
|
|
} catch {
|
|
setJsonError("The JSON is not valid.");
|
|
return null;
|
|
}
|
|
const result = buildPayload(server, parsed);
|
|
setShapeErrors(result.errors);
|
|
return result.errors.length === 0 ? result.payload : null;
|
|
}
|
|
|
|
async function previewImport() {
|
|
const payload = prepare();
|
|
if (!payload) return;
|
|
setBusy("preview");
|
|
try {
|
|
setPreview(await onPreview(payload));
|
|
} catch (err) {
|
|
setJsonError(err.message);
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function apply(mode) {
|
|
const payload = prepare();
|
|
if (!payload) return;
|
|
setBusy(mode);
|
|
try {
|
|
await onApply(payload, mode);
|
|
setPreview(null);
|
|
} catch (err) {
|
|
setJsonError(err.message);
|
|
if (err.data) setPreview(err.data);
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
const hasConflicts = preview?.conflicts?.length > 0;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-line bg-surface-2/40">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
className="flex w-full items-center justify-between gap-3 p-3 text-left transition hover:bg-surface-2/60"
|
|
aria-expanded={expanded}
|
|
>
|
|
<span className="flex items-center gap-2.5">
|
|
<FileJson size={15} className="text-accent" />
|
|
<span className="text-sm font-semibold text-ink">Bulk Import</span>
|
|
<span className="hidden text-[11px] text-ink-muted sm:inline">
|
|
add domains & ports to {server.name}
|
|
</span>
|
|
</span>
|
|
<ChevronDown size={16} className={`shrink-0 text-ink-muted transition-transform ${expanded ? "rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="grid gap-4 border-t border-line p-4">
|
|
{!preview ? (
|
|
<>
|
|
<textarea
|
|
className="input mono min-h-40 text-xs"
|
|
spellCheck="false"
|
|
value={jsonInput}
|
|
onChange={(event) => setJsonInput(event.target.value)}
|
|
/>
|
|
<p className="-mt-2 text-[11px] leading-relaxed text-ink-muted">
|
|
One entry per service: <span className="mono text-ink-soft">{"{ domain, ports, is_backup }"}</span>.
|
|
Each domain becomes a service on {server.name}; same domain + port already present is skipped,
|
|
a domain with a different port is a conflict.
|
|
</p>
|
|
{jsonError && (
|
|
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs font-medium text-rose-400">
|
|
{jsonError}
|
|
</p>
|
|
)}
|
|
{shapeErrors.length > 0 && (
|
|
<div className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3">
|
|
<ul className="grid gap-1.5">
|
|
{shapeErrors.map((errorItem, index) => (
|
|
<li key={index} className="mono text-[11px] text-rose-300/90">
|
|
#{errorItem.index}
|
|
{errorItem.serviceIndex !== undefined ? `.${errorItem.serviceIndex}` : ""} — {errorItem.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<Button variant="primary" icon={ArrowRight} onClick={previewImport} loading={busy === "preview"}>
|
|
Preview Import
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="rounded-xl border border-line bg-surface-2/60 p-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-muted">New services</p>
|
|
<p className="mt-1 text-xl font-bold text-ink">{preview.newServices?.length || 0}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-line bg-surface-2/60 p-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-muted">Duplicates</p>
|
|
<p className="mt-1 text-xl font-bold text-ink">{preview.duplicates?.length || 0}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-line bg-surface-2/60 p-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-muted">Conflicts</p>
|
|
<p className={`mt-1 text-xl font-bold ${hasConflicts ? "text-amber-400" : "text-ink"}`}>
|
|
{preview.conflicts?.length || 0}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{preview.errors.length > 0 && (
|
|
<div className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3">
|
|
<p className="mb-2 text-xs font-bold text-rose-400">Validation errors — fix these before applying</p>
|
|
<ul className="grid gap-1.5">
|
|
{preview.errors.map((errorItem, index) => (
|
|
<li key={index} className="mono text-[11px] text-rose-300/90">
|
|
#{errorItem.index}
|
|
{errorItem.serviceIndex !== undefined ? `.${errorItem.serviceIndex}` : ""} — {errorItem.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{preview.duplicates.length > 0 && (
|
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3">
|
|
<p className="mb-2 flex items-center gap-1.5 text-xs font-bold text-emerald-400">
|
|
<Check size={13} /> {preview.duplicates.length} exact duplicate
|
|
{preview.duplicates.length === 1 ? "" : "s"} will be skipped
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{preview.duplicates.map((dup, index) => (
|
|
<Badge key={index} className="mono border-emerald-500/30 bg-emerald-500/10 text-emerald-300">
|
|
{dup.name}:{dup.port}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasConflicts && (
|
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
|
|
<p className="mb-3 flex items-center gap-1.5 text-xs font-bold text-amber-400">
|
|
<AlertTriangle size={13} /> {preview.conflicts.length} conflict
|
|
{preview.conflicts.length === 1 ? "" : "s"} need confirmation
|
|
</p>
|
|
<div className="grid gap-2.5">
|
|
{preview.conflicts.map((conflict, index) => (
|
|
<div key={index} className="rounded-lg border border-line bg-surface-2/60 p-3">
|
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
<Badge className="border-violet-500/40 bg-violet-500/10 text-violet-300">Port conflict</Badge>
|
|
<span className="mono text-[11px] text-ink-soft">{conflict.key || conflict.ipAddress}</span>
|
|
</div>
|
|
<div className="grid items-center gap-2 sm:grid-cols-[1fr_auto_1fr]">
|
|
<ConflictSide title="Existing" data={conflict.existing} />
|
|
<ArrowRight size={14} className="mx-auto text-ink-muted" />
|
|
<ConflictSide title="Incoming" data={conflict.incoming} highlight />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!hasConflicts && preview.errors.length === 0 && (
|
|
<p className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-xs font-medium text-emerald-400">
|
|
<ShieldCheck size={14} /> Import is clean — applying adds new services and skips exact duplicates.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap justify-end gap-3 border-t border-line pt-4">
|
|
<Button variant="ghost" onClick={() => setPreview(null)} disabled={Boolean(busy)}>
|
|
Back
|
|
</Button>
|
|
{hasConflicts ? (
|
|
<Button variant="amber" onClick={() => apply("overwrite")} loading={busy === "overwrite"}>
|
|
Confirm Overwrite
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => apply("skip")}
|
|
loading={busy === "skip"}
|
|
disabled={preview.errors.length > 0}
|
|
>
|
|
Apply Import
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|