Modular UI rebuild, bulk proxy/delete, search fixes

This commit is contained in:
MOHAN 2026-08-01 18:28:31 +05:30
parent 49013eba0d
commit 3947249b33
26 changed files with 2386 additions and 374 deletions

View File

@ -4,7 +4,15 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<meta name="description" content="MTC Server Reverse Proxy Manager — catalog internal servers, register services, and fire proxied requests from one control center." />
<meta name="theme-color" content="#070b15" />
<title>MTC Reverse Proxy Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View File

@ -1,405 +1,505 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertTriangle,
Check,
Database,
FileJson,
Globe2,
Loader2,
Plus,
RefreshCcw,
Route,
Server,
ShieldCheck,
Zap,
} from "lucide-react";
import { AnimatePresence } from "framer-motion";
import { Plus, Play, RefreshCcw, Search, Server, ShieldCheck, Trash2 } from "lucide-react";
import Header from "./components/Header";
import StatsBar from "./components/StatsBar";
import ServerCard from "./components/ServerCard";
import ServerDetail from "./components/ServerDetail";
import BulkStatusPage from "./components/BulkStatusPage";
import ServerFormModal from "./components/modals/ServerFormModal";
import ServiceFormModal from "./components/modals/ServiceFormModal";
import ConfirmDialog from "./components/ui/ConfirmDialog";
import EmptyState from "./components/ui/EmptyState";
import Skeleton from "./components/ui/Skeleton";
import Button from "./components/ui/Button";
import { ToastProvider, useToast } from "./components/toast/ToastProvider";
import useServers from "./hooks/useServers";
import { API_BASE } from "./lib/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api";
const THEME_KEY = "mtc-theme";
const VIEW_KEY = "mtc-service-view";
const emptyServer = { name: "", ipAddress: "", description: "" };
const emptyService = {
serverId: "",
name: "",
port: "",
endpoint: "",
method: "POST",
requestTemplate: "{}",
notes: "",
};
function AppShell() {
const { notify } = useToast();
const data = useServers();
const { servers, loading, loadError, refresh } = data;
const sampleJson = JSON.stringify(
{
servers: [
{
name: "Billing Server",
ipAddress: "192.168.1.50",
services: [
{
name: "Invoice API",
port: 8080,
endpoint: "/api/invoices/sync",
method: "POST",
requestTemplate: { limit: 10 },
},
],
},
],
},
null,
2,
);
const [query, setQuery] = useState("");
const [sort, setSort] = useState("name");
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark");
const [proxyCalls, setProxyCalls] = useState(0);
async function api(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
headers: { "content-type": "application/json", ...(options.headers || {}) },
...options,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(data?.message || "Request failed.");
error.data = data;
throw error;
}
return data;
}
const [pageServerId, setPageServerId] = useState(null);
const [bulkRun, setBulkRun] = useState(null);
const [proxyBusyId, setProxyBusyId] = useState(null);
const [serviceView, setServiceView] = useState(() => localStorage.getItem(VIEW_KEY) || "grid");
const [serverModal, setServerModal] = useState(null);
const [serviceModal, setServiceModal] = useState(null);
const [confirm, setConfirm] = useState(null);
function parseJsonField(value) {
const trimmed = value.trim();
if (!trimmed) return null;
return JSON.parse(trimmed);
}
function Stat({ icon: Icon, label, value }) {
return (
<div className="flex items-center gap-3 border border-slate-200 bg-white px-4 py-3">
<span className="flex h-10 w-10 items-center justify-center bg-slate-100 text-slate-700">
<Icon size={20} />
</span>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="text-xl font-semibold text-slate-950">{value}</p>
</div>
</div>
);
}
function Field({ label, children }) {
return (
<label className="grid gap-1.5 text-sm font-medium text-slate-700">
<span>{label}</span>
{children}
</label>
);
}
function App() {
const [servers, setServers] = useState([]);
const [serverForm, setServerForm] = useState(emptyServer);
const [serviceForm, setServiceForm] = useState(emptyService);
const [jsonInput, setJsonInput] = useState(sampleJson);
const [preview, setPreview] = useState(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [proxyResult, setProxyResult] = useState(null);
const totals = useMemo(() => {
const services = servers.reduce((sum, server) => sum + server.services.length, 0);
const ports = new Set(servers.flatMap((server) => server.services.map((service) => service.port)));
return { servers: servers.length, services, ports: ports.size };
}, [servers]);
async function loadServers() {
setLoading(true);
setError("");
try {
const data = await api("/servers");
setServers(data);
if (data[0] && !serviceForm.serverId) {
setServiceForm((current) => ({ ...current, serverId: data[0].id }));
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
const activeServer = pageServerId ? servers.find((server) => server.id === pageServerId) || null : null;
useEffect(() => {
loadServers();
}, []);
document.documentElement.classList.toggle("light", theme === "light");
localStorage.setItem(THEME_KEY, theme);
}, [theme]);
async function createServer(event) {
event.preventDefault();
setError("");
setMessage("");
try {
await api("/servers", { method: "POST", body: JSON.stringify(serverForm) });
setServerForm(emptyServer);
setMessage("Server added.");
await loadServers();
} catch (err) {
setError(err.message);
useEffect(() => {
localStorage.setItem(VIEW_KEY, serviceView);
}, [serviceView]);
useEffect(() => {
if (activeServer) window.scrollTo({ top: 0 });
}, [activeServer]);
useEffect(() => {
if (pageServerId && !activeServer && !loading) setPageServerId(null);
}, [pageServerId, activeServer, loading]);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
let list = servers;
if (needle) {
list = servers
.map((server) => {
const services = server.services.filter(
(service) =>
service.name.toLowerCase().includes(needle) ||
service.endpoint.toLowerCase().includes(needle) ||
String(service.port).includes(needle),
);
const matches =
services.length > 0 ||
server.name.toLowerCase().includes(needle) ||
server.ipAddress.toLowerCase().includes(needle) ||
(server.description || "").toLowerCase().includes(needle);
return matches ? { ...server, services } : null;
})
.filter(Boolean);
}
return [...list].sort((a, b) =>
sort === "count" ? b.services.length - a.services.length : a.name.localeCompare(b.name),
);
}, [servers, query, sort]);
const serviceMatches = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return [];
const matches = [];
for (const server of servers) {
for (const service of server.services) {
if (
service.name.toLowerCase().includes(needle) ||
service.endpoint.toLowerCase().includes(needle) ||
String(service.port).includes(needle) ||
(service.notes || "").toLowerCase().includes(needle)
) {
matches.push({ service, server });
}
}
}
return matches.sort(
(a, b) => a.service.name.localeCompare(b.service.name) || a.service.port - b.service.port,
);
}, [servers, query]);
async function createService(event) {
event.preventDefault();
setError("");
setMessage("");
try {
await api("/services", {
method: "POST",
body: JSON.stringify({
...serviceForm,
requestTemplate: parseJsonField(serviceForm.requestTemplate),
}),
async function handleSaveServer(form) {
if (serverModal?.initial) {
const saved = await data.updateServer(serverModal.initial.id, form);
notify("success", "Server updated", `${saved.name} (${saved.ipAddress})`);
} else {
const saved = await data.createServer(form);
notify("success", "Server added", `${saved.name} (${saved.ipAddress})`);
}
setServerModal(null);
}
async function handleSaveService(form) {
if (serviceModal?.service) {
const saved = await data.updateService(serviceModal.service.id, form);
notify("success", "Service updated", `${saved.name} ${saved.endpoint}`);
} else {
const saved = await data.createService(form);
notify("success", "Service added", `${saved.name} ${saved.endpoint}`);
}
setServiceModal(null);
}
function requestDeleteServer(server) {
setConfirm({
title: "Delete Server",
description: `"${server.name}" (${server.ipAddress}) and all of its ${server.services.length} service${server.services.length === 1 ? "" : "s"} will be permanently removed.`,
confirmLabel: "Delete Server",
action: async () => {
await data.deleteServer(server.id);
notify("success", "Server deleted", `${server.name} was removed.`);
},
});
setServiceForm((current) => ({
...emptyService,
serverId: current.serverId,
method: "POST",
requestTemplate: "{}",
}
function requestDeleteService(service) {
setConfirm({
title: "Delete Service",
description: `"${service.name}" (${service.method} ${service.endpoint}) will be permanently removed.`,
confirmLabel: "Delete Service",
action: async () => {
await data.deleteService(service.id);
notify("success", "Service deleted", `${service.name} was removed.`);
},
});
}
async function handleApplyImport(payload, mode) {
const result = await data.applyImport(payload, mode);
await refresh();
notify(
"success",
mode === "overwrite" ? "Import applied with overwrite" : "Import applied",
`${result.applied.servicesCreated} services created, ${result.applied.duplicatesSkipped} duplicates skipped.`,
);
}
async function handleProxy(service) {
try {
const result = await data.proxyRequest(service.id, undefined);
setProxyCalls((count) => count + (result.ok ? 1 : 0));
if (result.ok) {
notify("success", "Proxy OK", `${service.name}:${service.port}${result.status}`);
} else {
notify("error", "Proxy failed", `${service.name}:${service.port} returned ${result.status}.`);
}
} catch (err) {
notify("error", "Proxy failed", `${service.name}:${service.port}${err.message}`);
}
}
async function handleBulkProxy(services) {
const tasks = services.map((service) => ({
id: service.id,
service,
status: "pending",
statusCode: null,
detail: "",
}));
setMessage("Service added.");
await loadServers();
} catch (err) {
setError(err.message);
}
}
setBulkRun({ serverId: activeServer?.id, tasks });
async function previewImport() {
setError("");
setMessage("");
const publish = () => setBulkRun((current) => ({ ...current, tasks: [...tasks] }));
const outcomes = await Promise.all(
tasks.map(async (task) => {
task.status = "running";
publish();
try {
const payload = JSON.parse(jsonInput);
const data = await api("/imports/preview", { method: "POST", body: JSON.stringify(payload) });
setPreview(data);
setMessage(
data.conflicts.length
? "Conflicts found. Review and confirm before overwrite."
: "Import is ready. Exact duplicates will be skipped.",
const result = await data.proxyRequest(task.service.id, undefined);
if (result.ok) {
task.status = "success";
task.statusCode = result.status;
task.detail = result.target || "";
return true;
}
task.status = "error";
task.statusCode = result.status;
task.detail = `HTTP ${result.status}`;
return false;
} catch (err) {
task.status = "error";
task.detail = err.message;
return false;
} finally {
publish();
}
}),
);
} catch (err) {
setError(err.message);
}
}
async function applyImport(mode = "skip") {
setError("");
setMessage("");
try {
const payload = JSON.parse(jsonInput);
const data = await api("/imports/apply", {
method: "POST",
body: JSON.stringify({ payload, mode }),
});
setPreview(null);
setMessage(
`Import applied: ${data.applied.serversCreated} servers and ${data.applied.servicesCreated} services created.`,
const okCount = outcomes.filter(Boolean).length;
setProxyCalls((count) => count + okCount);
const failedCount = outcomes.length - okCount;
notify("success", "Bulk proxy complete", `${okCount} of ${outcomes.length} succeeded.`);
if (failedCount > 0) {
notify(
"error",
`${failedCount} failed`,
tasks
.filter((task) => task.status === "error")
.map((task) => `${task.service.name}:${task.service.port}${task.detail}`)
.join(", "),
);
await loadServers();
} catch (err) {
setError(err.message);
if (err.data) setPreview(err.data);
}
}
async function reverseProxy(serviceId, body) {
setError("");
setProxyResult(null);
async function handleBulkDelete(services) {
const results = await Promise.allSettled(services.map((service) => data.deleteService(service.id)));
const failed = results
.map((outcome, index) => (outcome.status === "rejected" ? services[index].name : null))
.filter(Boolean);
const okCount = services.length - failed.length;
await refresh();
notify("success", "Bulk delete complete", `${okCount} of ${services.length} services deleted.`);
if (failed.length > 0) {
notify("error", `${failed.length} failed`, failed.join(", "));
}
}
async function fireProxy(service) {
setProxyBusyId(service.id);
try {
const data = await api(`/services/${serviceId}/reverse-proxy`, {
method: "POST",
body: JSON.stringify({ body }),
});
setProxyResult(data);
await handleProxy(service);
} finally {
setProxyBusyId(null);
}
}
async function handleRefresh() {
try {
await refresh();
notify("info", "Data refreshed", `${servers.length} servers loaded.`);
} catch (err) {
setError(err.message);
notify("error", "Refresh failed", err.message);
}
}
const showSkeletons = loading && servers.length === 0;
return (
<div className="min-h-screen bg-[#f5f7fb]">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center bg-slate-950 text-white">
<Route size={22} />
<div className="min-h-screen">
<Header
query={query}
onQueryChange={setQuery}
theme={theme}
onToggleTheme={() => setTheme((current) => (current === "dark" ? "light" : "dark"))}
onAddServer={() => setServerModal({ initial: null })}
onAddService={() => setServiceModal({ server: null, service: null })}
onRefresh={handleRefresh}
loading={loading}
searchEnabled={!activeServer}
/>
{activeServer ? (
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:px-8">
{bulkRun ? (
<BulkStatusPage server={activeServer} run={bulkRun} onBack={() => setBulkRun(null)} />
) : (
<ServerDetail
server={activeServer}
viewMode={serviceView}
onToggleViewMode={setServiceView}
onBack={() => setPageServerId(null)}
onEditServer={() => setServerModal({ initial: activeServer })}
onDeleteServer={() => requestDeleteServer(activeServer)}
onEditService={(service) => setServiceModal({ server: activeServer, service })}
onDeleteService={(service) => requestDeleteService(service)}
onProxy={handleProxy}
onBulkProxy={handleBulkProxy}
onBulkDelete={handleBulkDelete}
onImportPreview={(payload) => data.previewImport(payload)}
onImportApply={handleApplyImport}
/>
)}
</main>
) : (
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:px-8">
<StatsBar servers={servers} proxyCalls={proxyCalls} />
{serviceMatches.length > 0 && (
<section className="grid content-start gap-3">
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-bold text-ink">Matching Services</h2>
<span className="rounded-lg bg-surface-2 px-2 py-0.5 text-xs font-semibold text-ink-soft">
{serviceMatches.length}
</span>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-cyan-700">MTC Server Reverse Proxy</p>
<h1 className="text-2xl font-semibold text-slate-950 sm:text-3xl">Reverse Proxy Manager</h1>
</div>
</div>
<div className="card overflow-hidden p-2">
<div className="grid gap-1">
{serviceMatches.map(({ service, server }) => (
<div
key={service.id}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition hover:bg-surface-2/60"
>
<span className="mono flex h-8 w-14 shrink-0 items-center justify-center rounded-lg bg-surface-3/80 text-xs font-semibold text-ink-soft">
:{service.port}
</span>
<button
className="inline-flex items-center justify-center gap-2 bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-700"
onClick={loadServers}
type="button"
onClick={() => setPageServerId(server.id)}
className="min-w-0 flex-1 truncate text-left text-sm font-semibold text-ink transition hover:text-accent"
title="Open server"
>
{service.name}
</button>
<button
type="button"
onClick={() => setPageServerId(server.id)}
className="hidden max-w-44 truncate text-[11px] text-ink-muted transition hover:text-ink sm:block"
title="Open server"
>
{server.name}
</button>
<Button
variant="primary"
size="sm"
icon={Play}
loading={proxyBusyId === service.id}
disabled={proxyBusyId !== null && proxyBusyId !== service.id}
onClick={() => fireProxy(service)}
>
Proxy
</Button>
<button
aria-label={`Delete ${service.name}`}
className="rounded-lg p-2 text-ink-muted transition hover:bg-rose-500/10 hover:text-rose-400"
onClick={() => requestDeleteService(service)}
type="button"
>
{loading ? <Loader2 className="animate-spin" size={18} /> : <RefreshCcw size={18} />}
Refresh
<Trash2 size={14} />
</button>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Stat icon={Server} label="Servers" value={totals.servers} />
<Stat icon={Globe2} label="Services" value={totals.services} />
<Stat icon={Database} label="Ports" value={totals.ports} />
</div>
</div>
</header>
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[380px_1fr] lg:px-8">
<aside className="grid content-start gap-6">
<section className="border border-slate-200 bg-white p-4">
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-slate-950">
<Plus size={18} /> Add Server
</h2>
<form className="grid gap-3" onSubmit={createServer}>
<Field label="Server name">
<input className="border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" required value={serverForm.name} onChange={(event) => setServerForm({ ...serverForm, name: event.target.value })} />
</Field>
<Field label="IP address">
<input className="border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" required value={serverForm.ipAddress} onChange={(event) => setServerForm({ ...serverForm, ipAddress: event.target.value })} />
</Field>
<Field label="Description">
<textarea className="min-h-20 border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" value={serverForm.description} onChange={(event) => setServerForm({ ...serverForm, description: event.target.value })} />
</Field>
<button className="inline-flex items-center justify-center gap-2 bg-cyan-700 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-950">
<Check size={18} /> Save Server
</button>
</form>
</section>
<section className="border border-slate-200 bg-white p-4">
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-slate-950">
<Zap size={18} /> Add Service
</h2>
<form className="grid gap-3" onSubmit={createService}>
<Field label="Server">
<select className="border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" required value={serviceForm.serverId} onChange={(event) => setServiceForm({ ...serviceForm, serverId: event.target.value })}>
<option value="">Select server</option>
{servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ipAddress})
</option>
))}
</div>
</div>
</section>
)}
{!showSkeletons && (
<section className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-bold text-ink">Servers</h2>
<span className="rounded-lg bg-surface-2 px-2 py-0.5 text-xs font-semibold text-ink-soft">
{filtered.length}
</span>
{query.trim() && (
<button
className="flex items-center gap-1 rounded-lg border border-line bg-surface-2 px-2.5 py-1 text-xs font-medium text-ink-soft transition hover:text-ink"
onClick={() => setQuery("")}
type="button"
>
<Search size={12} /> {query.trim()} <span className="text-ink-muted">clear</span>
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs font-medium text-ink-muted">
Sort
<select
className="input !w-auto !py-1.5 text-xs"
value={sort}
onChange={(event) => setSort(event.target.value)}
>
<option value="name">Name</option>
<option value="count">Most services</option>
</select>
</Field>
<Field label="Service name">
<input className="border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" required value={serviceForm.name} onChange={(event) => setServiceForm({ ...serviceForm, name: event.target.value })} />
</Field>
<div className="grid grid-cols-[1fr_110px] gap-3">
<Field label="Endpoint">
<input className="border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" required value={serviceForm.endpoint} onChange={(event) => setServiceForm({ ...serviceForm, endpoint: event.target.value })} />
</Field>
<Field label="Port">
<input className="border border-slate-300 px-3 py-2 outline-none focus:border-cyan-600" required min="1" max="65535" type="number" value={serviceForm.port} onChange={(event) => setServiceForm({ ...serviceForm, port: event.target.value })} />
</Field>
</div>
<Field label="Request JSON">
<textarea className="min-h-24 border border-slate-300 px-3 py-2 font-mono text-xs outline-none focus:border-cyan-600" value={serviceForm.requestTemplate} onChange={(event) => setServiceForm({ ...serviceForm, requestTemplate: event.target.value })} />
</Field>
<button className="inline-flex items-center justify-center gap-2 bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-cyan-700">
<Check size={18} /> Save Service
</button>
</form>
</label>
</section>
)}
<section className="border border-slate-200 bg-white p-4">
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-slate-950">
<FileJson size={18} /> JSON Import
</h2>
<textarea className="min-h-80 w-full border border-slate-300 px-3 py-2 font-mono text-xs outline-none focus:border-cyan-600" value={jsonInput} onChange={(event) => setJsonInput(event.target.value)} />
<div className="mt-3 grid grid-cols-2 gap-3">
<button className="border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:border-cyan-700" onClick={previewImport} type="button">
Preview
</button>
<button className="bg-cyan-700 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-950" onClick={() => applyImport("skip")} type="button">
Add New
</button>
<AnimatePresence mode="popLayout">
{showSkeletons && (
<div className="grid gap-4" key="skeletons">
<Skeleton className="h-36 w-full" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-36 w-full" />
</div>
</section>
</aside>
)}
<section className="grid content-start gap-6">
<AnimatePresence>
{(message || error) && (
<motion.div animate={{ opacity: 1, y: 0 }} className={`border p-4 text-sm font-medium ${error ? "border-rose-200 bg-rose-50 text-rose-800" : "border-emerald-200 bg-emerald-50 text-emerald-800"}`} exit={{ opacity: 0, y: -8 }} initial={{ opacity: 0, y: -8 }}>
{error || message}
</motion.div>
{!showSkeletons && loadError && (
<EmptyState
key="error"
icon={ShieldCheck}
title="Can't reach the API"
description={`${loadError} — make sure the backend is running and CORS allows this origin.`}
>
<Button variant="primary" icon={RefreshCcw} onClick={handleRefresh}>
Try again
</Button>
</EmptyState>
)}
{!showSkeletons && !loadError && servers.length === 0 && (
<EmptyState
key="empty"
icon={Server}
title="No servers registered"
description="Add your first internal server (e.g. the gateway machine), then use Bulk Import inside it to load your service catalog."
>
<Button variant="primary" icon={Plus} onClick={() => setServerModal({ initial: null })}>
Add Server
</Button>
</EmptyState>
)}
{!showSkeletons && !loadError && servers.length > 0 && filtered.length === 0 && (
<EmptyState
key="no-match"
icon={Search}
title="No matches"
description={`Nothing matches “${query.trim()}”. Try a different search term.`}
>
<Button variant="secondary" onClick={() => setQuery("")}>
Clear search
</Button>
</EmptyState>
)}
{!showSkeletons && filtered.length > 0 && (
<div className="grid gap-4" key="list">
{filtered.map((server, index) => (
<ServerCard
key={server.id}
server={server}
index={index}
onOpenServer={(item) => setPageServerId(item.id)}
onEditServer={(item) => setServerModal({ initial: item })}
onDeleteServer={requestDeleteServer}
/>
))}
</div>
)}
</AnimatePresence>
{preview && (
<section className="border border-slate-200 bg-white p-4">
<div className="flex flex-col justify-between gap-3 md:flex-row md:items-center">
<h2 className="flex items-center gap-2 text-base font-semibold text-slate-950">
{preview.conflicts?.length ? <AlertTriangle size={18} /> : <ShieldCheck size={18} />}
Import Preview
</h2>
{preview.conflicts?.length > 0 && (
<button className="bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-950" onClick={() => applyImport("overwrite")} type="button">
Confirm Overwrite
</button>
)}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<Stat icon={Plus} label="New servers" value={preview.newServers?.length || 0} />
<Stat icon={Zap} label="New services" value={preview.newServices?.length || 0} />
<Stat icon={AlertTriangle} label="Conflicts" value={preview.conflicts?.length || 0} />
</div>
</section>
)}
<div className="grid gap-4">
{servers.map((server) => (
<motion.article animate={{ opacity: 1, y: 0 }} className="border border-slate-200 bg-white p-4" initial={{ opacity: 0, y: 8 }} key={server.id}>
<div className="flex flex-col justify-between gap-3 border-b border-slate-200 pb-4 md:flex-row md:items-start">
<div>
<h2 className="text-lg font-semibold text-slate-950">{server.name}</h2>
<p className="mt-1 font-mono text-sm text-slate-600">{server.ipAddress}</p>
{server.description && <p className="mt-2 text-sm text-slate-600">{server.description}</p>}
</div>
<span className="bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-700">{server.services.length} services</span>
</div>
<div className="mt-4 grid gap-3 xl:grid-cols-2">
{server.services.map((service) => (
<div className="border border-slate-200 p-3" key={service.id}>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold text-slate-950">{service.name}</h3>
<p className="mt-1 break-all font-mono text-xs text-slate-600">
{service.method} http://{server.ipAddress}:{service.port}
{service.endpoint}
</p>
</div>
<button className="inline-flex shrink-0 items-center gap-2 bg-slate-950 px-3 py-2 text-xs font-semibold text-white hover:bg-cyan-700" onClick={() => reverseProxy(service.id, service.requestTemplate)} type="button">
<Route size={15} /> Reverse Proxy
</button>
</div>
</div>
))}
{server.services.length === 0 && <p className="border border-dashed border-slate-300 p-4 text-sm text-slate-500">No services added for this server yet.</p>}
</div>
</motion.article>
))}
</div>
{servers.length === 0 && !loading && <section className="border border-dashed border-slate-300 bg-white p-8 text-center text-slate-600">Add a server manually or import JSON to start building the proxy table.</section>}
{proxyResult && (
<section className="border border-slate-200 bg-white p-4">
<h2 className="mb-3 text-base font-semibold text-slate-950">Reverse Proxy Result</h2>
<pre className="max-h-96 overflow-auto bg-slate-950 p-4 text-xs text-slate-50">{JSON.stringify(proxyResult, null, 2)}</pre>
</section>
)}
</section>
<footer className="flex flex-wrap items-center justify-between gap-2 border-t border-line pb-4 pt-6 text-[11px] text-ink-muted">
<p>MTC Reverse Proxy Manager</p>
<p className="mono">API {API_BASE}</p>
</footer>
</main>
)}
<ServerFormModal
open={Boolean(serverModal)}
onClose={() => setServerModal(null)}
initial={serverModal?.initial}
onSave={handleSaveServer}
/>
<ServiceFormModal
open={Boolean(serviceModal)}
onClose={() => setServiceModal(null)}
initial={serviceModal?.service}
servers={servers}
onSave={handleSaveService}
/>
<ConfirmDialog
open={Boolean(confirm)}
onClose={() => setConfirm(null)}
title={confirm?.title}
description={confirm?.description}
confirmLabel={confirm?.confirmLabel}
onConfirm={async () => {
try {
await confirm.action();
setConfirm(null);
} catch (err) {
notify("error", "Action failed", err.message);
setConfirm(null);
}
}}
/>
</div>
);
}
export default App;
export default function App() {
return (
<ToastProvider>
<AppShell />
</ToastProvider>
);
}

View File

@ -0,0 +1,157 @@
import { motion } from "framer-motion";
import {
CheckCircle2,
ChevronLeft,
CircleDashed,
Loader2,
Server,
XCircle,
Zap,
} from "lucide-react";
import Button from "./ui/Button";
const STATUS_META = {
pending: { label: "Waiting", icon: CircleDashed, className: "text-ink-muted" },
running: { label: "Running", icon: Loader2, className: "text-cyan-400" },
success: { label: "Succeeded", icon: CheckCircle2, className: "text-emerald-400" },
error: { label: "Failed", icon: XCircle, className: "text-rose-400" },
};
function TaskRow({ task }) {
const meta = STATUS_META[task.status] || STATUS_META.pending;
const Icon = meta.icon;
const spinning = task.status === "running";
return (
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2/60 px-3.5 py-3">
<Icon size={18} className={`shrink-0 ${meta.className} ${spinning ? "animate-spin" : ""}`} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink">{task.service.name}</p>
<p className="truncate font-mono text-[11px] text-ink-muted">
port {task.service.port}
{task.detail ? ` · ${task.detail}` : ` · ${meta.label}`}
</p>
</div>
{task.statusCode != null && (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 font-mono text-[11px] font-semibold ${
task.status === "success"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-rose-500/30 bg-rose-500/10 text-rose-400"
}`}
>
{task.statusCode}
</span>
)}
</div>
);
}
export default function BulkStatusPage({ server, run, onBack }) {
const total = run.tasks.length;
const succeeded = run.tasks.filter((task) => task.status === "success").length;
const failed = run.tasks.filter((task) => task.status === "error").length;
const running = run.tasks.filter((task) => task.status === "running").length;
const done = succeeded + failed;
const finished = done === total;
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const barColor = finished
? failed > 0
? "bg-gradient-to-r from-amber-500 to-rose-500"
: "bg-gradient-to-r from-emerald-500 to-teal-500"
: "bg-gradient-to-r from-cyan-500 to-indigo-500";
return (
<section className="grid content-start gap-5">
<button
type="button"
onClick={onBack}
className="flex w-fit items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-medium text-ink-muted transition hover:bg-surface-2 hover:text-ink"
>
<ChevronLeft size={16} /> {server.name}
</button>
<div className="card overflow-hidden">
<div className="flex flex-col gap-4 border-b border-line p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3.5">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-500/20 to-indigo-500/20 text-accent">
<Zap size={22} />
</span>
<div className="min-w-0">
<h2 className="truncate text-lg font-bold text-ink">Bulk Proxy Status</h2>
<p className="flex items-center gap-1.5 truncate text-xs text-ink-soft">
<Server size={12} className="text-ink-muted" /> {server.name} · {server.ipAddress}
</p>
</div>
</div>
<span
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-semibold ${
finished
? failed > 0
? "border-amber-500/30 bg-amber-500/10 text-amber-400"
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-cyan-500/30 bg-cyan-500/10 text-cyan-400"
}`}
>
{finished ? (failed > 0 ? "Finished with errors" : "Finished") : `${running} running`}
</span>
</div>
<div className="grid gap-5 p-5">
<div>
<div className="mb-1.5 flex items-center justify-between text-xs font-medium">
<span className="text-ink-soft">
{done} of {total} services
</span>
<span className="font-mono text-ink">{pct}%</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-surface-3">
<motion.div
className={`h-full rounded-full ${barColor}`}
initial={false}
animate={{ width: `${Math.max(pct, 2)}%` }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Succeeded", value: succeeded, className: "text-emerald-400" },
{ label: "Failed", value: failed, className: "text-rose-400" },
{ label: "Running", value: running, className: "text-cyan-400" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-line bg-surface-2/60 px-4 py-3 text-center">
<p className={`text-2xl font-bold tabular-nums ${stat.className}`}>{stat.value}</p>
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-muted">{stat.label}</p>
</div>
))}
</div>
<div className="grid gap-2">
{run.tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
{finished && (
<div
className={`flex flex-col items-center justify-between gap-3 rounded-xl border p-4 sm:flex-row ${
failed > 0 ? "border-amber-500/30 bg-amber-500/5" : "border-emerald-500/30 bg-emerald-500/5"
}`}
>
<p className="text-sm font-semibold text-ink">
{failed > 0
? `${succeeded} succeeded, ${failed} failed.`
: `All ${succeeded} requests succeeded.`}
</p>
<Button variant="primary" onClick={onBack}>
Back to {server.name}
</Button>
</div>
)}
</div>
</div>
</section>
);
}

88
src/components/Header.jsx Normal file
View File

@ -0,0 +1,88 @@
import { useEffect, useRef } from "react";
import { Moon, Plus, RefreshCcw, Route, Search, Server, Sun } from "lucide-react";
import Button from "./ui/Button";
export default function Header({
query,
onQueryChange,
theme,
onToggleTheme,
onAddServer,
onAddService,
onRefresh,
loading,
searchEnabled = true,
}) {
const searchRef = useRef(null);
useEffect(() => {
function onKey(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchRef.current?.focus();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<header className="sticky top-0 z-40 border-b border-line bg-bg">
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-3 px-4 py-3.5 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-500 to-indigo-600 text-white shadow-lg shadow-cyan-500/30">
<Route size={20} />
</span>
<div className="leading-tight">
<p className="text-[11px] font-bold uppercase tracking-[0.18em] text-accent">MTC</p>
<h1 className="text-[15px] font-bold text-ink">Reverse Proxy Manager</h1>
</div>
</div>
{searchEnabled && (
<div className="order-last w-full sm:order-none sm:mx-auto sm:max-w-md sm:flex-1">
<div className="group relative">
<Search size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted" />
<input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Search servers, IPs, endpoints…"
className="input pl-9! pr-14!"
type="search"
/>
<kbd className="mono pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 rounded border border-line bg-surface-2 px-1.5 py-0.5 text-[10px] font-medium text-ink-muted">
Ctrl K
</kbd>
</div>
</div>
)}
<div className="flex items-center gap-2">
<button
aria-label="Toggle theme"
className="rounded-lg border border-line bg-surface-2 p-2 text-ink-soft transition hover:border-line-strong hover:text-ink"
onClick={onToggleTheme}
type="button"
>
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
</button>
<button
aria-label="Refresh"
className="rounded-lg border border-line bg-surface-2 p-2 text-ink-soft transition hover:border-line-strong hover:text-ink"
onClick={onRefresh}
type="button"
>
<RefreshCcw size={16} className={loading ? "animate-spin" : ""} />
</button>
<Button variant="secondary" icon={Plus} onClick={onAddService} className="hidden md:inline-flex">
Service
</Button>
<Button variant="primary" icon={Server} onClick={onAddServer}>
<span className="hidden sm:inline">Add </span>Server
</Button>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,282 @@
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>
);
}

View File

@ -0,0 +1,91 @@
import { motion } from "framer-motion";
import { ArrowUpRight, Pencil, Server, Trash2 } from "lucide-react";
import CopyButton from "./ui/CopyButton";
import Button from "./ui/Button";
export default function ServerCard({ server, index = 0, onOpenServer, onEditServer, onDeleteServer }) {
return (
<motion.article
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18, delay: Math.min(index * 0.03, 0.3) }}
className="card group relative overflow-hidden transition hover:border-line-strong hover:shadow-xl hover:shadow-black/25"
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent opacity-0 transition group-hover:opacity-100" />
<div className="flex flex-col gap-3 border-b border-line p-4 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={() => onOpenServer(server)}
className="group/head -m-1 flex min-w-0 items-center gap-3 rounded-xl p-1 text-left"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-accent transition group-hover/head:bg-gradient-to-br group-hover/head:from-cyan-500/20 group-hover/head:to-indigo-500/20">
<Server size={20} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-bold text-ink">{server.name}</span>
<ArrowUpRight size={14} className="shrink-0 text-accent opacity-0 transition group-hover/head:opacity-100" />
</span>
<span className="mono mt-0.5 flex items-center gap-1 text-xs text-ink-muted">
{server.ipAddress}
<CopyButton value={server.ipAddress} className="opacity-0 transition group-hover:opacity-100" />
</span>
</span>
</button>
<div className="flex items-center gap-2">
{server.proxyPort && (
<span className="mono hidden rounded-lg bg-cyan-500/10 px-2.5 py-1 text-[11px] font-medium text-cyan-400 md:inline-block">
gateway :{server.proxyPort}
{server.proxyPath || "/api/nginx/app"}
</span>
)}
<span className="rounded-lg bg-surface-2 px-2.5 py-1 text-xs font-semibold text-ink-soft">
{server.services.length} {server.services.length === 1 ? "service" : "services"}
</span>
<button
aria-label={`Edit ${server.name}`}
className="rounded-lg p-2 text-ink-muted transition hover:bg-surface-2 hover:text-ink"
onClick={() => onEditServer(server)}
type="button"
>
<Pencil size={15} />
</button>
<button
aria-label={`Delete ${server.name}`}
className="rounded-lg p-2 text-ink-muted transition hover:bg-rose-500/10 hover:text-rose-400"
onClick={() => onDeleteServer(server)}
type="button"
>
<Trash2 size={15} />
</button>
</div>
</div>
{server.description && <p className="border-b border-line px-4 py-2.5 text-xs leading-relaxed text-ink-soft">{server.description}</p>}
<div className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="flex min-w-0 flex-wrap gap-1.5">
{server.services.slice(0, 3).map((service) => (
<span key={service.id} className="mono max-w-56 truncate rounded-lg border border-line bg-surface-2/60 px-2 py-1 text-[11px] text-ink-soft">
{service.name}:{service.port}
</span>
))}
{server.services.length === 0 && (
<span className="text-[11px] text-ink-muted">No services yet</span>
)}
{server.services.length > 3 && (
<span className="mono rounded-lg bg-surface-3/60 px-2 py-1 text-[11px] font-semibold text-ink-muted">
+{server.services.length - 3}
</span>
)}
</div>
<Button variant="secondary" size="sm" onClick={() => onOpenServer(server)}>
View services
</Button>
</div>
</motion.article>
);
}

View File

@ -0,0 +1,293 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, LayoutGrid, List, Pencil, Search, Server, Trash2, Zap } from "lucide-react";
import CopyButton from "./ui/CopyButton";
import Button from "./ui/Button";
import ConfirmDialog from "./ui/ConfirmDialog";
import ServiceCard from "./ServiceCard";
import ImportPanel from "./ImportPanel";
function DetailStat({ label, value }) {
return (
<div className="px-5 py-3.5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-muted">{label}</p>
<p className="mt-0.5 text-xl font-bold text-ink">{value}</p>
</div>
);
}
export default function ServerDetail({
server,
viewMode,
onToggleViewMode,
onBack,
onEditServer,
onDeleteServer,
onEditService,
onDeleteService,
onProxy,
onBulkProxy,
onBulkDelete,
onImportPreview,
onImportApply,
}) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(() => new Set());
const [bulkDeleteTargets, setBulkDeleteTargets] = useState([]);
const [bulkDeleteBusy, setBulkDeleteBusy] = useState(false);
const selectAllRef = useRef(null);
const services = useMemo(() => {
const needle = query.trim().toLowerCase();
const list = needle
? server.services.filter(
(service) =>
service.name.toLowerCase().includes(needle) ||
String(service.port).includes(needle) ||
(service.notes || "").toLowerCase().includes(needle),
)
: server.services;
return [...list].sort((a, b) => a.port - b.port || a.name.localeCompare(b.name));
}, [server, query]);
useEffect(() => {
setSelected((current) => {
const valid = new Set(server.services.map((service) => service.id));
return new Set([...current].filter((id) => valid.has(id)));
});
}, [server]);
const allSelected = services.length > 0 && services.every((service) => selected.has(service.id));
const someSelected = services.some((service) => selected.has(service.id));
useEffect(() => {
if (selectAllRef.current) selectAllRef.current.indeterminate = someSelected && !allSelected;
}, [someSelected, allSelected]);
function toggleAll(event) {
setSelected(event.target.checked ? new Set(services.map((service) => service.id)) : new Set());
}
function toggleOne(id) {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function runBulkProxy() {
const targets = services.filter((service) => selected.has(service.id));
if (targets.length === 0) return;
onBulkProxy(targets);
}
async function runBulkDelete() {
setBulkDeleteBusy(true);
try {
await onBulkDelete(bulkDeleteTargets);
setSelected(new Set());
setBulkDeleteTargets([]);
} finally {
setBulkDeleteBusy(false);
}
}
const ports = new Set(server.services.map((service) => service.port)).size;
const backups = server.services.filter((service) => service.notes === "backup").length;
return (
<section className="grid content-start gap-5">
<button
type="button"
onClick={onBack}
className="flex w-fit items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-medium text-ink-muted transition hover:bg-surface-2 hover:text-ink"
>
<ChevronLeft size={16} /> All Servers
</button>
<div className="card overflow-hidden">
<div className="flex flex-col gap-4 border-b border-line p-5 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-center gap-3.5">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-500/20 to-indigo-500/20 text-accent">
<Server size={24} />
</span>
<div className="min-w-0">
<h2 className="truncate text-xl font-bold text-ink">{server.name}</h2>
<p className="mono mt-0.5 flex items-center gap-1.5 text-sm text-ink-muted">
{server.ipAddress}
<CopyButton value={server.ipAddress} />
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{server.proxyPort && (
<span className="mono rounded-lg bg-cyan-500/10 px-2.5 py-1.5 text-[11px] font-medium text-cyan-400">
gateway :{server.proxyPort}
{server.proxyPath || "/api/nginx/app"}
</span>
)}
<div className="flex items-center gap-1 rounded-xl border border-line bg-surface-2 p-1">
<button
aria-label="List view"
type="button"
onClick={() => onToggleViewMode("list")}
className={`rounded-lg p-1.5 transition ${viewMode === "list" ? "bg-surface-3 text-ink" : "text-ink-muted hover:text-ink"}`}
>
<List size={15} />
</button>
<button
aria-label="Grid view"
type="button"
onClick={() => onToggleViewMode("grid")}
className={`rounded-lg p-1.5 transition ${viewMode === "grid" ? "bg-surface-3 text-ink" : "text-ink-muted hover:text-ink"}`}
>
<LayoutGrid size={15} />
</button>
<span className="mx-1 h-5 w-px bg-line" />
<button
aria-label="Edit server"
className="rounded-lg p-1.5 text-ink-muted transition hover:text-ink"
onClick={onEditServer}
type="button"
>
<Pencil size={15} />
</button>
<button
aria-label="Delete server"
className="rounded-lg p-1.5 text-ink-muted transition hover:text-rose-400"
onClick={onDeleteServer}
type="button"
>
<Trash2 size={15} />
</button>
</div>
</div>
</div>
{server.description && <p className="border-b border-line px-5 py-3 text-sm text-ink-soft">{server.description}</p>}
<div className="grid grid-cols-3 divide-x divide-line border-b border-line">
<DetailStat label="Services" value={server.services.length} />
<DetailStat label="Ports" value={ports} />
<DetailStat label="Backups" value={backups} />
</div>
<div className="p-5">
<ImportPanel server={server} onPreview={onImportPreview} onApply={onImportApply} />
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2.5">
<h3 className="flex items-center gap-2 text-base font-bold text-ink">
Services
<span className="rounded-lg bg-surface-2 px-2 py-0.5 text-xs font-semibold text-ink-soft">{services.length}</span>
</h3>
<label className="flex cursor-pointer select-none items-center gap-2 rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs font-medium text-ink-soft transition hover:text-ink">
<input
ref={selectAllRef}
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="h-3.5 w-3.5 cursor-pointer rounded accent-cyan-500"
/>
Select all
</label>
{someSelected && (
<span className="rounded-lg bg-cyan-500/10 px-2 py-1 text-xs font-semibold text-cyan-400">
{services.filter((service) => selected.has(service.id)).length} selected
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted" />
<input
className="input w-56! pl-9!"
placeholder="Filter services…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Button
variant="primary"
icon={Zap}
onClick={runBulkProxy}
disabled={!someSelected}
title="Fire a proxy request to every selected service at once"
>
Proxy Selected
</Button>
<Button
variant="danger"
icon={Trash2}
onClick={() => setBulkDeleteTargets(services.filter((service) => selected.has(service.id)))}
disabled={!someSelected}
title="Delete every selected service"
>
Delete Selected
</Button>
</div>
</div>
<AnimatePresence mode="popLayout">
{services.length === 0 ? (
<motion.p
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-xl border border-dashed border-line-strong px-4 py-10 text-center text-sm text-ink-muted"
>
{server.services.length === 0
? "No services yet — add them with Bulk Import above or via Add Service."
: "No services match the filter."}
</motion.p>
) : viewMode === "list" ? (
<div key="list" className="grid gap-2">
{services.map((service, index) => (
<ServiceCard
key={service.id}
service={service}
mode="list"
index={index}
selected={selected.has(service.id)}
onToggleSelect={() => toggleOne(service.id)}
onEdit={() => onEditService(service)}
onDelete={() => onDeleteService(service)}
onProxy={() => onProxy(service)}
/>
))}
</div>
) : (
<div key="grid" className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{services.map((service, index) => (
<ServiceCard
key={service.id}
service={service}
mode="grid"
index={index}
selected={selected.has(service.id)}
onToggleSelect={() => toggleOne(service.id)}
onEdit={() => onEditService(service)}
onDelete={() => onDeleteService(service)}
onProxy={() => onProxy(service)}
/>
))}
</div>
)}
</AnimatePresence>
<ConfirmDialog
open={bulkDeleteTargets.length > 0}
onClose={() => !bulkDeleteBusy && setBulkDeleteTargets([])}
title={`Delete ${bulkDeleteTargets.length} service${bulkDeleteTargets.length === 1 ? "" : "s"}?`}
description={`${bulkDeleteTargets.map((service) => service.name).join(", ")} will be removed from ${server.name}.`}
confirmLabel={bulkDeleteTargets.length > 1 ? "Delete All" : "Delete"}
loading={bulkDeleteBusy}
onConfirm={runBulkDelete}
/>
</section>
);
}

View File

@ -0,0 +1,124 @@
import { useState } from "react";
import { motion } from "framer-motion";
import { Pencil, Play, Trash2 } from "lucide-react";
import Button from "./ui/Button";
function Checkbox({ service, selected, onToggleSelect }) {
return (
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
aria-label={`Select ${service.name}`}
className="h-4 w-4 shrink-0 cursor-pointer rounded accent-cyan-500"
/>
);
}
function Actions({ service, onEdit, onDelete, onProxy, proxyBusy }) {
return (
<div className="flex shrink-0 items-center gap-1">
<button
aria-label={`Edit ${service.name}`}
className="rounded-md p-1.5 text-ink-muted transition hover:bg-surface-3 hover:text-ink"
onClick={onEdit}
type="button"
>
<Pencil size={13} />
</button>
<button
aria-label={`Delete ${service.name}`}
className="rounded-md p-1.5 text-ink-muted transition hover:bg-rose-500/10 hover:text-rose-400"
onClick={onDelete}
type="button"
>
<Trash2 size={13} />
</button>
<Button variant="primary" size="sm" icon={Play} onClick={onProxy} loading={proxyBusy} className="ml-1">
Proxy
</Button>
</div>
);
}
export default function ServiceCard({
service,
mode = "grid",
index = 0,
selected = false,
onToggleSelect,
onEdit,
onDelete,
onProxy,
}) {
const [busy, setBusy] = useState(false);
async function fireProxy() {
setBusy(true);
try {
await onProxy(service);
} finally {
setBusy(false);
}
}
const entrance = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.15, delay: Math.min(index * 0.02, 0.2) },
};
if (mode === "list") {
return (
<motion.div
{...entrance}
className="group/service flex items-center gap-3 rounded-xl border border-line bg-surface-2/60 px-3.5 py-2.5 transition hover:border-line-strong hover:bg-surface-2"
>
<Checkbox service={service} selected={selected} onToggleSelect={onToggleSelect} />
<span className="mono flex h-8 w-16 shrink-0 items-center justify-center rounded-lg bg-surface-3/80 text-xs font-semibold text-ink-soft">
:{service.port}
</span>
<p className="min-w-0 flex-1 truncate text-sm font-semibold text-ink">{service.name}</p>
<p className="hidden max-w-44 truncate text-[11px] text-ink-muted sm:block">{service.notes || "registered service"}</p>
<Actions service={service} onEdit={onEdit} onDelete={onDelete} onProxy={fireProxy} proxyBusy={busy} />
</motion.div>
);
}
return (
<motion.div
{...entrance}
className="group/service flex flex-col rounded-xl border border-line bg-surface-2/60 p-3.5 transition hover:border-line-strong hover:bg-surface-2"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Checkbox service={service} selected={selected} onToggleSelect={onToggleSelect} />
<span className="mono rounded-lg bg-surface-3/80 px-2.5 py-1 text-xs font-semibold text-ink-soft">:{service.port}</span>
</div>
<div className="flex items-center gap-1">
<button
aria-label={`Edit ${service.name}`}
className="rounded-md p-1.5 text-ink-muted transition hover:bg-surface-3 hover:text-ink"
onClick={onEdit}
type="button"
>
<Pencil size={13} />
</button>
<button
aria-label={`Delete ${service.name}`}
className="rounded-md p-1.5 text-ink-muted transition hover:bg-rose-500/10 hover:text-rose-400"
onClick={onDelete}
type="button"
>
<Trash2 size={13} />
</button>
</div>
</div>
<p className="mt-2.5 truncate text-sm font-semibold text-ink">{service.name}</p>
<p className="mt-0.5 truncate text-[11px] text-ink-muted">{service.notes || "registered service"}</p>
<Button variant="primary" size="sm" icon={Play} onClick={fireProxy} loading={busy} className="mt-3 w-full">
Proxy
</Button>
</motion.div>
);
}

View File

@ -0,0 +1,22 @@
import { motion } from "framer-motion";
export default function StatCard({ icon: Icon, label, value, gradient, index = 0 }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04, duration: 0.2 }}
className="card group relative overflow-hidden p-4"
>
<div className="flex items-center gap-3.5">
<span className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br ${gradient}`}>
<Icon size={20} />
</span>
<div className="min-w-0">
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-ink-muted">{label}</p>
<p className="mt-0.5 text-2xl font-bold leading-none text-ink">{value}</p>
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,22 @@
import { Globe2, Route, Server, Zap } from "lucide-react";
import StatCard from "./StatCard";
export default function StatsBar({ servers, proxyCalls }) {
const services = servers.reduce((sum, server) => sum + server.services.length, 0);
const ports = new Set(servers.flatMap((server) => server.services.map((service) => service.port))).size;
const stats = [
{ icon: Server, label: "Servers", value: servers.length, gradient: "from-cyan-500 to-sky-600" },
{ icon: Globe2, label: "Services", value: services, gradient: "from-indigo-500 to-violet-600" },
{ icon: Route, label: "Unique Ports", value: ports, gradient: "from-fuchsia-500 to-pink-600" },
{ icon: Zap, label: "Proxy Calls", value: proxyCalls, gradient: "from-amber-500 to-orange-600" },
];
return (
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{stats.map((stat, index) => (
<StatCard key={stat.label} {...stat} index={index} />
))}
</section>
);
}

View File

@ -0,0 +1,131 @@
import { useEffect, useState } from "react";
import { Server } from "lucide-react";
import Modal from "../ui/Modal";
import Field from "../ui/Field";
import Button from "../ui/Button";
import { emptyServer } from "../../lib/constants";
export default function ServerFormModal({ open, onClose, initial, onSave }) {
const [form, setForm] = useState(emptyServer);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const isEditing = Boolean(initial?.id);
useEffect(() => {
if (open) {
setForm(
initial
? {
name: initial.name || "",
ipAddress: initial.ipAddress || "",
description: initial.description || "",
proxyPort: initial.proxyPort ?? "",
proxyPath: initial.proxyPath || "",
}
: emptyServer,
);
setError("");
}
}, [open, initial]);
async function submit(event) {
event.preventDefault();
setSaving(true);
setError("");
try {
await onSave({
...form,
proxyPort: form.proxyPort === "" ? null : Number(form.proxyPort),
proxyPath: form.proxyPath.trim() === "" ? null : form.proxyPath.trim(),
});
onClose();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title={isEditing ? "Edit Server" : "Add Server"}
subtitle={isEditing ? `Editing ${initial.ipAddress}` : "Register a new internal server."}
icon={Server}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" form="server-form" loading={saving}>
{isEditing ? "Save Changes" : "Add Server"}
</Button>
</>
}
>
<form id="server-form" className="grid gap-4" onSubmit={submit}>
<Field label="Server name">
<input
className="input"
required
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="e.g. Billing Server"
/>
</Field>
<Field label="IP address" hint="or hostname">
<input
className="input mono"
required
value={form.ipAddress}
onChange={(event) => setForm({ ...form, ipAddress: event.target.value })}
placeholder="192.168.1.50"
/>
</Field>
<Field label="Description" hint="optional">
<textarea
className="input min-h-20"
value={form.description}
onChange={(event) => setForm({ ...form, description: event.target.value })}
placeholder="What is this server responsible for?"
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Gateway port" hint="optional — common proxy port">
<input
className="input mono"
min="1"
max="65535"
type="number"
value={form.proxyPort}
onChange={(event) => setForm({ ...form, proxyPort: event.target.value })}
placeholder="9999"
/>
</Field>
<Field label="Gateway path" hint="optional — common proxy endpoint">
<input
className="input mono"
value={form.proxyPath}
onChange={(event) => setForm({ ...form, proxyPath: event.target.value })}
placeholder="/api/nginx/app"
/>
</Field>
</div>
{form.proxyPort && (
<p className="rounded-lg border border-line bg-surface-2 px-3 py-2 text-[11px] text-ink-muted">
All services on this server will proxy to{" "}
<span className="mono text-ink-soft">
http://{form.ipAddress || ""}:{form.proxyPort}
{form.proxyPath || "/api/nginx/app"}
</span>{" "}
with the service domain &amp; port in the body.
</p>
)}
{error && <p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs font-medium text-rose-400">{error}</p>}
</form>
</Modal>
);
}

View File

@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { Globe2 } from "lucide-react";
import Modal from "../ui/Modal";
import Field from "../ui/Field";
import Button from "../ui/Button";
const emptyService = { serverId: "", name: "", port: "" };
export default function ServiceFormModal({ open, onClose, initial, servers, onSave }) {
const [form, setForm] = useState(emptyService);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const isEditing = Boolean(initial?.id);
useEffect(() => {
if (open) {
setForm(
initial
? { serverId: initial.serverId || "", name: initial.name || "", port: initial.port ?? "" }
: { ...emptyService, serverId: servers[0]?.id || "" },
);
setError("");
}
}, [open, initial, servers]);
async function submit(event) {
event.preventDefault();
setSaving(true);
setError("");
const port = Number(form.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
setError("Port must be a number between 1 and 65535.");
setSaving(false);
return;
}
try {
await onSave({ serverId: form.serverId, name: form.name.trim(), port });
onClose();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title={isEditing ? "Edit Service" : "Add Service"}
subtitle={isEditing ? `Editing ${initial.name}` : "A service is just a domain and a port."}
icon={Globe2}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" form="service-form" loading={saving}>
{isEditing ? "Save Changes" : "Add Service"}
</Button>
</>
}
>
<form id="service-form" className="grid gap-4" onSubmit={submit}>
<Field label="Server">
<select
className="input"
required
disabled={isEditing}
value={form.serverId}
onChange={(event) => setForm({ ...form, serverId: event.target.value })}
>
{servers.length === 0 && <option value="">No servers yet add one first</option>}
{servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ipAddress})
</option>
))}
</select>
</Field>
<Field label="Service name" hint="the domain">
<input
className="input mono"
required
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="gitpipeline.metatronhost.com"
/>
</Field>
<Field label="Port">
<input
className="input mono"
required
min="1"
max="65535"
type="number"
value={form.port}
onChange={(event) => setForm({ ...form, port: event.target.value })}
placeholder="7777"
/>
</Field>
{error && <p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs font-medium text-rose-400">{error}</p>}
</form>
</Modal>
);
}

View File

@ -0,0 +1,78 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
const ToastContext = createContext(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error("useToast must be used inside <ToastProvider>.");
return context;
}
const ICONS = {
success: { icon: CheckCircle2, className: "text-emerald-400" },
error: { icon: XCircle, className: "text-rose-400" },
info: { icon: Info, className: "text-cyan-400" },
warning: { icon: AlertTriangle, className: "text-amber-400" },
};
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const idRef = useRef(0);
const dismiss = useCallback((id) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const notify = useCallback(
(type, title, description) => {
const id = ++idRef.current;
setToasts((current) => [...current, { id, type, title, description }]);
window.setTimeout(() => dismiss(id), type === "error" ? 6000 : 4000);
},
[dismiss],
);
const value = useMemo(() => ({ notify }), [notify]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex w-full max-w-sm flex-col gap-2.5">
<AnimatePresence>
{toasts.map((toast) => {
const meta = ICONS[toast.type] || ICONS.info;
const Icon = meta.icon;
return (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 32, scale: 0.96 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 24, scale: 0.96 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="pointer-events-auto flex items-start gap-3 rounded-xl border border-line bg-surface p-3.5 shadow-xl shadow-black/30"
>
<Icon size={18} className={`mt-0.5 shrink-0 ${meta.className}`} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-ink">{toast.title}</p>
{toast.description && (
<p className="mt-0.5 break-words text-xs leading-relaxed text-ink-soft">{toast.description}</p>
)}
</div>
<button
aria-label="Dismiss"
className="rounded-md p-1 text-ink-muted transition hover:bg-surface-2 hover:text-ink"
onClick={() => dismiss(toast.id)}
>
<X size={14} />
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}

View File

@ -0,0 +1,7 @@
export default function Badge({ children, className = "" }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-semibold ${className}`}>
{children}
</span>
);
}

View File

@ -0,0 +1,42 @@
import { Loader2 } from "lucide-react";
const VARIANTS = {
primary:
"bg-gradient-to-r from-cyan-500 to-indigo-500 text-white shadow-lg shadow-cyan-500/20 hover:from-cyan-400 hover:to-indigo-400",
secondary: "border border-line bg-surface-2 text-ink hover:border-line-strong hover:bg-surface-3",
ghost: "text-ink-soft hover:bg-surface-2 hover:text-ink",
danger: "bg-rose-500 text-white shadow-lg shadow-rose-500/20 hover:bg-rose-400",
outline: "border border-line-strong text-ink hover:border-accent hover:text-accent",
amber: "bg-amber-500 text-white shadow-lg shadow-amber-500/20 hover:bg-amber-400",
};
const SIZES = {
sm: "gap-1.5 px-2.5 py-1.5 text-xs",
md: "gap-2 px-3.5 py-2 text-sm",
lg: "gap-2 px-4 py-2.5 text-sm",
};
export default function Button({
variant = "secondary",
size = "md",
icon: Icon,
loading = false,
children,
className = "",
...props
}) {
return (
<button
className={`inline-flex items-center justify-center rounded-lg font-semibold transition-all active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
disabled={loading || props.disabled}
{...props}
>
{loading ? (
<Loader2 size={size === "sm" ? 14 : 16} className="animate-spin" />
) : Icon ? (
<Icon size={size === "sm" ? 14 : 16} />
) : null}
{children}
</button>
);
}

View File

@ -0,0 +1,28 @@
import { AlertTriangle } from "lucide-react";
import Modal from "./Modal";
import Button from "./Button";
export default function ConfirmDialog({ open, onClose, title, description, confirmLabel = "Delete", loading = false, onConfirm }) {
return (
<Modal
open={open}
onClose={onClose}
title={title}
subtitle="This action cannot be undone."
icon={AlertTriangle}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</>
}
>
<p className="text-sm leading-relaxed text-ink-soft">{description}</p>
</Modal>
);
}

View File

@ -0,0 +1,24 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { copyText } from "../../lib/format";
export default function CopyButton({ value, className = "" }) {
const [copied, setCopied] = useState(false);
async function copy() {
await copyText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
}
return (
<button
aria-label="Copy to clipboard"
className={`rounded-md p-1 text-ink-muted transition hover:bg-surface-2 hover:text-ink ${className}`}
onClick={copy}
type="button"
>
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
</button>
);
}

View File

@ -0,0 +1,16 @@
export default function EmptyState({ icon: Icon, title, description, children }) {
return (
<div className="flex flex-col items-center justify-center gap-4 border border-dashed border-line-strong bg-surface/50 px-6 py-14 text-center">
{Icon && (
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500/15 to-indigo-500/15 text-accent">
<Icon size={26} />
</span>
)}
<div>
<h3 className="text-base font-bold text-ink">{title}</h3>
{description && <p className="mx-auto mt-1 max-w-md text-sm leading-relaxed text-ink-muted">{description}</p>}
</div>
{children && <div className="flex flex-wrap justify-center gap-3">{children}</div>}
</div>
);
}

View File

@ -0,0 +1,12 @@
export default function Field({ label, hint, error, children }) {
return (
<label className="grid gap-1.5">
<span className="text-xs font-semibold text-ink-soft">
{label}
{hint && <span className="ml-1 font-normal text-ink-muted">{hint}</span>}
</span>
{children}
{error && <span className="text-xs font-medium text-rose-400">{error}</span>}
</label>
);
}

View File

@ -0,0 +1,73 @@
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
const WIDTHS = {
sm: "max-w-md",
md: "max-w-xl",
lg: "max-w-3xl",
xl: "max-w-5xl",
};
export default function Modal({ open, onClose, title, subtitle, icon: Icon, size = "md", children, footer }) {
useEffect(() => {
if (!open) return;
function onKey(event) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<motion.div
role="dialog"
aria-modal="true"
aria-label={title}
className={`relative w-full ${WIDTHS[size]} overflow-hidden rounded-2xl border border-line bg-surface shadow-2xl shadow-black/50`}
initial={{ opacity: 0, y: 18, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
>
<header className="flex items-start justify-between gap-4 border-b border-line p-5">
<div className="flex items-center gap-3">
{Icon && (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-500/15 to-indigo-500/15 text-accent">
<Icon size={20} />
</span>
)}
<div>
<h2 className="text-base font-bold text-ink">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-ink-muted">{subtitle}</p>}
</div>
</div>
<button
aria-label="Close"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-surface-2 hover:text-ink"
onClick={onClose}
>
<X size={18} />
</button>
</header>
<div className="max-h-[68vh] overflow-y-auto p-5">{children}</div>
{footer && <footer className="flex flex-wrap justify-end gap-3 border-t border-line bg-surface-2/50 p-4">{footer}</footer>}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,3 @@
export default function Skeleton({ className = "" }) {
return <div className={`animate-pulse rounded-lg bg-surface-3 ${className}`} />;
}

89
src/hooks/useServers.js Normal file
View File

@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../lib/api";
export default function useServers() {
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await api.get("/servers");
setServers(data);
setLoadError("");
return data;
} catch (error) {
setLoadError(error.message);
throw error;
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh().catch(() => {});
}, [refresh]);
async function createServer(data) {
const server = await api.post("/servers", data);
await refresh();
return server;
}
async function updateServer(id, data) {
const server = await api.patch(`/servers/${id}`, data);
await refresh();
return server;
}
async function deleteServer(id) {
await api.del(`/servers/${id}`);
await refresh();
}
async function createService(data) {
const service = await api.post("/services", data);
await refresh();
return service;
}
async function updateService(id, data) {
const service = await api.patch(`/services/${id}`, data);
await refresh();
return service;
}
async function deleteService(id) {
await api.del(`/services/${id}`);
await refresh();
}
function previewImport(payload) {
return api.post("/imports/preview", payload);
}
function applyImport(payload, mode) {
return api.post("/imports/apply", { payload, mode });
}
function proxyRequest(serviceId, body) {
return api.post(`/services/${serviceId}/reverse-proxy`, { body });
}
return {
servers,
loading,
loadError,
refresh,
createServer,
updateServer,
deleteServer,
createService,
updateService,
deleteService,
previewImport,
applyImport,
proxyRequest,
};
}

View File

@ -1,14 +1,85 @@
@import "tailwindcss";
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-ink: var(--ink);
--color-ink-soft: var(--ink-soft);
--color-ink-muted: var(--ink-muted);
--color-accent: var(--accent);
--color-accent-2: var(--accent-2);
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
:root {
--bg: #070b15;
--surface: #0d1424;
--surface-2: #111a2f;
--surface-3: #182442;
--line: #1b2745;
--line-strong: #2b3a63;
--ink: #e9effc;
--ink-soft: #a6b4d1;
--ink-muted: #5d6b8c;
--accent: #22d3ee;
--accent-2: #818cf8;
color-scheme: dark;
}
html.light {
--bg: #f3f6fb;
--surface: #ffffff;
--surface-2: #eef2f9;
--surface-3: #e2e9f4;
--line: #e3e9f2;
--line-strong: #c9d4e4;
--ink: #101828;
--ink-soft: #44506b;
--ink-muted: #8b96ad;
--accent: #0891b2;
--accent-2: #6366f1;
color-scheme: light;
}
html {
scrollbar-color: var(--line-strong) transparent;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #f5f7fb;
color: #16202a;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background-color: var(--bg);
color: var(--ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
background-image:
radial-gradient(52rem 32rem at 12% -8%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 60%),
radial-gradient(48rem 30rem at 108% 4%, color-mix(in srgb, var(--accent-2) 9%, transparent), transparent 60%),
radial-gradient(60rem 36rem at 50% 120%, color-mix(in srgb, var(--accent-2) 7%, transparent), transparent 65%);
}
::selection {
background: color-mix(in srgb, var(--accent) 30%, transparent);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
button,
@ -25,3 +96,46 @@ button {
textarea {
resize: vertical;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.input {
width: 100%;
border-radius: 0.65rem;
border: 1px solid var(--line);
background-color: var(--surface-2);
padding: 0.55rem 0.8rem;
font-size: 0.875rem;
color: var(--ink);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.input::placeholder {
color: var(--ink-muted);
}
.input:hover {
border-color: var(--line-strong);
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.input.invalid {
border-color: #fb7185;
}
.input.invalid:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, #fb7185 18%, transparent);
}
.card {
border-radius: 1rem;
border: 1px solid var(--line);
background: var(--surface);
}
.mono {
font-family: var(--font-mono);
}

44
src/lib/api.js Normal file
View File

@ -0,0 +1,44 @@
export const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api";
async function request(path, { method = "GET", body, headers } = {}) {
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: { "content-type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 204) return null;
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok) {
const error = new Error(data?.message || `Request failed (${response.status}).`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: "POST", body }),
patch: (path, body) => request(path, { method: "PATCH", body }),
del: (path) => request(path, { method: "DELETE" }),
};
export const sampleJson = JSON.stringify(
[
{ domain: "gitpipeline.metatronhost.com", ports: [7777], is_backup: false },
{ domain: "rayaari-email.metatronhost.com", ports: [13000], is_backup: false },
],
null,
2,
);

21
src/lib/constants.js Normal file
View File

@ -0,0 +1,21 @@
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export const METHOD_STYLES = {
GET: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
POST: "border-cyan-500/30 bg-cyan-500/10 text-cyan-400",
PUT: "border-amber-500/30 bg-amber-500/10 text-amber-400",
PATCH: "border-violet-500/30 bg-violet-500/10 text-violet-400",
DELETE: "border-rose-500/30 bg-rose-500/10 text-rose-400",
};
export const emptyServer = { name: "", ipAddress: "", description: "", proxyPort: "", proxyPath: "" };
export const emptyService = {
serverId: "",
name: "",
port: "",
endpoint: "",
method: "POST",
requestTemplate: "{}",
notes: "",
};

35
src/lib/format.js Normal file
View File

@ -0,0 +1,35 @@
export function parseJson(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return null;
return JSON.parse(trimmed);
}
export function formatDuration(ms) {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function formatBytes(bytes) {
if (bytes === undefined || bytes === null) return "—";
const units = ["B", "KB", "MB", "GB"];
let value = Number(bytes);
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function copyText(value) {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(value);
}
const textarea = document.createElement("textarea");
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return Promise.resolve();
}