Add selected server bulk proxy
This commit is contained in:
parent
84f1cda661
commit
2af83967f5
126
src/App.jsx
126
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" ? (
|
||||
<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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user