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, }