diff --git a/src/security/LogTab.i18n.ts b/src/security/LogTab.i18n.ts
index c74ad479..c5a4ec9f 100644
--- a/src/security/LogTab.i18n.ts
+++ b/src/security/LogTab.i18n.ts
@@ -7,6 +7,12 @@ export const logTabText = messages(
{
time: "时间",
type: "类型",
+ /** 「类型」列头的筛选:菜单第一项 */
+ allTypes: "全部类型",
+ /** 筛着一类、这段时间里没有这一类的。区间的写法同 `emptyIn` */
+ noneOfType: (guard: string, range: string, custom: boolean) =>
+ custom ? `${range}无${guard}记录` : `${range}内无${guard}记录`,
+ showAllTypes: "显示全部类型",
hit: "命中",
action: "处置",
request: "请求",
@@ -36,6 +42,10 @@ export const logTabText = messages(
{
time: "Time",
type: "Type",
+ allTypes: "All types",
+ noneOfType: (_guard: string, range: string, custom: boolean) =>
+ custom ? `No entries of this type ${lower(range)}` : `No entries of this type in the last ${range}`,
+ showAllTypes: "Show all types",
hit: "Match",
action: "Action",
request: "Request",
diff --git a/src/security/LogTab.tsx b/src/security/LogTab.tsx
index 8cf5ffd3..bf7fb0c8 100644
--- a/src/security/LogTab.tsx
+++ b/src/security/LogTab.tsx
@@ -1,8 +1,16 @@
import { Fragment, useCallback, useState } from "react";
-import { ArrowRightIcon, ChevronRightIcon, DownloadIcon, ShieldCheckIcon, ShieldOffIcon } from "lucide-react";
+import { ArrowRightIcon, ChevronDownIcon, ChevronRightIcon, DownloadIcon, ShieldCheckIcon, ShieldOffIcon } from "lucide-react";
import { Badge } from "@/ui/badge";
import { Button } from "@/ui/button";
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/ui/dropdown-menu";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/ui/dropdown-menu";
import { UpstreamLogo, ClientLogo } from "@/ui/logos";
import { rowMotion, usePresentList } from "@/ui/motion";
import { notify, usePending } from "@/ui/notify";
@@ -39,7 +47,8 @@ export interface LogActions {
}
/**
- * 安全日志:一行是一次命中,三项防护的都在一张表里,「类型」一列分得开。
+ * 安全日志:一行是一次命中,三项防护的都在一张表里,「类型」一列分得开。**筛选在
+ * 「类型」的列头上**:点开选一项,只列这一项的;工具条只放区间和导出。
*
* **按天分组,读起来是一条时间线**:一天一个标题(今天、昨天、9月23日周三),
* 行上只写时刻。每行先说处置(状态点:切断、拒绝红,删除、替换绿,仅记录琥珀),
@@ -49,18 +58,24 @@ export interface LogActions {
* 具体做了什么。不换页、不开浮层 —— 排查时常要几条对照着看。那次请求本身在详情里的
* 链接和行菜单的「查看请求」里,打开右侧的请求详情;会话的链接跳到流量页的那次会话。
*
- * 工具条右边是导出:这段时间里的全部命中,JSON(完整细节)或 CSV(一条一行)。
+ * 工具条右边是导出:这段时间里的全部命中(按类型筛着就是这一类的),JSON(完整细节)
+ * 或 CSV(一条一行)。
*/
export function LogTab({
log,
range,
onRange,
+ guard,
+ onGuard,
detail,
actions,
}: {
log: SecurityLog;
range: Range;
onRange: (r: Range) => void;
+ /** 「类型」列头上筛着的那一项,`null` 是全部 */
+ guard: Guard | null;
+ onGuard: (g: Guard | null) => void;
detail: SecurityDetail | undefined;
actions: LogActions;
}) {
@@ -82,14 +97,15 @@ export function LogTab({
-
+
}
errorTitle={t.loadFailed}
- isEmpty={(p) => p.events.length === 0}
+ // 筛着一类时空的是这一类:表照样画,列头上的筛选留在原处,空的那一句在表里说
+ isEmpty={(p) => p.events.length === 0 && guard === null}
empty={
allOff ? (
void run(async () => {
const from = windowStart(range);
const to = Date.now();
- const events = await fetchAll(api.events, from, to);
+ const events = await fetchAll(api.events, from, to, guard);
const text = format === "json" ? toJson(events, from, to) : toCsv(events);
const path = await api.saveExport(fileName(format), text);
if (path !== null) notify.success(t.exported(events.length), path);
@@ -169,12 +188,50 @@ function ExportMenu({ range, disabled }: { range: Range; disabled: boolean }) {
);
}
+/**
+ * 「类型」的列头:点开选一项,只列这一项的。筛着时列头写的就是那一项(亮色),一眼看得出
+ * 表里不是全部;菜单第一项回到全部。
+ */
+function TypeHead({ guard, onGuard }: { guard: Guard | null; onGuard: (g: Guard | null) => void }) {
+ const t = useText(logTabText);
+ const lt = useText(securityLabelsText);
+ return (
+
+
+
+ {/* `sm` 档的字和别的列头一样大(`xs` 小一号),高度压到 24 放进 32 的表头 */}
+
+
+
+ onGuard(v === "" ? null : (v as Guard))}>
+ {t.allTypes}
+
+ {GUARDS.map((g) => (
+
+ {lt.guards[g]}
+
+ ))}
+
+
+
+
+ );
+}
+
/** 一条命中的详情那一行的 id:展开的行用 `aria-controls` 指着它 */
const detailId = (id: number) => `security-hit-${id}`;
function LogTable({
events,
more,
+ range,
+ guard,
+ onGuard,
detail,
actions,
onOpen,
@@ -183,6 +240,9 @@ function LogTable({
}: {
events: SecurityEventView[];
more: boolean;
+ range: Range;
+ guard: Guard | null;
+ onGuard: (g: Guard | null) => void;
detail: SecurityDetail | undefined;
actions: LogActions;
onOpen: (requestId: number) => void;
@@ -251,14 +311,14 @@ function LogTable({
return (
<>
{/* 列宽是定死的:窗口再窄,「命中」一列也要留出能读的宽度,放不下就横向滚 */}
-
+
{/* 时刻前面一个展开的箭头 */}
{/* 「● Recorded」要 86 */}
- {/* 类型:英文的「Redaction」「Tool call」 */}
-
+ {/* 类型:「密钥与隐私」。列头筛着它时后面还跟一个箭头,Windows 的 14px 也放得下 */}
+
{/* 时刻一列宽出来的那一截从这里让:放不下时先截的是密钥,见下 */}
@@ -268,13 +328,28 @@ function LogTable({
{t.time}
{t.action}
- {t.type}
+
{t.hit}
{t.request}
+ {guard !== null && shown.length === 0 && (
+
+
+ }
+ title={t.noneOfType(lt.guards[guard], range.label, range.custom === true)}
+ action={
+
+ }
+ />
+
+
+ )}
{days.map((day, i) => {
const head = dayHead(day.at);
// 最后一天可能还没翻完:条数只写读全了的那几天
diff --git a/src/security/SecurityPage.tsx b/src/security/SecurityPage.tsx
index 50c752e7..b53a0606 100644
--- a/src/security/SecurityPage.tsx
+++ b/src/security/SecurityPage.tsx
@@ -19,7 +19,7 @@ import { securityLabelsText } from "./labels.i18n";
import { LogTab, type LogActions } from "./LogTab";
import { BuiltinRuleDialog, DeleteRuleDialog, patternOf, RuleDialog, TestDialog, type RuleSeed } from "./RuleDialog";
import { securityPageText } from "./SecurityPage.i18n";
-import { useSecurityLog, type SecurityLog } from "./useSecurityLog";
+import { narrowed, useSecurityLog, type SecurityLog } from "./useSecurityLog";
import { ObserveHint } from "@/guide/PageHints";
export type SecurityTab = "log" | Guard;
@@ -95,7 +95,15 @@ export default function SecurityPage({
const detail = useResource("security-detail", () => api.detail(), { deps: [configVersion] });
// 日志的区间放在这一层:页头的命中次数和日志是同一次读取
const [range, setRange] = useRange("tw-security-range", "1d");
- const log = useSecurityLog(range, tick);
+ /** 日志「类型」列头上筛着的那一项。离开这一页就回到全部 */
+ const [guard, setGuard] = useState(null);
+ /*
+ **页头说的一直是全部类型**:它是整页的全貌,不跟着日志的筛选变。全部的那一份一直
+ 挂着,不筛时日志就用它(还是同一次读取);筛着一类时另读那一类的
+ */
+ const all = useSecurityLog(range, tick);
+ const only = useSecurityLog(range, tick, guard, guard === null);
+ const log = guard === null ? all : narrowed(only, all, guard);
const [dialog, setDialog] = useState(null);
/*
@@ -106,6 +114,8 @@ export default function SecurityPage({
if (!focus) return;
setTab("log");
setRange(focus.range);
+ // 概览上的数是全部类型的:点进来看到的条数要对得上
+ setGuard(null);
}, [focus, setRange]);
/*
@@ -289,7 +299,7 @@ export default function SecurityPage({
setTab(v as SecurityTab)} className="gap-0">
}
+ summary={}
tabs={
{t.log}
@@ -308,7 +318,15 @@ export default function SecurityPage({
d[g].mode === "observe").length : 0} className="mt-4" />
-
+
{GUARDS.map((g) => (
diff --git a/src/security/export.test.ts b/src/security/export.test.ts
index b704c51f..66d4dcb1 100644
--- a/src/security/export.test.ts
+++ b/src/security/export.test.ts
@@ -63,6 +63,21 @@ describe("导出读完整段", () => {
expect(got.map((e) => e.id)).toEqual(all.map((e) => e.id));
expect(asked.map((q) => q.before)).toEqual([null, 704, 204]);
expect(asked.every((q) => q.from_ms === 1000 && q.to_ms === 2000 && q.limit === 500)).toBe(true);
+ expect(asked.every((q) => q.guard === null)).toBe(true);
+ });
+
+ it("日志按类型筛着时,每一页都只要这一项的", async () => {
+ const asked: SecurityEventsQuery[] = [];
+ const zero = { recorded: 0, replaced: 0, cut: 0, stripped: 0, blocked: 0 };
+ const page = async (q: SecurityEventsQuery): Promise => {
+ asked.push(q);
+ return q.before == null
+ ? { events: [hit({ id: 9, guard: "inspect_tools" })], more: true, total: 2, by_outcome: zero }
+ : { events: [hit({ id: 3, guard: "inspect_tools" })], more: false, total: 2, by_outcome: zero };
+ };
+ const got = await fetchAll(page, 1000, 2000, "inspect_tools");
+ expect(got.map((e) => e.id)).toEqual([9, 3]);
+ expect(asked.map((q) => q.guard)).toEqual(["inspect_tools", "inspect_tools"]);
});
});
diff --git a/src/security/export.ts b/src/security/export.ts
index 9cd7f92f..0cfaf46b 100644
--- a/src/security/export.ts
+++ b/src/security/export.ts
@@ -10,6 +10,7 @@
import { textOf } from "@/i18n";
import { appLabel } from "@/labels";
import type {
+ Guard,
HitLocation,
OutcomeDetail,
SecurityEventView,
@@ -32,13 +33,18 @@ type Page = (q: SecurityEventsQuery) => Promise;
/**
* `[from, to)` 里的全部命中,按时间倒序(和日志一样)。按 `before` 往前翻,直到 core 说
- * 没有更多。
+ * 没有更多。给了 `guard` 就只要这一项的:日志按类型筛着,导出的就是看到的那些
*/
-export async function fetchAll(page: Page, from: number, to: number): Promise {
+export async function fetchAll(
+ page: Page,
+ from: number,
+ to: number,
+ guard: Guard | null = null,
+): Promise {
const out: SecurityEventView[] = [];
let before: number | null = null;
for (;;) {
- const p = await page({ from_ms: from, to_ms: to, before, limit: MAX_PAGE });
+ const p = await page({ guard, from_ms: from, to_ms: to, before, limit: MAX_PAGE });
out.push(...p.events);
const last = p.events[p.events.length - 1];
if (!p.more || !last) return out;
diff --git a/src/security/useSecurityLog.ts b/src/security/useSecurityLog.ts
index a8a345b2..18810e68 100644
--- a/src/security/useSecurityLog.ts
+++ b/src/security/useSecurityLog.ts
@@ -2,7 +2,7 @@ import { useRef } from "react";
import { useResource, type Resource } from "@/lib/resource";
import { usePending } from "@/ui/notify";
import { windowStart, type Range } from "@/ui/range";
-import type { SecurityEventsPage } from "@/types";
+import type { Guard, SecurityEventsPage } from "@/types";
import { api } from "./api";
/** 一次读多少条。**一屏半** —— 再多就是替用户翻他不会看的那几页 */
@@ -31,16 +31,20 @@ export interface SecurityLog {
*
* 时间窗的起点和概览用同一个函数算(`windowStart`):从概览点进来时,条数是
* 那几个数之和。
+ *
+ * `guard` 只读这一项的(日志「类型」列头的筛选),总数和各做法的条数也只数这一项。
+ * `skip` 什么都不读:页面一直挂着全部的那一份,筛选着的时候才挂这一份。
*/
-export function useSecurityLog(range: Range, tick: number): SecurityLog {
+export function useSecurityLog(range: Range, tick: number, guard: Guard | null = null, skip = false): SecurityLog {
// 自定义区间按起点认:它的 `ms` 是建它那一刻量的,同一天再选一次就是另一个数
const scope = `${range.from ?? range.ms}|${range.live ? 1 : 0}|${range.custom ? 1 : 0}`;
/** 已经翻出来多少条。重读时照这个数读,翻过的不丢 */
const loaded = useRef(0);
const r = useResource(
- `security-log:${scope}`,
+ skip ? null : `security-log:${scope}:${guard ?? "all"}`,
() =>
api.events({
+ guard,
from_ms: windowStart(range),
limit: Math.min(MAX_PAGE, Math.max(PAGE, loaded.current)),
}),
@@ -54,7 +58,7 @@ export function useSecurityLog(range: Range, tick: number): SecurityLog {
void run(async () => {
const last = r.data?.events[r.data.events.length - 1];
if (!last) return;
- const p = await api.events({ from_ms: windowStart(range), before: last.id, limit: PAGE });
+ const p = await api.events({ guard, from_ms: windowStart(range), before: last.id, limit: PAGE });
// 读的这一会儿列表被重读过(来了新记录):尾巴已经不是这一页接得上的那一条,
// 这一页不接,免得中间缺一段。再点一次就从新的尾巴往下读。
// 总数和各做法的条数说的是整段时间,哪一页带来的都一样:取新的这一页的
@@ -67,3 +71,15 @@ export function useSecurityLog(range: Range, tick: number): SecurityLog {
return { r, scope, loadMore, loadingMore };
}
+
+/**
+ * 日志按类型筛着时画哪一份。这一类读到过就是它(缓存,后台再读);**刚换到一类、还没
+ * 读到**时,先把全部的那一份里这一类的挑出来画着 —— 不然表换成骨架,列头上的筛选
+ * 跟着消失、焦点丢掉。读到了无声换成这一类的。读失败了照常报错,不拿挑出来的顶着
+ */
+export function narrowed(only: SecurityLog, all: SecurityLog, guard: Guard): SecurityLog {
+ const page = all.r.data;
+ if (only.r.data !== undefined || !only.r.loading || !page) return only;
+ const events = page.events.filter((e) => e.guard === guard);
+ return { ...only, r: { ...only.r, data: { ...page, events }, loading: false } };
+}
diff --git a/src/ui/dropdown-menu.tsx b/src/ui/dropdown-menu.tsx
index 31c2d2ba..77b2becd 100644
--- a/src/ui/dropdown-menu.tsx
+++ b/src/ui/dropdown-menu.tsx
@@ -154,6 +154,42 @@ function DropdownMenuCheckboxItem({
)
}
+/*
+ * 几个里选一个(列头的筛选)。勾在右边,和 `DropdownMenuCheckboxItem` 同一个位置:
+ * 单选、多选长得一样,区别只在读屏软件听到的是 menuitemradio 还是 menuitemcheckbox
+ */
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
export {
DropdownMenu,
DropdownMenuTrigger,
@@ -165,4 +201,6 @@ export {
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
}