diff --git a/index.html b/index.html index f94d687..c3e40b1 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,15 @@ - frontend + + + MTC Reverse Proxy Manager + + +
diff --git a/src/App.jsx b/src/App.jsx index ed814b1..42eb570 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,405 +1,505 @@ import { useEffect, useMemo, useState } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { - AlertTriangle, - Check, - Database, - FileJson, - Globe2, - Loader2, - Plus, - RefreshCcw, - Route, - Server, - ShieldCheck, - Zap, -} from "lucide-react"; +import { AnimatePresence } from "framer-motion"; +import { Plus, Play, RefreshCcw, Search, Server, ShieldCheck, Trash2 } from "lucide-react"; +import Header from "./components/Header"; +import StatsBar from "./components/StatsBar"; +import ServerCard from "./components/ServerCard"; +import ServerDetail from "./components/ServerDetail"; +import BulkStatusPage from "./components/BulkStatusPage"; +import ServerFormModal from "./components/modals/ServerFormModal"; +import ServiceFormModal from "./components/modals/ServiceFormModal"; +import ConfirmDialog from "./components/ui/ConfirmDialog"; +import EmptyState from "./components/ui/EmptyState"; +import Skeleton from "./components/ui/Skeleton"; +import Button from "./components/ui/Button"; +import { ToastProvider, useToast } from "./components/toast/ToastProvider"; +import useServers from "./hooks/useServers"; +import { API_BASE } from "./lib/api"; -const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api"; +const THEME_KEY = "mtc-theme"; +const VIEW_KEY = "mtc-service-view"; -const emptyServer = { name: "", ipAddress: "", description: "" }; -const emptyService = { - serverId: "", - name: "", - port: "", - endpoint: "", - method: "POST", - requestTemplate: "{}", - notes: "", -}; +function AppShell() { + const { notify } = useToast(); + const data = useServers(); + const { servers, loading, loadError, refresh } = data; -const sampleJson = JSON.stringify( - { - servers: [ - { - name: "Billing Server", - ipAddress: "192.168.1.50", - services: [ - { - name: "Invoice API", - port: 8080, - endpoint: "/api/invoices/sync", - method: "POST", - requestTemplate: { limit: 10 }, - }, - ], - }, - ], - }, - null, - 2, -); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("name"); + const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark"); + const [proxyCalls, setProxyCalls] = useState(0); -async function api(path, options = {}) { - const response = await fetch(`${API_BASE}${path}`, { - headers: { "content-type": "application/json", ...(options.headers || {}) }, - ...options, - }); - const data = await response.json().catch(() => null); - if (!response.ok) { - const error = new Error(data?.message || "Request failed."); - error.data = data; - throw error; - } - return data; -} + const [pageServerId, setPageServerId] = useState(null); + const [bulkRun, setBulkRun] = useState(null); + const [proxyBusyId, setProxyBusyId] = useState(null); + const [serviceView, setServiceView] = useState(() => localStorage.getItem(VIEW_KEY) || "grid"); + const [serverModal, setServerModal] = useState(null); + const [serviceModal, setServiceModal] = useState(null); + const [confirm, setConfirm] = useState(null); -function parseJsonField(value) { - const trimmed = value.trim(); - if (!trimmed) return null; - return JSON.parse(trimmed); -} - -function Stat({ icon: Icon, label, value }) { - return ( -
- - - -
-

{label}

-

{value}

-
-
- ); -} - -function Field({ label, children }) { - return ( - - ); -} - -function App() { - const [servers, setServers] = useState([]); - const [serverForm, setServerForm] = useState(emptyServer); - const [serviceForm, setServiceForm] = useState(emptyService); - const [jsonInput, setJsonInput] = useState(sampleJson); - const [preview, setPreview] = useState(null); - const [message, setMessage] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [proxyResult, setProxyResult] = useState(null); - - const totals = useMemo(() => { - const services = servers.reduce((sum, server) => sum + server.services.length, 0); - const ports = new Set(servers.flatMap((server) => server.services.map((service) => service.port))); - return { servers: servers.length, services, ports: ports.size }; - }, [servers]); - - async function loadServers() { - setLoading(true); - setError(""); - try { - const data = await api("/servers"); - setServers(data); - if (data[0] && !serviceForm.serverId) { - setServiceForm((current) => ({ ...current, serverId: data[0].id })); - } - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - } + const activeServer = pageServerId ? servers.find((server) => server.id === pageServerId) || null : null; useEffect(() => { - loadServers(); - }, []); + document.documentElement.classList.toggle("light", theme === "light"); + localStorage.setItem(THEME_KEY, theme); + }, [theme]); - async function createServer(event) { - event.preventDefault(); - setError(""); - setMessage(""); + 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 { - await api("/servers", { method: "POST", body: JSON.stringify(serverForm) }); - setServerForm(emptyServer); - setMessage("Server added."); - await loadServers(); + 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) { - setError(err.message); + notify("error", "Proxy failed", `${service.name}:${service.port} — ${err.message}`); } } - async function createService(event) { - event.preventDefault(); - setError(""); - setMessage(""); - try { - await api("/services", { - method: "POST", - body: JSON.stringify({ - ...serviceForm, - requestTemplate: parseJsonField(serviceForm.requestTemplate), - }), - }); - setServiceForm((current) => ({ - ...emptyService, - serverId: current.serverId, - method: "POST", - requestTemplate: "{}", - })); - setMessage("Service added."); - await loadServers(); - } catch (err) { - setError(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 }); - async function previewImport() { - setError(""); - setMessage(""); - try { - const payload = JSON.parse(jsonInput); - const data = await api("/imports/preview", { method: "POST", body: JSON.stringify(payload) }); - setPreview(data); - setMessage( - data.conflicts.length - ? "Conflicts found. Review and confirm before overwrite." - : "Import is ready. Exact duplicates will be skipped.", + const 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(", "), ); - } catch (err) { - setError(err.message); } } - async function applyImport(mode = "skip") { - setError(""); - setMessage(""); - try { - const payload = JSON.parse(jsonInput); - const data = await api("/imports/apply", { - method: "POST", - body: JSON.stringify({ payload, mode }), - }); - setPreview(null); - setMessage( - `Import applied: ${data.applied.serversCreated} servers and ${data.applied.servicesCreated} services created.`, - ); - await loadServers(); - } catch (err) { - setError(err.message); - if (err.data) setPreview(err.data); + 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 reverseProxy(serviceId, body) { - setError(""); - setProxyResult(null); + async function fireProxy(service) { + setProxyBusyId(service.id); try { - const data = await api(`/services/${serviceId}/reverse-proxy`, { - method: "POST", - body: JSON.stringify({ body }), - }); - setProxyResult(data); - } catch (err) { - setError(err.message); + 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 ( -
-
-
-
-
- - +
+
setTheme((current) => (current === "dark" ? "light" : "dark"))} + onAddServer={() => setServerModal({ initial: null })} + onAddService={() => setServiceModal({ server: null, service: null })} + onRefresh={handleRefresh} + loading={loading} + searchEnabled={!activeServer} + /> + + {activeServer ? ( +
+ {bulkRun ? ( + setBulkRun(null)} /> + ) : ( + 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} + /> + )} +
+ ) : ( +
+ + + {serviceMatches.length > 0 && ( +
+
+

Matching Services

+ + {serviceMatches.length} -
-

MTC Server Reverse Proxy

-

Reverse Proxy Manager

-
- -
-
- - - -
-
-
- -
-