Add selected server bulk proxy
This commit is contained in:
parent
84f1cda661
commit
2af83967f5
146
src/App.jsx
146
src/App.jsx
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AnimatePresence } from "framer-motion";
|
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 Header from "./components/Header";
|
||||||
import StatsBar from "./components/StatsBar";
|
import StatsBar from "./components/StatsBar";
|
||||||
import ServerCard from "./components/ServerCard";
|
import ServerCard from "./components/ServerCard";
|
||||||
@ -28,6 +28,7 @@ function AppShell() {
|
|||||||
const [sort, setSort] = useState("name");
|
const [sort, setSort] = useState("name");
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark");
|
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || "dark");
|
||||||
const [proxyCalls, setProxyCalls] = useState(0);
|
const [proxyCalls, setProxyCalls] = useState(0);
|
||||||
|
const [selectedServerIds, setSelectedServerIds] = useState([]);
|
||||||
|
|
||||||
const [pageServerId, setPageServerId] = useState(null);
|
const [pageServerId, setPageServerId] = useState(null);
|
||||||
const [bulkRun, setBulkRun] = useState(null);
|
const [bulkRun, setBulkRun] = useState(null);
|
||||||
@ -56,6 +57,11 @@ function AppShell() {
|
|||||||
if (pageServerId && !activeServer && !loading) setPageServerId(null);
|
if (pageServerId && !activeServer && !loading) setPageServerId(null);
|
||||||
}, [pageServerId, activeServer, loading]);
|
}, [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 filtered = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
let list = servers;
|
let list = servers;
|
||||||
@ -103,6 +109,22 @@ function AppShell() {
|
|||||||
);
|
);
|
||||||
}, [servers, query]);
|
}, [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) {
|
async function handleSaveServer(form) {
|
||||||
if (serverModal?.initial) {
|
if (serverModal?.initial) {
|
||||||
const saved = await data.updateServer(serverModal.initial.id, form);
|
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) {
|
async function handleBulkDelete(services) {
|
||||||
const results = await Promise.allSettled(services.map((service) => data.deleteService(service.id)));
|
const results = await Promise.allSettled(services.map((service) => data.deleteService(service.id)));
|
||||||
const failed = results
|
const failed = results
|
||||||
@ -276,7 +373,11 @@ function AppShell() {
|
|||||||
searchEnabled={!activeServer}
|
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">
|
<main className="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:px-8">
|
||||||
{bulkRun ? (
|
{bulkRun ? (
|
||||||
<BulkStatusPage server={activeServer} run={bulkRun} onBack={() => setBulkRun(null)} />
|
<BulkStatusPage server={activeServer} run={bulkRun} onBack={() => setBulkRun(null)} />
|
||||||
@ -378,17 +479,36 @@ function AppShell() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-xs font-medium text-ink-muted">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Sort
|
<Button variant="secondary" size="sm" icon={CheckSquare} onClick={selectVisibleServers} disabled={filtered.length === 0}>
|
||||||
<select
|
Select visible
|
||||||
className="input !w-auto !py-1.5 text-xs"
|
</Button>
|
||||||
value={sort}
|
{selectedServerIds.length > 0 && (
|
||||||
onChange={(event) => setSort(event.target.value)}
|
<Button variant="ghost" size="sm" onClick={() => setSelectedServerIds([])}>
|
||||||
|
Clear ({selectedServerIds.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={Play}
|
||||||
|
onClick={handleSelectedServerProxy}
|
||||||
|
disabled={selectedServers.length === 0}
|
||||||
>
|
>
|
||||||
<option value="name">Name</option>
|
Proxy selected
|
||||||
<option value="count">Most services</option>
|
</Button>
|
||||||
</select>
|
<label className="flex items-center gap-2 text-xs font-medium text-ink-muted">
|
||||||
</label>
|
Sort
|
||||||
|
<select
|
||||||
|
className="input !w-auto !py-1.5 text-xs"
|
||||||
|
value={sort}
|
||||||
|
onChange={(event) => setSort(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="count">Most services</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -447,6 +567,8 @@ function AppShell() {
|
|||||||
key={server.id}
|
key={server.id}
|
||||||
server={server}
|
server={server}
|
||||||
index={index}
|
index={index}
|
||||||
|
selected={selectedServerIds.includes(server.id)}
|
||||||
|
onSelectServer={toggleServerSelection}
|
||||||
onOpenServer={(item) => setPageServerId(item.id)}
|
onOpenServer={(item) => setPageServerId(item.id)}
|
||||||
onEditServer={(item) => setServerModal({ initial: item })}
|
onEditServer={(item) => setServerModal({ initial: item })}
|
||||||
onDeleteServer={requestDeleteServer}
|
onDeleteServer={requestDeleteServer}
|
||||||
|
|||||||
@ -47,13 +47,19 @@ function TaskRow({ task }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BulkStatusPage({ server, run, onBack }) {
|
export default function BulkStatusPage({ server, run, onBack }) {
|
||||||
const total = run.tasks.length;
|
const groups = run.groups || [{ server, tasks: run.tasks }];
|
||||||
const succeeded = run.tasks.filter((task) => task.status === "success").length;
|
const tasks = groups.flatMap((group) => group.tasks);
|
||||||
const failed = run.tasks.filter((task) => task.status === "error").length;
|
const total = tasks.length;
|
||||||
const running = run.tasks.filter((task) => task.status === "running").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 done = succeeded + failed;
|
||||||
const finished = done === total;
|
const finished = done === total;
|
||||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
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
|
const barColor = finished
|
||||||
? failed > 0
|
? failed > 0
|
||||||
@ -68,7 +74,7 @@ export default function BulkStatusPage({ server, run, onBack }) {
|
|||||||
onClick={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"
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
@ -78,9 +84,9 @@ export default function BulkStatusPage({ server, run, onBack }) {
|
|||||||
<Zap size={22} />
|
<Zap size={22} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -128,10 +134,39 @@ export default function BulkStatusPage({ server, run, onBack }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-4">
|
||||||
{run.tasks.map((task) => (
|
{groups.map((group) => {
|
||||||
<TaskRow key={task.id} task={task} />
|
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">
|
||||||
|
{group.tasks.map((task) => (
|
||||||
|
<TaskRow key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{finished && (
|
{finished && (
|
||||||
@ -146,7 +181,7 @@ export default function BulkStatusPage({ server, run, onBack }) {
|
|||||||
: `All ${succeeded} requests succeeded.`}
|
: `All ${succeeded} requests succeeded.`}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="primary" onClick={onBack}>
|
<Button variant="primary" onClick={onBack}>
|
||||||
Back to {server.name}
|
Back to {server?.name || "Dashboard"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -3,14 +3,24 @@ import { ArrowUpRight, Pencil, Server, Trash2 } from "lucide-react";
|
|||||||
import CopyButton from "./ui/CopyButton";
|
import CopyButton from "./ui/CopyButton";
|
||||||
import Button from "./ui/Button";
|
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 (
|
return (
|
||||||
<motion.article
|
<motion.article
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.18, delay: Math.min(index * 0.03, 0.3) }}
|
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" />
|
<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>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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">
|
<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}
|
gateway :{server.proxyPort}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user