diff --git a/src/App.jsx b/src/App.jsx index 42eb570..2710509 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { AnimatePresence } from "framer-motion"; -import { Plus, Play, RefreshCcw, Search, Server, ShieldCheck, Trash2 } from "lucide-react"; +import { CheckSquare, 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"; @@ -28,6 +28,7 @@ function AppShell() { const [sort, setSort] = useState("name"); const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark"); const [proxyCalls, setProxyCalls] = useState(0); + const [selectedServerIds, setSelectedServerIds] = useState([]); const [pageServerId, setPageServerId] = useState(null); const [bulkRun, setBulkRun] = useState(null); @@ -56,6 +57,11 @@ function AppShell() { if (pageServerId && !activeServer && !loading) setPageServerId(null); }, [pageServerId, activeServer, loading]); + useEffect(() => { + const ids = new Set(servers.map((server) => server.id)); + setSelectedServerIds((current) => current.filter((id) => ids.has(id))); + }, [servers]); + const filtered = useMemo(() => { const needle = query.trim().toLowerCase(); let list = servers; @@ -103,6 +109,22 @@ function AppShell() { ); }, [servers, query]); + const selectedServers = useMemo( + () => servers.filter((server) => selectedServerIds.includes(server.id)), + [servers, selectedServerIds], + ); + + function toggleServerSelection(server, checked) { + setSelectedServerIds((current) => { + if (checked) return current.includes(server.id) ? current : [...current, server.id]; + return current.filter((id) => id !== server.id); + }); + } + + function selectVisibleServers() { + setSelectedServerIds(filtered.map((server) => server.id)); + } + async function handleSaveServer(form) { if (serverModal?.initial) { const saved = await data.updateServer(serverModal.initial.id, form); @@ -228,6 +250,81 @@ function AppShell() { } } + async function handleSelectedServerProxy() { + const runnableServers = selectedServers.filter((server) => server.services.length > 0); + const skippedServers = selectedServers.length - runnableServers.length; + + if (runnableServers.length === 0) { + notify("error", "No services selected", "Choose at least one server that has services."); + return; + } + + const groups = runnableServers.map((server) => ({ + server, + tasks: server.services.map((service) => ({ + id: service.id, + service, + status: "pending", + statusCode: null, + detail: "", + })), + })); + + setBulkRun({ + scope: "servers", + title: "Selected Servers Proxy", + subtitle: `${runnableServers.length} server${runnableServers.length === 1 ? "" : "s"} · ${groups.reduce((sum, group) => sum + group.tasks.length, 0)} services`, + groups, + }); + + const publish = () => + setBulkRun((current) => + current?.scope === "servers" + ? { + ...current, + groups: groups.map((group) => ({ ...group, tasks: [...group.tasks] })), + } + : current, + ); + + const tasks = groups.flatMap((group) => group.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; + const failedCount = outcomes.length - okCount; + setProxyCalls((count) => count + okCount); + + notify( + failedCount > 0 ? "error" : "success", + "Selected proxy complete", + `${okCount} of ${outcomes.length} succeeded${skippedServers ? `, ${skippedServers} empty server${skippedServers === 1 ? "" : "s"} skipped` : ""}.`, + ); + } + async function handleBulkDelete(services) { const results = await Promise.allSettled(services.map((service) => data.deleteService(service.id))); const failed = results @@ -276,7 +373,11 @@ function AppShell() { searchEnabled={!activeServer} /> - {activeServer ? ( + {bulkRun?.scope === "servers" ? ( +
+ setBulkRun(null)} /> +
+ ) : activeServer ? (
{bulkRun ? ( setBulkRun(null)} /> @@ -378,17 +479,36 @@ function AppShell() { )} - + Proxy selected + + + )} @@ -447,6 +567,8 @@ function AppShell() { key={server.id} server={server} index={index} + selected={selectedServerIds.includes(server.id)} + onSelectServer={toggleServerSelection} onOpenServer={(item) => setPageServerId(item.id)} onEditServer={(item) => setServerModal({ initial: item })} onDeleteServer={requestDeleteServer} diff --git a/src/components/BulkStatusPage.jsx b/src/components/BulkStatusPage.jsx index 63a00c2..cafaa99 100644 --- a/src/components/BulkStatusPage.jsx +++ b/src/components/BulkStatusPage.jsx @@ -47,13 +47,19 @@ function TaskRow({ task }) { } 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 groups = run.groups || [{ server, tasks: run.tasks }]; + const tasks = groups.flatMap((group) => group.tasks); + const total = tasks.length; + const succeeded = tasks.filter((task) => task.status === "success").length; + const failed = tasks.filter((task) => task.status === "error").length; + const running = 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 title = run.title || "Bulk Proxy Status"; + const subtitle = + run.subtitle || + (server ? `${server.name} · ${server.ipAddress}` : `${groups.length} servers selected`); const barColor = finished ? failed > 0 @@ -68,7 +74,7 @@ export default function BulkStatusPage({ server, run, onBack }) { 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" > - {server.name} + {server?.name || "Dashboard"}
@@ -78,9 +84,9 @@ export default function BulkStatusPage({ server, run, onBack }) {
-

Bulk Proxy Status

+

{title}

- {server.name} · {server.ipAddress} + {subtitle}

@@ -128,10 +134,39 @@ export default function BulkStatusPage({ server, run, onBack }) { ))} -
- {run.tasks.map((task) => ( - - ))} +
+ {groups.map((group) => { + const groupDone = group.tasks.filter((task) => task.status === "success" || task.status === "error").length; + const groupFailed = group.tasks.filter((task) => task.status === "error").length; + const groupPct = group.tasks.length === 0 ? 0 : Math.round((groupDone / group.tasks.length) * 100); + + return ( +
+
+
+

{group.server.name}

+

{group.server.ipAddress}

+
+ 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" + }`} + > + {groupDone}/{group.tasks.length} · {groupPct}% + +
+
+ {group.tasks.map((task) => ( + + ))} +
+
+ ); + })}
{finished && ( @@ -146,7 +181,7 @@ export default function BulkStatusPage({ server, run, onBack }) { : `All ${succeeded} requests succeeded.`}

)} diff --git a/src/components/ServerCard.jsx b/src/components/ServerCard.jsx index 61bdfee..e23e4f6 100644 --- a/src/components/ServerCard.jsx +++ b/src/components/ServerCard.jsx @@ -3,14 +3,24 @@ 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 }) { +export default function ServerCard({ + server, + index = 0, + selected = false, + onSelectServer, + onOpenServer, + onEditServer, + onDeleteServer, +}) { return (
@@ -36,6 +46,15 @@ export default function ServerCard({ server, index = 0, onOpenServer, onEditServ
+ {server.proxyPort && ( gateway :{server.proxyPort}