feat(traffic): 流量列表新增「清空全部」,并借空库时机压实索引
一次删除全部流量记录,忽略当前筛选条件。与按 host 删除的两条路径相比多做 两件事: - 清理索引已不再记录的历史 host 目录。按 host 删除只能从索引反查目录, 行已消失的孤立目录会一直留着;「清空全部」既然语义是不留残余,就直接 扫顶层目录(跳过 _index/_blobs/_ca/_delete_staging)。 - 收尾做一次全量压实(optimize + VACUUM + wal_checkpoint(TRUNCATE)),把 索引占用的磁盘空间还给系统,并把实际释放量返回给前端提示。 VACUUM 的耗时与临时空间只和它保留的内容成正比,所以空库是做全量压实唯一 免费的时机——也正因如此,这一步顺带成为已有实例的转换途径:auto_vacuum 只能通过 VACUUM 生效,清空一次之后旧库即转为增量回收模式,此后日常按 host 删除就能自行回收空间(TestDeleteAllConvertsLegacyIndex 覆盖)。 已绑定到漏洞的流量证据保存在独立的证据库中,不受影响——绑定时已拷出独立 副本,正是为了让热流量可以随时清空而不带走证据。确认弹窗里明确写了这点。 前端把「清空全部」做成 outline 样式而非第二个 destructive 按钮:它忽略一切 筛选条件,不该看起来和「删除该目标」只差一次误点;无流量时禁用。 测试:新增 TestDeleteAllPurgesAndCompacts(清空后索引、正文、blob 引用与 孤立目录全清,空间回收到 1/8 以下,清空后仍可继续录制)与 TestDeleteAllConvertsLegacyIndex(旧库经清空后转为增量回收,随后普通删除 即可回收)。
This commit is contained in:
1 parent
61d486160e
commit
2f5f7c0227
6 files changed
+314
-24
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
ArrowUpNarrowWideIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
EraserIcon,
|
||||
FilterXIcon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
||||
@@ -56,7 +58,9 @@ function fmtTime(ts: string) {
|
||||
|
||||
function fmtBytes(n: number) {
|
||||
if (n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB"];
|
||||
// GB matters for the reclaimed-space figure a full purge reports; a capture-heavy
|
||||
// instance can hand back several.
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
||||
const v = n / 1024 ** i;
|
||||
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
||||
@@ -142,7 +146,7 @@ export default function TrafficPage() {
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("desc");
|
||||
|
||||
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | null>(null); // null = dialog closed
|
||||
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
|
||||
|
||||
@@ -245,22 +249,50 @@ export default function TrafficPage() {
|
||||
[hosts, hostCountSortDirection],
|
||||
);
|
||||
|
||||
// "清空" for the unfiltered purge, "删除" for the host-scoped ones — the dialog's
|
||||
// title and its confirm button both follow from which is in play.
|
||||
const deleteVerb = deleteMode === "all" ? "清空" : "删除";
|
||||
const deleteTitle = deleteMode
|
||||
? {
|
||||
all: "清空全部流量记录?",
|
||||
selected: `删除选中的 ${selectedHosts.length} 个目标的全部流量?`,
|
||||
filter: "删除该目标的全部流量?",
|
||||
}[deleteMode]
|
||||
: "";
|
||||
|
||||
// `reclaimed` only comes back from the full purge; the host-scoped deletions
|
||||
// report the row count alone.
|
||||
const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => {
|
||||
if (mode === "all") return api.trafficDeleteAll();
|
||||
if (mode === "selected") return api.trafficDeleteHosts(selectedHosts);
|
||||
return api.trafficDeleteHost(hostQ);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteMode) return;
|
||||
setDeleting(true);
|
||||
const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ);
|
||||
p.then(() => {
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (deleteMode === "selected") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
const mode = deleteMode;
|
||||
requestDelete(mode)
|
||||
.then((r) => {
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (mode !== "filter") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
if (mode === "all") {
|
||||
// Reclaimed space is the whole point of compacting an emptied index, so say so.
|
||||
const reclaimed = r.reclaimed ?? 0;
|
||||
const freed = reclaimed > 0 ? `,释放 ${fmtBytes(reclaimed)} 存储` : "";
|
||||
toast.success(`已清空 ${r.deleted} 条流量${freed}`);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
})
|
||||
.catch((e) => {
|
||||
// Keep the confirmation open so the user can retry a failed deletion.
|
||||
if (mode === "all") toast.error(`清空失败:${(e as Error).message}`);
|
||||
})
|
||||
.finally(() => setDeleting(false));
|
||||
};
|
||||
@@ -434,6 +466,19 @@ export default function TrafficPage() {
|
||||
<Trash2Icon className="size-3.5" />
|
||||
删除该目标
|
||||
</Button>
|
||||
{/* Outline rather than a second destructive button: this one ignores every
|
||||
filter, so it must not look one mis-click away from "删除该目标". */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={!traffic?.count || deleting}
|
||||
title={traffic?.count ? "删除全部流量并压实存储" : "当前没有流量记录"}
|
||||
onClick={() => setDeleteMode("all")}
|
||||
>
|
||||
<EraserIcon className="size-3.5" />
|
||||
清空全部
|
||||
</Button>
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -735,13 +780,19 @@ export default function TrafficPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{deleteMode === "selected"
|
||||
? `删除选中的 ${selectedHosts.length} 个目标的全部流量?`
|
||||
: "删除该目标的全部流量?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>{deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteMode === "selected" ? (
|
||||
{deleteMode === "all" && (
|
||||
<>
|
||||
将永久删除全部 <span className="font-semibold tabular-nums">{traffic?.count ?? 0}</span>{" "}
|
||||
条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{deleteMode === "selected" && (
|
||||
<>
|
||||
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
|
||||
<span className="font-mono">
|
||||
@@ -750,7 +801,8 @@ export default function TrafficPage() {
|
||||
</span>
|
||||
)的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{deleteMode === "filter" && (
|
||||
<>
|
||||
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||
的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||
@@ -768,7 +820,7 @@ export default function TrafficPage() {
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "删除中…" : "确认删除"}
|
||||
{deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in new issue
Block a user