506 lines
18 KiB
JavaScript
506 lines
18 KiB
JavaScript
import { useEffect, useMemo, useState } from "react";
|
|
import { AnimatePresence } from "framer-motion";
|
|
import { Plus, Play, RefreshCcw, Search, Server, ShieldCheck, Trash2 } from "lucide-react";
|
|
import Header from "./components/Header";
|
|
import StatsBar from "./components/StatsBar";
|
|
import ServerCard from "./components/ServerCard";
|
|
import ServerDetail from "./components/ServerDetail";
|
|
import BulkStatusPage from "./components/BulkStatusPage";
|
|
import ServerFormModal from "./components/modals/ServerFormModal";
|
|
import ServiceFormModal from "./components/modals/ServiceFormModal";
|
|
import ConfirmDialog from "./components/ui/ConfirmDialog";
|
|
import EmptyState from "./components/ui/EmptyState";
|
|
import Skeleton from "./components/ui/Skeleton";
|
|
import Button from "./components/ui/Button";
|
|
import { ToastProvider, useToast } from "./components/toast/ToastProvider";
|
|
import useServers from "./hooks/useServers";
|
|
import { API_BASE } from "./lib/api";
|
|
|
|
const THEME_KEY = "mtc-theme";
|
|
const VIEW_KEY = "mtc-service-view";
|
|
|
|
function AppShell() {
|
|
const { notify } = useToast();
|
|
const data = useServers();
|
|
const { servers, loading, loadError, refresh } = data;
|
|
|
|
const [query, setQuery] = useState("");
|
|
const [sort, setSort] = useState("name");
|
|
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark");
|
|
const [proxyCalls, setProxyCalls] = useState(0);
|
|
|
|
const [pageServerId, setPageServerId] = useState(null);
|
|
const [bulkRun, setBulkRun] = useState(null);
|
|
const [proxyBusyId, setProxyBusyId] = useState(null);
|
|
const [serviceView, setServiceView] = useState(() => localStorage.getItem(VIEW_KEY) || "grid");
|
|
const [serverModal, setServerModal] = useState(null);
|
|
const [serviceModal, setServiceModal] = useState(null);
|
|
const [confirm, setConfirm] = useState(null);
|
|
|
|
const activeServer = pageServerId ? servers.find((server) => server.id === pageServerId) || null : null;
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.toggle("light", theme === "light");
|
|
localStorage.setItem(THEME_KEY, theme);
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(VIEW_KEY, serviceView);
|
|
}, [serviceView]);
|
|
|
|
useEffect(() => {
|
|
if (activeServer) window.scrollTo({ top: 0 });
|
|
}, [activeServer]);
|
|
|
|
useEffect(() => {
|
|
if (pageServerId && !activeServer && !loading) setPageServerId(null);
|
|
}, [pageServerId, activeServer, loading]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const needle = query.trim().toLowerCase();
|
|
let list = servers;
|
|
if (needle) {
|
|
list = servers
|
|
.map((server) => {
|
|
const services = server.services.filter(
|
|
(service) =>
|
|
service.name.toLowerCase().includes(needle) ||
|
|
service.endpoint.toLowerCase().includes(needle) ||
|
|
String(service.port).includes(needle),
|
|
);
|
|
const matches =
|
|
services.length > 0 ||
|
|
server.name.toLowerCase().includes(needle) ||
|
|
server.ipAddress.toLowerCase().includes(needle) ||
|
|
(server.description || "").toLowerCase().includes(needle);
|
|
return matches ? { ...server, services } : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
return [...list].sort((a, b) =>
|
|
sort === "count" ? b.services.length - a.services.length : a.name.localeCompare(b.name),
|
|
);
|
|
}, [servers, query, sort]);
|
|
|
|
const serviceMatches = useMemo(() => {
|
|
const needle = query.trim().toLowerCase();
|
|
if (!needle) return [];
|
|
const matches = [];
|
|
for (const server of servers) {
|
|
for (const service of server.services) {
|
|
if (
|
|
service.name.toLowerCase().includes(needle) ||
|
|
service.endpoint.toLowerCase().includes(needle) ||
|
|
String(service.port).includes(needle) ||
|
|
(service.notes || "").toLowerCase().includes(needle)
|
|
) {
|
|
matches.push({ service, server });
|
|
}
|
|
}
|
|
}
|
|
return matches.sort(
|
|
(a, b) => a.service.name.localeCompare(b.service.name) || a.service.port - b.service.port,
|
|
);
|
|
}, [servers, query]);
|
|
|
|
async function handleSaveServer(form) {
|
|
if (serverModal?.initial) {
|
|
const saved = await data.updateServer(serverModal.initial.id, form);
|
|
notify("success", "Server updated", `${saved.name} (${saved.ipAddress})`);
|
|
} else {
|
|
const saved = await data.createServer(form);
|
|
notify("success", "Server added", `${saved.name} (${saved.ipAddress})`);
|
|
}
|
|
setServerModal(null);
|
|
}
|
|
|
|
async function handleSaveService(form) {
|
|
if (serviceModal?.service) {
|
|
const saved = await data.updateService(serviceModal.service.id, form);
|
|
notify("success", "Service updated", `${saved.name} ${saved.endpoint}`);
|
|
} else {
|
|
const saved = await data.createService(form);
|
|
notify("success", "Service added", `${saved.name} ${saved.endpoint}`);
|
|
}
|
|
setServiceModal(null);
|
|
}
|
|
|
|
function requestDeleteServer(server) {
|
|
setConfirm({
|
|
title: "Delete Server",
|
|
description: `"${server.name}" (${server.ipAddress}) and all of its ${server.services.length} service${server.services.length === 1 ? "" : "s"} will be permanently removed.`,
|
|
confirmLabel: "Delete Server",
|
|
action: async () => {
|
|
await data.deleteServer(server.id);
|
|
notify("success", "Server deleted", `${server.name} was removed.`);
|
|
},
|
|
});
|
|
}
|
|
|
|
function requestDeleteService(service) {
|
|
setConfirm({
|
|
title: "Delete Service",
|
|
description: `"${service.name}" (${service.method} ${service.endpoint}) will be permanently removed.`,
|
|
confirmLabel: "Delete Service",
|
|
action: async () => {
|
|
await data.deleteService(service.id);
|
|
notify("success", "Service deleted", `${service.name} was removed.`);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleApplyImport(payload, mode) {
|
|
const result = await data.applyImport(payload, mode);
|
|
await refresh();
|
|
notify(
|
|
"success",
|
|
mode === "overwrite" ? "Import applied with overwrite" : "Import applied",
|
|
`${result.applied.servicesCreated} services created, ${result.applied.duplicatesSkipped} duplicates skipped.`,
|
|
);
|
|
}
|
|
|
|
async function handleProxy(service) {
|
|
try {
|
|
const result = await data.proxyRequest(service.id, undefined);
|
|
setProxyCalls((count) => count + (result.ok ? 1 : 0));
|
|
if (result.ok) {
|
|
notify("success", "Proxy OK", `${service.name}:${service.port} → ${result.status}`);
|
|
} else {
|
|
notify("error", "Proxy failed", `${service.name}:${service.port} returned ${result.status}.`);
|
|
}
|
|
} catch (err) {
|
|
notify("error", "Proxy failed", `${service.name}:${service.port} — ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function handleBulkProxy(services) {
|
|
const tasks = services.map((service) => ({
|
|
id: service.id,
|
|
service,
|
|
status: "pending",
|
|
statusCode: null,
|
|
detail: "",
|
|
}));
|
|
setBulkRun({ serverId: activeServer?.id, tasks });
|
|
|
|
const publish = () => setBulkRun((current) => ({ ...current, tasks: [...tasks] }));
|
|
|
|
const outcomes = await Promise.all(
|
|
tasks.map(async (task) => {
|
|
task.status = "running";
|
|
publish();
|
|
try {
|
|
const result = await data.proxyRequest(task.service.id, undefined);
|
|
if (result.ok) {
|
|
task.status = "success";
|
|
task.statusCode = result.status;
|
|
task.detail = result.target || "";
|
|
return true;
|
|
}
|
|
task.status = "error";
|
|
task.statusCode = result.status;
|
|
task.detail = `HTTP ${result.status}`;
|
|
return false;
|
|
} catch (err) {
|
|
task.status = "error";
|
|
task.detail = err.message;
|
|
return false;
|
|
} finally {
|
|
publish();
|
|
}
|
|
}),
|
|
);
|
|
|
|
const okCount = outcomes.filter(Boolean).length;
|
|
setProxyCalls((count) => count + okCount);
|
|
|
|
const failedCount = outcomes.length - okCount;
|
|
notify("success", "Bulk proxy complete", `${okCount} of ${outcomes.length} succeeded.`);
|
|
if (failedCount > 0) {
|
|
notify(
|
|
"error",
|
|
`${failedCount} failed`,
|
|
tasks
|
|
.filter((task) => task.status === "error")
|
|
.map((task) => `${task.service.name}:${task.service.port} — ${task.detail}`)
|
|
.join(", "),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleBulkDelete(services) {
|
|
const results = await Promise.allSettled(services.map((service) => data.deleteService(service.id)));
|
|
const failed = results
|
|
.map((outcome, index) => (outcome.status === "rejected" ? services[index].name : null))
|
|
.filter(Boolean);
|
|
const okCount = services.length - failed.length;
|
|
await refresh();
|
|
|
|
notify("success", "Bulk delete complete", `${okCount} of ${services.length} services deleted.`);
|
|
if (failed.length > 0) {
|
|
notify("error", `${failed.length} failed`, failed.join(", "));
|
|
}
|
|
}
|
|
|
|
async function fireProxy(service) {
|
|
setProxyBusyId(service.id);
|
|
try {
|
|
await handleProxy(service);
|
|
} finally {
|
|
setProxyBusyId(null);
|
|
}
|
|
}
|
|
|
|
async function handleRefresh() {
|
|
try {
|
|
await refresh();
|
|
notify("info", "Data refreshed", `${servers.length} servers loaded.`);
|
|
} catch (err) {
|
|
notify("error", "Refresh failed", err.message);
|
|
}
|
|
}
|
|
|
|
const showSkeletons = loading && servers.length === 0;
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<Header
|
|
query={query}
|
|
onQueryChange={setQuery}
|
|
theme={theme}
|
|
onToggleTheme={() => setTheme((current) => (current === "dark" ? "light" : "dark"))}
|
|
onAddServer={() => setServerModal({ initial: null })}
|
|
onAddService={() => setServiceModal({ server: null, service: null })}
|
|
onRefresh={handleRefresh}
|
|
loading={loading}
|
|
searchEnabled={!activeServer}
|
|
/>
|
|
|
|
{activeServer ? (
|
|
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:px-8">
|
|
{bulkRun ? (
|
|
<BulkStatusPage server={activeServer} run={bulkRun} onBack={() => setBulkRun(null)} />
|
|
) : (
|
|
<ServerDetail
|
|
server={activeServer}
|
|
viewMode={serviceView}
|
|
onToggleViewMode={setServiceView}
|
|
onBack={() => setPageServerId(null)}
|
|
onEditServer={() => setServerModal({ initial: activeServer })}
|
|
onDeleteServer={() => requestDeleteServer(activeServer)}
|
|
onEditService={(service) => setServiceModal({ server: activeServer, service })}
|
|
onDeleteService={(service) => requestDeleteService(service)}
|
|
onProxy={handleProxy}
|
|
onBulkProxy={handleBulkProxy}
|
|
onBulkDelete={handleBulkDelete}
|
|
onImportPreview={(payload) => data.previewImport(payload)}
|
|
onImportApply={handleApplyImport}
|
|
/>
|
|
)}
|
|
</main>
|
|
) : (
|
|
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:px-8">
|
|
<StatsBar servers={servers} proxyCalls={proxyCalls} />
|
|
|
|
{serviceMatches.length > 0 && (
|
|
<section className="grid content-start gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<h2 className="text-lg font-bold text-ink">Matching Services</h2>
|
|
<span className="rounded-lg bg-surface-2 px-2 py-0.5 text-xs font-semibold text-ink-soft">
|
|
{serviceMatches.length}
|
|
</span>
|
|
</div>
|
|
<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
|
|
type="button"
|
|
onClick={() => setPageServerId(server.id)}
|
|
className="min-w-0 flex-1 truncate text-left text-sm font-semibold text-ink transition hover:text-accent"
|
|
title="Open server"
|
|
>
|
|
{service.name}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPageServerId(server.id)}
|
|
className="hidden max-w-44 truncate text-[11px] text-ink-muted transition hover:text-ink sm:block"
|
|
title="Open server"
|
|
>
|
|
{server.name}
|
|
</button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
icon={Play}
|
|
loading={proxyBusyId === service.id}
|
|
disabled={proxyBusyId !== null && proxyBusyId !== service.id}
|
|
onClick={() => fireProxy(service)}
|
|
>
|
|
Proxy
|
|
</Button>
|
|
<button
|
|
aria-label={`Delete ${service.name}`}
|
|
className="rounded-lg p-2 text-ink-muted transition hover:bg-rose-500/10 hover:text-rose-400"
|
|
onClick={() => requestDeleteService(service)}
|
|
type="button"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</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>
|
|
</label>
|
|
</section>
|
|
)}
|
|
|
|
<AnimatePresence mode="popLayout">
|
|
{showSkeletons && (
|
|
<div className="grid gap-4" key="skeletons">
|
|
<Skeleton className="h-36 w-full" />
|
|
<Skeleton className="h-36 w-full" />
|
|
<Skeleton className="h-36 w-full" />
|
|
</div>
|
|
)}
|
|
|
|
{!showSkeletons && loadError && (
|
|
<EmptyState
|
|
key="error"
|
|
icon={ShieldCheck}
|
|
title="Can't reach the API"
|
|
description={`${loadError} — make sure the backend is running and CORS allows this origin.`}
|
|
>
|
|
<Button variant="primary" icon={RefreshCcw} onClick={handleRefresh}>
|
|
Try again
|
|
</Button>
|
|
</EmptyState>
|
|
)}
|
|
|
|
{!showSkeletons && !loadError && servers.length === 0 && (
|
|
<EmptyState
|
|
key="empty"
|
|
icon={Server}
|
|
title="No servers registered"
|
|
description="Add your first internal server (e.g. the gateway machine), then use Bulk Import inside it to load your service catalog."
|
|
>
|
|
<Button variant="primary" icon={Plus} onClick={() => setServerModal({ initial: null })}>
|
|
Add Server
|
|
</Button>
|
|
</EmptyState>
|
|
)}
|
|
|
|
{!showSkeletons && !loadError && servers.length > 0 && filtered.length === 0 && (
|
|
<EmptyState
|
|
key="no-match"
|
|
icon={Search}
|
|
title="No matches"
|
|
description={`Nothing matches “${query.trim()}”. Try a different search term.`}
|
|
>
|
|
<Button variant="secondary" onClick={() => setQuery("")}>
|
|
Clear search
|
|
</Button>
|
|
</EmptyState>
|
|
)}
|
|
|
|
{!showSkeletons && filtered.length > 0 && (
|
|
<div className="grid gap-4" key="list">
|
|
{filtered.map((server, index) => (
|
|
<ServerCard
|
|
key={server.id}
|
|
server={server}
|
|
index={index}
|
|
onOpenServer={(item) => setPageServerId(item.id)}
|
|
onEditServer={(item) => setServerModal({ initial: item })}
|
|
onDeleteServer={requestDeleteServer}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<footer className="flex flex-wrap items-center justify-between gap-2 border-t border-line pb-4 pt-6 text-[11px] text-ink-muted">
|
|
<p>MTC Reverse Proxy Manager</p>
|
|
<p className="mono">API {API_BASE}</p>
|
|
</footer>
|
|
</main>
|
|
)}
|
|
|
|
<ServerFormModal
|
|
open={Boolean(serverModal)}
|
|
onClose={() => setServerModal(null)}
|
|
initial={serverModal?.initial}
|
|
onSave={handleSaveServer}
|
|
/>
|
|
<ServiceFormModal
|
|
open={Boolean(serviceModal)}
|
|
onClose={() => setServiceModal(null)}
|
|
initial={serviceModal?.service}
|
|
servers={servers}
|
|
onSave={handleSaveService}
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(confirm)}
|
|
onClose={() => setConfirm(null)}
|
|
title={confirm?.title}
|
|
description={confirm?.description}
|
|
confirmLabel={confirm?.confirmLabel}
|
|
onConfirm={async () => {
|
|
try {
|
|
await confirm.action();
|
|
setConfirm(null);
|
|
} catch (err) {
|
|
notify("error", "Action failed", err.message);
|
|
setConfirm(null);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<ToastProvider>
|
|
<AppShell />
|
|
</ToastProvider>
|
|
);
|
|
}
|