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 (