Skip to content

Commit bff28c9

Browse files
committed
feat(webui): add platform lease management
1 parent 7e006ba commit bff28c9

6 files changed

Lines changed: 445 additions & 36 deletions

File tree

webui/src/features/platforms/PlatformDetailPage.tsx

Lines changed: 264 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
import { zodResolver } from "@hookform/resolvers/zod";
22
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3-
import { AlertTriangle, ArrowLeft, Info, RefreshCw } from "lucide-react";
3+
import type { ColumnDef } from "@tanstack/react-table";
4+
import { AlertTriangle, ArrowLeft, Info, RefreshCw, Sparkles, Trash2 } from "lucide-react";
45
import { useEffect, useState } from "react";
56
import { useForm } from "react-hook-form";
6-
import { useNavigate, useParams } from "react-router-dom";
7+
import { useLocation, useNavigate, useParams } from "react-router-dom";
78
import { Badge } from "../../components/ui/Badge";
89
import { Button } from "../../components/ui/Button";
910
import { Card } from "../../components/ui/Card";
11+
import { DataTable } from "../../components/ui/DataTable";
1012
import { Input } from "../../components/ui/Input";
13+
import { OffsetPagination } from "../../components/ui/OffsetPagination";
1114
import { Select } from "../../components/ui/Select";
1215
import { Switch } from "../../components/ui/Switch";
1316
import { Textarea } from "../../components/ui/Textarea";
1417
import { ToastContainer } from "../../components/ui/Toast";
1518
import { useToast } from "../../hooks/useToast";
1619
import { useI18n } from "../../i18n";
1720
import { formatApiErrorMessage } from "../../lib/error-message";
18-
import { formatGoDuration, formatRelativeTime } from "../../lib/time";
19-
import { clearAllPlatformLeases, deletePlatform, getPlatform, resetPlatform, updatePlatform } from "./api";
21+
import { formatDateTime, formatGoDuration, formatRelativeTime } from "../../lib/time";
22+
import {
23+
clearAllPlatformLeases,
24+
deletePlatform,
25+
deletePlatformLease,
26+
getPlatform,
27+
listPlatformLeases,
28+
resetPlatform,
29+
updatePlatform,
30+
} from "./api";
2031
import {
2132
allocationPolicies,
2233
allocationPolicyLabel,
@@ -35,10 +46,13 @@ import {
3546
} from "./formModel";
3647
import { PlatformAccessPanel } from "./PlatformAccessPanel";
3748
import { PlatformMonitorPanel } from "./PlatformMonitorPanel";
49+
import type { PlatformLease } from "./types";
3850

3951
type PlatformDetailTab = "monitor" | "access" | "config" | "ops";
4052

4153
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
54+
const LEASE_MANAGEMENT_ANCHOR = "platform-lease-management";
55+
const LEASE_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
4256
const DETAIL_TABS: Array<{ key: PlatformDetailTab; label: string; hint: string }> = [
4357
{ key: "monitor", label: "监控", hint: "平台运行态趋势和快照" },
4458
{ key: "access", label: "接入", hint: "复制正向/反向代理地址" },
@@ -49,8 +63,11 @@ const DETAIL_TABS: Array<{ key: PlatformDetailTab; label: string; hint: string }
4963
export function PlatformDetailPage() {
5064
const { t } = useI18n();
5165
const { platformId = "" } = useParams();
66+
const location = useLocation();
5267
const navigate = useNavigate();
5368
const [activeTab, setActiveTab] = useState<PlatformDetailTab>("monitor");
69+
const [leasePage, setLeasePage] = useState(0);
70+
const [leasePageSize, setLeasePageSize] = useState<number>(LEASE_PAGE_SIZE_OPTIONS[0]);
5471
const { toasts, showToast, dismissToast } = useToast();
5572
const queryClient = useQueryClient();
5673
const formatPlatformMutationError = (error: unknown) => {
@@ -71,6 +88,33 @@ export function PlatformDetailPage() {
7188

7289
const platform = platformQuery.data ?? null;
7390

91+
const leaseQuery = useQuery({
92+
queryKey: ["platform-leases", platform?.id, leasePage, leasePageSize],
93+
queryFn: () => {
94+
if (!platform) {
95+
throw new Error("平台不存在或已被删除");
96+
}
97+
return listPlatformLeases(platform.id, {
98+
limit: leasePageSize,
99+
offset: leasePage * leasePageSize,
100+
sort_by: "expiry",
101+
sort_order: "asc",
102+
});
103+
},
104+
enabled: Boolean(platform?.id) && activeTab === "ops",
105+
refetchInterval: 30_000,
106+
placeholderData: (previous) => previous,
107+
});
108+
109+
const leasesPage = leaseQuery.data ?? {
110+
items: [],
111+
total: 0,
112+
limit: leasePageSize,
113+
offset: leasePage * leasePageSize,
114+
};
115+
const leases = leasesPage.items;
116+
const leaseTotalPages = Math.max(1, Math.ceil(leasesPage.total / leasePageSize));
117+
74118
const editForm = useForm<PlatformFormValues>({
75119
resolver: zodResolver(platformFormSchema),
76120
defaultValues: defaultPlatformFormValues,
@@ -84,6 +128,34 @@ export function PlatformDetailPage() {
84128
editForm.reset(platformToFormValues(platform));
85129
}, [platform, editForm]);
86130

131+
useEffect(() => {
132+
setLeasePage(0);
133+
}, [platformId]);
134+
135+
useEffect(() => {
136+
const maxPage = Math.max(0, Math.ceil(leasesPage.total / leasePageSize) - 1);
137+
if (leasePage > maxPage) {
138+
setLeasePage(maxPage);
139+
}
140+
}, [leasePage, leasePageSize, leasesPage.total]);
141+
142+
useEffect(() => {
143+
const tab = new URLSearchParams(location.search).get("tab");
144+
if (tab === "ops" || location.hash === `#${LEASE_MANAGEMENT_ANCHOR}`) {
145+
setActiveTab("ops");
146+
}
147+
}, [location.hash, location.search]);
148+
149+
useEffect(() => {
150+
if (activeTab !== "ops" || location.hash !== `#${LEASE_MANAGEMENT_ANCHOR}`) {
151+
return;
152+
}
153+
154+
window.requestAnimationFrame(() => {
155+
document.getElementById(LEASE_MANAGEMENT_ANCHOR)?.scrollIntoView({ block: "start" });
156+
});
157+
}, [activeTab, location.hash]);
158+
87159
const invalidatePlatform = async (id: string) => {
88160
await Promise.all([
89161
queryClient.invalidateQueries({ queryKey: ["platforms"] }),
@@ -135,14 +207,39 @@ export function PlatformDetailPage() {
135207
return platform;
136208
},
137209
onSuccess: async (updated) => {
138-
await queryClient.invalidateQueries({ queryKey: ["platform-monitor"] });
210+
await Promise.all([
211+
queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }),
212+
queryClient.invalidateQueries({ queryKey: ["platform-leases", updated.id] }),
213+
]);
139214
showToast("success", t("平台 {{name}} 的所有租约已清除", { name: updated.name }));
140215
},
141216
onError: (error) => {
142217
showToast("error", formatApiErrorMessage(error, t));
143218
},
144219
});
145220

221+
const releaseLeaseMutation = useMutation({
222+
mutationFn: async (lease: PlatformLease) => {
223+
if (!platform) {
224+
throw new Error("平台不存在或已被删除");
225+
}
226+
await deletePlatformLease(platform.id, lease.account);
227+
return lease;
228+
},
229+
onSuccess: async (lease) => {
230+
if (platform) {
231+
await Promise.all([
232+
queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }),
233+
queryClient.invalidateQueries({ queryKey: ["platform-leases", platform.id] }),
234+
]);
235+
}
236+
showToast("success", t("账号 {{account}} 的租约已释放", { account: lease.account }));
237+
},
238+
onError: (error) => {
239+
showToast("error", formatApiErrorMessage(error, t));
240+
},
241+
});
242+
146243
const deleteMutation = useMutation({
147244
mutationFn: async () => {
148245
if (!platform) {
@@ -190,6 +287,82 @@ export function PlatformDetailPage() {
190287
await clearLeasesMutation.mutateAsync();
191288
};
192289

290+
const handleReleaseLease = async (lease: PlatformLease) => {
291+
const confirmed = window.confirm(t("确认释放账号 {{account}} 的租约?", { account: lease.account }));
292+
if (!confirmed) {
293+
return;
294+
}
295+
await releaseLeaseMutation.mutateAsync(lease);
296+
};
297+
298+
const changeLeasePageSize = (next: number) => {
299+
setLeasePageSize(next);
300+
setLeasePage(0);
301+
};
302+
303+
const leaseColumns: ColumnDef<PlatformLease>[] = [
304+
{
305+
accessorKey: "account",
306+
header: t("账号"),
307+
cell: ({ row }) => (
308+
<span className="lease-account-cell" title={row.original.account}>
309+
{row.original.account || "-"}
310+
</span>
311+
),
312+
},
313+
{
314+
id: "node",
315+
header: t("节点"),
316+
cell: ({ row }) => {
317+
const lease = row.original;
318+
return (
319+
<span className="lease-node-cell" title={lease.node_tag || lease.node_hash}>
320+
<strong>{lease.node_tag || "-"}</strong>
321+
<small>{lease.node_hash || "-"}</small>
322+
</span>
323+
);
324+
},
325+
},
326+
{
327+
accessorKey: "egress_ip",
328+
header: t("出口 IP"),
329+
cell: ({ row }) => row.original.egress_ip || "-",
330+
},
331+
{
332+
accessorKey: "expiry",
333+
header: t("过期时间"),
334+
cell: ({ row }) => formatDateTime(row.original.expiry),
335+
},
336+
{
337+
accessorKey: "last_accessed",
338+
header: t("最后访问"),
339+
cell: ({ row }) => formatDateTime(row.original.last_accessed),
340+
},
341+
{
342+
id: "actions",
343+
header: t("操作"),
344+
cell: ({ row }) => {
345+
const lease = row.original;
346+
const releasing = releaseLeaseMutation.isPending && releaseLeaseMutation.variables?.account === lease.account;
347+
return (
348+
<div className="lease-row-actions" onClick={(event) => event.stopPropagation()}>
349+
<Button
350+
variant="ghost"
351+
size="sm"
352+
onClick={() => void handleReleaseLease(lease)}
353+
disabled={releasing || clearLeasesMutation.isPending}
354+
title={t("释放租约")}
355+
aria-label={t("释放账号 {{account}} 的租约", { account: lease.account })}
356+
style={{ color: "var(--delete-btn-color, #c27070)" }}
357+
>
358+
<Trash2 size={14} />
359+
</Button>
360+
</div>
361+
);
362+
},
363+
},
364+
];
365+
193366
const stickyTTL = platform ? formatGoDuration(platform.sticky_ttl, t("默认")) : t("默认");
194367
const regionCount = platform?.region_filters.length ?? 0;
195368
const regexCount = platform?.regex_filters.length ?? 0;
@@ -494,49 +667,105 @@ export function PlatformDetailPage() {
494667
) : null}
495668

496669
{activeTab === "ops" ? (
497-
<section
670+
<div
498671
id="platform-tabpanel-ops"
499672
role="tabpanel"
500673
aria-labelledby="platform-tab-ops"
501-
className="platform-detail-tabpanel platform-ops-section"
674+
className="platform-detail-tabpanel platform-ops-tabpanel"
502675
>
503-
<div className="platform-drawer-section-head">
504-
<h4>{t("运维操作")}</h4>
505-
<p>{t("以下操作会直接作用于当前平台,请谨慎执行。")}</p>
506-
</div>
676+
<section className="platform-ops-section">
677+
<div className="platform-drawer-section-head">
678+
<h4>{t("运维操作")}</h4>
679+
<p>{t("以下操作会直接作用于当前平台,请谨慎执行。")}</p>
680+
</div>
507681

508-
<div className="platform-ops-list">
509-
<div className="platform-op-item">
510-
<div className="platform-op-copy">
511-
<h5>{t("重置为默认配置")}</h5>
512-
<p className="platform-op-hint">{t("恢复默认设置,并覆盖当前修改。")}</p>
682+
<div className="platform-ops-list">
683+
<div className="platform-op-item">
684+
<div className="platform-op-copy">
685+
<h5>{t("重置为默认配置")}</h5>
686+
<p className="platform-op-hint">{t("恢复默认设置,并覆盖当前修改。")}</p>
687+
</div>
688+
<Button variant="secondary" onClick={() => void resetMutation.mutateAsync()} disabled={resetMutation.isPending}>
689+
{resetMutation.isPending ? t("重置中...") : t("重置为默认配置")}
690+
</Button>
513691
</div>
514-
<Button variant="secondary" onClick={() => void resetMutation.mutateAsync()} disabled={resetMutation.isPending}>
515-
{resetMutation.isPending ? t("重置中...") : t("重置为默认配置")}
516-
</Button>
517-
</div>
518692

519-
<div className="platform-op-item">
520-
<div className="platform-op-copy">
521-
<h5>{t("清除所有租约")}</h5>
522-
<p className="platform-op-hint">{t("立即清除当前平台的全部租约,下次请求将重新分配出口。")}</p>
693+
<div className="platform-op-item">
694+
<div className="platform-op-copy">
695+
<h5>{t("清除所有租约")}</h5>
696+
<p className="platform-op-hint">{t("立即清除当前平台的全部租约,下次请求将重新分配出口。")}</p>
697+
</div>
698+
<Button variant="danger" onClick={() => void handleClearAllLeases()} disabled={clearLeasesMutation.isPending}>
699+
{clearLeasesMutation.isPending ? t("清除中...") : t("清除所有租约")}
700+
</Button>
701+
</div>
702+
703+
<div className="platform-op-item">
704+
<div className="platform-op-copy">
705+
<h5>{t("删除平台")}</h5>
706+
<p className="platform-op-hint">{t("永久删除当前平台及其配置,操作不可撤销。")}</p>
707+
</div>
708+
<Button variant="danger" onClick={() => void handleDelete()} disabled={deleteDisabled}>
709+
{deleteMutation.isPending ? t("删除中...") : t("删除平台")}
710+
</Button>
523711
</div>
524-
<Button variant="danger" onClick={() => void handleClearAllLeases()} disabled={clearLeasesMutation.isPending}>
525-
{clearLeasesMutation.isPending ? t("清除中...") : t("清除所有租约")}
526-
</Button>
527712
</div>
713+
</section>
528714

529-
<div className="platform-op-item">
530-
<div className="platform-op-copy">
531-
<h5>{t("删除平台")}</h5>
532-
<p className="platform-op-hint">{t("永久删除当前平台及其配置,操作不可撤销。")}</p>
715+
<section id={LEASE_MANAGEMENT_ANCHOR} className="platform-lease-section">
716+
<div className="platform-drawer-section-head platform-lease-head">
717+
<div>
718+
<h4>{t("租约管理")}</h4>
719+
<p>{t("查看当前平台的租约绑定,并按账号释放单个租约。")}</p>
533720
</div>
534-
<Button variant="danger" onClick={() => void handleDelete()} disabled={deleteDisabled}>
535-
{deleteMutation.isPending ? t("删除中...") : t("删除平台")}
721+
<Button
722+
variant="secondary"
723+
size="sm"
724+
onClick={() => void leaseQuery.refetch()}
725+
disabled={leaseQuery.isFetching}
726+
>
727+
<RefreshCw size={16} className={leaseQuery.isFetching ? "spin" : undefined} />
728+
{t("刷新")}
536729
</Button>
537730
</div>
538-
</div>
539-
</section>
731+
732+
{leaseQuery.isLoading ? <p className="muted">{t("正在加载租约数据...")}</p> : null}
733+
734+
{leaseQuery.isError ? (
735+
<div className="callout callout-error">
736+
<AlertTriangle size={14} />
737+
<span>{formatApiErrorMessage(leaseQuery.error, t)}</span>
738+
</div>
739+
) : null}
740+
741+
{!leaseQuery.isLoading && !leases.length ? (
742+
<div className="empty-box">
743+
<Sparkles size={16} />
744+
<p>{t("当前平台暂无租约")}</p>
745+
</div>
746+
) : null}
747+
748+
{leases.length ? (
749+
<DataTable
750+
data={leases}
751+
columns={leaseColumns}
752+
getRowId={(lease) => lease.account}
753+
className="data-table-leases"
754+
wrapClassName="platform-lease-table-wrap"
755+
/>
756+
) : null}
757+
758+
<OffsetPagination
759+
page={leasePage}
760+
totalPages={leaseTotalPages}
761+
totalItems={leasesPage.total}
762+
pageSize={leasePageSize}
763+
pageSizeOptions={LEASE_PAGE_SIZE_OPTIONS}
764+
onPageChange={setLeasePage}
765+
onPageSizeChange={changeLeasePageSize}
766+
/>
767+
</section>
768+
</div>
540769
) : null}
541770
</Card>
542771
</>

0 commit comments

Comments
 (0)