Modular UI rebuild, bulk proxy/delete, search fixes
This commit is contained in:
parent
49013eba0d
commit
3947249b33
10
index.html
10
index.html
@ -4,7 +4,15 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
812
src/App.jsx
812
src/App.jsx
@ -1,405 +1,505 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import {
|
import { Plus, Play, RefreshCcw, Search, Server, ShieldCheck, Trash2 } from "lucide-react";
|
||||||
AlertTriangle,
|
import Header from "./components/Header";
|
||||||
Check,
|
import StatsBar from "./components/StatsBar";
|
||||||
Database,
|
import ServerCard from "./components/ServerCard";
|
||||||
FileJson,
|
import ServerDetail from "./components/ServerDetail";
|
||||||
Globe2,
|
import BulkStatusPage from "./components/BulkStatusPage";
|
||||||
Loader2,
|
import ServerFormModal from "./components/modals/ServerFormModal";
|
||||||
Plus,
|
import ServiceFormModal from "./components/modals/ServiceFormModal";
|
||||||
RefreshCcw,
|
import ConfirmDialog from "./components/ui/ConfirmDialog";
|
||||||
Route,
|
import EmptyState from "./components/ui/EmptyState";
|
||||||
Server,
|
import Skeleton from "./components/ui/Skeleton";
|
||||||
ShieldCheck,
|
import Button from "./components/ui/Button";
|
||||||
Zap,
|
import { ToastProvider, useToast } from "./components/toast/ToastProvider";
|
||||||
} from "lucide-react";
|
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: "" };
|
function AppShell() {
|
||||||
const emptyService = {
|
const { notify } = useToast();
|
||||||
serverId: "",
|
const data = useServers();
|
||||||
name: "",
|
const { servers, loading, loadError, refresh } = data;
|
||||||
port: "",
|
|
||||||
endpoint: "",
|
|
||||||
method: "POST",
|
|
||||||
requestTemplate: "{}",
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const sampleJson = JSON.stringify(
|
const [query, setQuery] = useState("");
|
||||||
{
|
const [sort, setSort] = useState("name");
|
||||||
servers: [
|
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark");
|
||||||
{
|
const [proxyCalls, setProxyCalls] = useState(0);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
const [pageServerId, setPageServerId] = useState(null);
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const [bulkRun, setBulkRun] = useState(null);
|
||||||
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
const [proxyBusyId, setProxyBusyId] = useState(null);
|
||||||
...options,
|
const [serviceView, setServiceView] = useState(() => localStorage.getItem(VIEW_KEY) || "grid");
|
||||||
});
|
const [serverModal, setServerModal] = useState(null);
|
||||||
const data = await response.json().catch(() => null);
|
const [serviceModal, setServiceModal] = useState(null);
|
||||||
if (!response.ok) {
|
const [confirm, setConfirm] = useState(null);
|
||||||
const error = new Error(data?.message || "Request failed.");
|
|
||||||
error.data = data;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonField(value) {
|
const activeServer = pageServerId ? servers.find((server) => server.id === pageServerId) || null : null;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadServers();
|
document.documentElement.classList.toggle("light", theme === "light");
|
||||||
}, []);
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
async function createServer(event) {
|
useEffect(() => {
|
||||||
event.preventDefault();
|
localStorage.setItem(VIEW_KEY, serviceView);
|
||||||
setError("");
|
}, [serviceView]);
|
||||||
setMessage("");
|
|
||||||
try {
|
useEffect(() => {
|
||||||
await api("/servers", { method: "POST", body: JSON.stringify(serverForm) });
|
if (activeServer) window.scrollTo({ top: 0 });
|
||||||
setServerForm(emptyServer);
|
}, [activeServer]);
|
||||||
setMessage("Server added.");
|
|
||||||
await loadServers();
|
useEffect(() => {
|
||||||
} catch (err) {
|
if (pageServerId && !activeServer && !loading) setPageServerId(null);
|
||||||
setError(err.message);
|
}, [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) {
|
async function handleSaveServer(form) {
|
||||||
event.preventDefault();
|
if (serverModal?.initial) {
|
||||||
setError("");
|
const saved = await data.updateServer(serverModal.initial.id, form);
|
||||||
setMessage("");
|
notify("success", "Server updated", `${saved.name} (${saved.ipAddress})`);
|
||||||
try {
|
} else {
|
||||||
await api("/services", {
|
const saved = await data.createServer(form);
|
||||||
method: "POST",
|
notify("success", "Server added", `${saved.name} (${saved.ipAddress})`);
|
||||||
body: JSON.stringify({
|
}
|
||||||
...serviceForm,
|
setServerModal(null);
|
||||||
requestTemplate: parseJsonField(serviceForm.requestTemplate),
|
}
|
||||||
}),
|
|
||||||
|
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,
|
function requestDeleteService(service) {
|
||||||
method: "POST",
|
setConfirm({
|
||||||
requestTemplate: "{}",
|
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.");
|
setBulkRun({ serverId: activeServer?.id, tasks });
|
||||||
await loadServers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewImport() {
|
const publish = () => setBulkRun((current) => ({ ...current, tasks: [...tasks] }));
|
||||||
setError("");
|
|
||||||
setMessage("");
|
const outcomes = await Promise.all(
|
||||||
|
tasks.map(async (task) => {
|
||||||
|
task.status = "running";
|
||||||
|
publish();
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(jsonInput);
|
const result = await data.proxyRequest(task.service.id, undefined);
|
||||||
const data = await api("/imports/preview", { method: "POST", body: JSON.stringify(payload) });
|
if (result.ok) {
|
||||||
setPreview(data);
|
task.status = "success";
|
||||||
setMessage(
|
task.statusCode = result.status;
|
||||||
data.conflicts.length
|
task.detail = result.target || "";
|
||||||
? "Conflicts found. Review and confirm before overwrite."
|
return true;
|
||||||
: "Import is ready. Exact duplicates will be skipped.",
|
}
|
||||||
|
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") {
|
const okCount = outcomes.filter(Boolean).length;
|
||||||
setError("");
|
setProxyCalls((count) => count + okCount);
|
||||||
setMessage("");
|
|
||||||
try {
|
const failedCount = outcomes.length - okCount;
|
||||||
const payload = JSON.parse(jsonInput);
|
notify("success", "Bulk proxy complete", `${okCount} of ${outcomes.length} succeeded.`);
|
||||||
const data = await api("/imports/apply", {
|
if (failedCount > 0) {
|
||||||
method: "POST",
|
notify(
|
||||||
body: JSON.stringify({ payload, mode }),
|
"error",
|
||||||
});
|
`${failedCount} failed`,
|
||||||
setPreview(null);
|
tasks
|
||||||
setMessage(
|
.filter((task) => task.status === "error")
|
||||||
`Import applied: ${data.applied.serversCreated} servers and ${data.applied.servicesCreated} services created.`,
|
.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) {
|
async function handleBulkDelete(services) {
|
||||||
setError("");
|
const results = await Promise.allSettled(services.map((service) => data.deleteService(service.id)));
|
||||||
setProxyResult(null);
|
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 {
|
try {
|
||||||
const data = await api(`/services/${serviceId}/reverse-proxy`, {
|
await handleProxy(service);
|
||||||
method: "POST",
|
} finally {
|
||||||
body: JSON.stringify({ body }),
|
setProxyBusyId(null);
|
||||||
});
|
}
|
||||||
setProxyResult(data);
|
}
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
notify("info", "Data refreshed", `${servers.length} servers loaded.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
notify("error", "Refresh failed", err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showSkeletons = loading && servers.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f5f7fb]">
|
<div className="min-h-screen">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<Header
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
|
query={query}
|
||||||
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
|
onQueryChange={setQuery}
|
||||||
<div className="flex items-center gap-3">
|
theme={theme}
|
||||||
<span className="flex h-11 w-11 items-center justify-center bg-slate-950 text-white">
|
onToggleTheme={() => setTheme((current) => (current === "dark" ? "light" : "dark"))}
|
||||||
<Route size={22} />
|
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>
|
</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>
|
||||||
|
<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
|
<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"
|
type="button"
|
||||||
onClick={loadServers}
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="animate-spin" size={18} /> : <RefreshCcw size={18} />}
|
<Trash2 size={14} />
|
||||||
Refresh
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</select>
|
||||||
</Field>
|
</label>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="border border-slate-200 bg-white p-4">
|
<AnimatePresence mode="popLayout">
|
||||||
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-slate-950">
|
{showSkeletons && (
|
||||||
<FileJson size={18} /> JSON Import
|
<div className="grid gap-4" key="skeletons">
|
||||||
</h2>
|
<Skeleton className="h-36 w-full" />
|
||||||
<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)} />
|
<Skeleton className="h-36 w-full" />
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
<Skeleton className="h-36 w-full" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="grid content-start gap-6">
|
{!showSkeletons && loadError && (
|
||||||
<AnimatePresence>
|
<EmptyState
|
||||||
{(message || error) && (
|
key="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 }}>
|
icon={ShieldCheck}
|
||||||
{error || message}
|
title="Can't reach the API"
|
||||||
</motion.div>
|
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>
|
</AnimatePresence>
|
||||||
|
|
||||||
{preview && (
|
<footer className="flex flex-wrap items-center justify-between gap-2 border-t border-line pb-4 pt-6 text-[11px] text-ink-muted">
|
||||||
<section className="border border-slate-200 bg-white p-4">
|
<p>MTC Reverse Proxy Manager</p>
|
||||||
<div className="flex flex-col justify-between gap-3 md:flex-row md:items-center">
|
<p className="mono">API {API_BASE}</p>
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-slate-950">
|
</footer>
|
||||||
{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>
|
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<AppShell />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
157
src/components/BulkStatusPage.jsx
Normal file
157
src/components/BulkStatusPage.jsx
Normal 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
88
src/components/Header.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/ImportPanel.jsx
Normal file
282
src/components/ImportPanel.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/ServerCard.jsx
Normal file
91
src/components/ServerCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
src/components/ServerDetail.jsx
Normal file
293
src/components/ServerDetail.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/ServiceCard.jsx
Normal file
124
src/components/ServiceCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/StatCard.jsx
Normal file
22
src/components/StatCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/StatsBar.jsx
Normal file
22
src/components/StatsBar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/modals/ServerFormModal.jsx
Normal file
131
src/components/modals/ServerFormModal.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/modals/ServiceFormModal.jsx
Normal file
108
src/components/modals/ServiceFormModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/toast/ToastProvider.jsx
Normal file
78
src/components/toast/ToastProvider.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/ui/Badge.jsx
Normal file
7
src/components/ui/Badge.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/ui/Button.jsx
Normal file
42
src/components/ui/Button.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/ui/ConfirmDialog.jsx
Normal file
28
src/components/ui/ConfirmDialog.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/ui/CopyButton.jsx
Normal file
24
src/components/ui/CopyButton.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/ui/EmptyState.jsx
Normal file
16
src/components/ui/EmptyState.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/ui/Field.jsx
Normal file
12
src/components/ui/Field.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/ui/Modal.jsx
Normal file
73
src/components/ui/Modal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/ui/Skeleton.jsx
Normal file
3
src/components/ui/Skeleton.jsx
Normal 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
89
src/hooks/useServers.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
124
src/index.css
124
src/index.css
@ -1,14 +1,85 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f7fb;
|
background-color: var(--bg);
|
||||||
color: #16202a;
|
color: var(--ink);
|
||||||
font-family:
|
font-family: var(--font-sans);
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
-webkit-font-smoothing: antialiased;
|
||||||
sans-serif;
|
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,
|
button,
|
||||||
@ -25,3 +96,46 @@ button {
|
|||||||
textarea {
|
textarea {
|
||||||
resize: vertical;
|
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
44
src/lib/api.js
Normal 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
21
src/lib/constants.js
Normal 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
35
src/lib/format.js
Normal 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();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user