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