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 &amp; 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>
);
}