Add selected server bulk proxy

This commit is contained in:
MOHAN 2026-08-05 17:51:08 +05:30
parent 84f1cda661
commit 2af83967f5
3 changed files with 202 additions and 26 deletions

View File

@ -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" ? (
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:px-8">
<BulkStatusPage run={bulkRun} onBack={() => setBulkRun(null)} />
</main>
) : 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)} />
@ -378,6 +479,24 @@ function AppShell() {
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" icon={CheckSquare} onClick={selectVisibleServers} disabled={filtered.length === 0}>
Select visible
</Button>
{selectedServerIds.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setSelectedServerIds([])}>
Clear ({selectedServerIds.length})
</Button>
)}
<Button
variant="primary"
size="sm"
icon={Play}
onClick={handleSelectedServerProxy}
disabled={selectedServers.length === 0}
>
Proxy selected
</Button>
<label className="flex items-center gap-2 text-xs font-medium text-ink-muted">
Sort
<select
@ -389,6 +508,7 @@ function AppShell() {
<option value="count">Most services</option>
</select>
</label>
</div>
</section>
)}
@ -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}

View File

@ -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"
>
<ChevronLeft size={16} /> {server.name}
<ChevronLeft size={16} /> {server?.name || "Dashboard"}
</button>
<div className="card overflow-hidden">
@ -78,9 +84,9 @@ export default function BulkStatusPage({ server, run, onBack }) {
<Zap size={22} />
</span>
<div className="min-w-0">
<h2 className="truncate text-lg font-bold text-ink">Bulk Proxy Status</h2>
<h2 className="truncate text-lg font-bold text-ink">{title}</h2>
<p className="flex items-center gap-1.5 truncate text-xs text-ink-soft">
<Server size={12} className="text-ink-muted" /> {server.name} · {server.ipAddress}
<Server size={12} className="text-ink-muted" /> {subtitle}
</p>
</div>
</div>
@ -128,11 +134,40 @@ export default function BulkStatusPage({ server, run, onBack }) {
))}
</div>
<div className="grid gap-4">
{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 (
<div key={group.server.id} className="rounded-xl border border-line bg-surface-2/30 p-3">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-ink">{group.server.name}</p>
<p className="mono truncate text-[11px] text-ink-muted">{group.server.ipAddress}</p>
</div>
<span
className={`w-fit rounded-md border px-2 py-0.5 text-[11px] font-semibold ${
groupDone === group.tasks.length
? groupFailed > 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}%
</span>
</div>
<div className="grid gap-2">
{run.tasks.map((task) => (
{group.tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</div>
);
})}
</div>
{finished && (
<div
@ -146,7 +181,7 @@ export default function BulkStatusPage({ server, run, onBack }) {
: `All ${succeeded} requests succeeded.`}
</p>
<Button variant="primary" onClick={onBack}>
Back to {server.name}
Back to {server?.name || "Dashboard"}
</Button>
</div>
)}

View File

@ -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 (
<motion.article
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18, delay: Math.min(index * 0.03, 0.3) }}
className="card group relative overflow-hidden transition hover:border-line-strong hover:shadow-xl hover:shadow-black/25"
className={`card group relative overflow-hidden transition hover:border-line-strong hover:shadow-xl hover:shadow-black/25 ${
selected ? "border-accent shadow-lg shadow-cyan-500/10" : ""
}`}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent opacity-0 transition group-hover:opacity-100" />
@ -36,6 +46,15 @@ export default function ServerCard({ server, index = 0, onOpenServer, onEditServ
</button>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-line bg-surface-2 px-2.5 py-1 text-xs font-semibold text-ink-soft transition hover:border-line-strong hover:text-ink">
<input
type="checkbox"
checked={selected}
onChange={(event) => onSelectServer(server, event.target.checked)}
className="h-4 w-4 accent-cyan-500"
/>
Select
</label>
{server.proxyPort && (
<span className="mono hidden rounded-lg bg-cyan-500/10 px-2.5 py-1 text-[11px] font-medium text-cyan-400 md:inline-block">
gateway :{server.proxyPort}