Skip to content

Latest commit

 

History

History
381 lines (233 loc) · 76.3 KB

MONITOR_GUIDE.md

File metadata and controls

381 lines (233 loc) · 76.3 KB

Monitor 使用指南

Monitor 是 Trajecta 的本地 Web 工作台,由 internal/monitor 提供 API 与前端静态资源,和代理运行在同一个进程里(monitor.port)。它面向三个场景:

  • 查看真实 LLM HTTP 请求与原始协议。
  • 分析 session、模型、模型服务商、路由和失败。
  • 管理模型服务商、模型、路由设置、分析任务与个人 API token。

启动与登录

配置 monitor.port 后,serve 启动时会同时挂载管理端口,浏览器打开 http://localhost:<monitor.port> 即可进入 Monitor。

首次部署需要创建用户。tracked 默认配置 config/config.yaml 使用 Postgres 且 database.dsn 为空,运行前需导出 TRAJECTA_DATABASE_DSN;纯本地运行可改用 config/examples/local-sqlite.yaml:

export TRAJECTA_DATABASE_DSN='postgres://user:pass@host:5432/trajecta?sslmode=disable'
go run ./cmd/trajecta auth init-user -c config/config.yaml --username admin --password 'change-me-123'

Monitor 使用用户名密码登录(POST /api/auth/login),成功后签发仅用于 Monitor 的 JWT:issuer 为 trajecta-monitor,audience 为 trajecta-monitor-ui,TTL 默认 24 小时,可用 auth.session_ttl 调整。前端把 JWT 存在浏览器 localStorage,并以 Authorization: Bearer 访问 Monitor API;该 JWT 不用于 SDK、proxy 或 MCP。

左侧导航底部的账号入口(aria-label 为「账户」)打开一个向上弹出的浮层面板(Radix Popover,封装在 web/monitor-ui/src/components/ui/popover.tsx),而不是居中对话框。面板提供主题(深色、浅色、跟随系统)、语言(dark/light/system 与 zh-CN/en,都保存在当前浏览器)、修改密码(POST /api/auth/password)和退出登录。主题与语言各自是一个 Radix RadioGroup(role="radiogroup" + role="radio",方向键在组内移动并选中,整组只有一个 Tab 停靠点),选中后面板不关闭,两项可以在一次打开里都改完;改动语言会动态拉取另一个语言 chunk。Radix 把面板 portal 到 body 并按触发按钮定位,side="top" 让它开在按钮上方,align="start" 让它与按钮左边缘对齐,因此面板按视口而非按 264px 的侧边栏测量——它会横向盖出侧边栏,这正是之前被截断的原因;空间不足时 popper 会自动翻到下方而不是移出屏幕,collisionPadding 保证它不贴边。Esc 关闭、点击外部关闭、role="dialog" 与关闭后把焦点还给触发按钮都由 primitive 提供;焦点还原要求触发按钮与面板处在同一个 Popover root 内,所以 PrimaryNav 用一层 Popover 同时包住两者。

对话框仍用于真正需要打断的表单:修改密码(从面板进入,DialogContent 的 onCloseAutoFocus 显式把焦点还给账号按钮,因为这个表单没有自己的触发按钮)、模型服务商列表页的新建弹窗、模型服务商详情页的编辑弹窗(ChannelDetailPage 里的 EditProviderDialog,触发按钮与对话框同处一个 Dialog root)以及 trace 详情页的工具弹窗,都走 web/monitor-ui/src/components/ui/dialog.tsx(@radix-ui/react-dialog)。焦点陷阱、Esc 关闭、遮罩点击关闭、role="dialog" 与 aria-modal 语义、背景滚动锁定都由这个 primitive 提供,src/ 里已没有自建的 portal。serve 总是挂载 auth store,所以 /api/auth/status 返回 auth_required: true;只有在没有挂载 auth store 的嵌入式/测试场景下才返回 false,此时前端以 local 用户直接进入。

个人 API token

「令牌」页面(/api/auth/tokens)管理当前用户的个人 API token:创建、撤销、删除。列表列为 name、prefix、status、created、expires、last_used。

创建不是一个常驻卡片,而是列表页头部的按钮:点击后弹出对话框(TokensPanel 的 CreateTokenDialog)。表单只有两项——名称,以及到期时间(datetime-local 选择器)。到期时间默认为空,代表永不过期;填了就必须晚于当前时间,对话框把它换算成 API 接受的 ttl 时长(<秒数>s)再提交,ttl 本身不出现在表单和列表里。scope 只有 api 一个取值,既不可选、也不作为列表列显示(每行都是同一个词),创建时固定写 api 存入记录。创建成功后表单原位换成 token 明文:令牌只显示这一次,对话框保持打开,明文右侧是复制按钮。取消、关闭或点「完成」后原文不可再取,只保留 prefix。

复制按钮先用异步 Clipboard API,失败或该 API 不存在(例如 Monitor 通过纯 HTTP 的局域网地址访问时就不是安全上下文)时退回 document.execCommand("copy");两条路都失败会提示手动复制,明文本身用 user-select: all,单击即整段选中,因此手工 Ctrl+C 始终可行(web/monitor-ui/src/lib/clipboard.js)。

新签发的 token 形如 sk-<base64url>,与 OpenAI 的凭据形状一致;校验只看数据库里的 SHA-256 哈希,不看前缀,因此重命名之前签发的 llmtl_ token 继续可用。个人 token 的用途:

  • 代理 API:Authorization: Bearer <token>,proxy 的每个请求都要通过该 token 鉴权。
  • MCP:Authorization: Bearer <token>。

Monitor API 本身只接受登录 JWT,不使用个人 token。

数据来源

Monitor 同时使用两类数据:

  • application database(生产环境为 Postgres,本地 fallback 为 SQLite,默认文件 {{output_dir}}/trajecta.sqlite3):trace 索引、session、列表/过滤/分页/聚合、模型服务商与模型配置、模型别名、路由设置、事件、analysis run/job、Responses 状态和 audit 表。
  • raw .http cassette:trace 详情、raw protocol 与 replay-safe 检查的事实来源。路由事实在数据库里是 logs 上的派生列,原始 routing.* 事件仍记录在 cassette prelude 中。

因此列表页只读数据库索引、响应很快,详情页仍能回到原始 HTTP 证据。GET /api/routing/summary 也完全走数据库:buildRoutingSummary 调 store.RoutingSummary(since, model),对 logs 上的路由列做一次 GROUP BY,不再打开任何 cassette。计数是按 trace 而不是按路由事件:每条 trace 只保留每个事实的最后一个非空值(internal/recorder/recorder.go 的 setLastNonEmpty),所以同一条 trace 里的多次 sticky 事件折叠成最后一次。只要 selected_upstream_id、route_target_id、channel_id、credential_id、sticky_status、routing_failure_reason 任一非空,这条 trace 就算「有事件」;legacy_or_missing_events = total_traces - eventful_traces。迁移前的行不一定算 legacy:selected_upstream_id 是迁移前就有的列,这类 trace 仍算有事件,只是 route_target_id / channel_id / credential_id 与 sticky 拆分为空,直到一次重索引从 prelude 重新推导这五列。

页面

左侧主导航分三组共 10 项:监控(概览、流量、事件、质量)、配置(模型服务商、模型、路由、接入)、系统(系统、关于)。导航模型只有一份,声明在 web/monitor-ui/src/lib/nav.js:侧边栏与快速导航面板都从它生成,新增一个目的页不会出现「侧边栏有、面板没有」的第二份清单。内容较多的页面用页内标签页(tab)而不是继续增加导航项——标签页状态保存在 URL 的 tab 查询参数里(例如 /traces?tab=sessions),因此可以收藏与分享。标签条由 Radix Tabs 实现:整条 strip 只占一个 tab stop,方向键与 Home/End 移动选中项,只有选中的 tab 是 tabindex=0,tab 与它所控制的 panel 用 aria-controls / aria-labelledby 互指。导航栏底部、账号按钮上方是构建、连接状态与仓库合成的一行:左边显示当前运行的版本号并链到关于页,右边是实时连接状态图标与 GitHub 图标,前两者与仓库地址都来自 GET /api/about(连接状态来自本地 socket,见「实时推送」)。三者在展开状态下同处一行,不再为单个图标多占一行;版本号在窄屏与折叠状态的侧边栏里隐藏,三个图标等分那一列(每个 18px)。侧栏在 1180px 以下为 72px 宽,720px 以下收窄到 64px;两种窄栏都保留 56px 内容宽,三个 18px 图标仍能同处一行。

品牌下方的「快速导航」按钮(或任意页面按 Ctrl K / ⌘ K)打开命令面板:输入即过滤十个目的页,也可以执行「刷新数据」「切换明暗主题」「切换语言」三个动作。面板是 Radix Dialog(portal、焦点陷阱、Esc、焦点归还都由 primitive 提供),输入框保持焦点,↑ / ↓ / Home / End 移动高亮行、Enter 打开;搜索框用 aria-activedescendant 指向当前行,读屏器能读出高亮项。目的页列表来自 navGroups(),动作只有刷新与两个偏好切换,因此面板不会成为第二套导航。面板与新增的「刷新数据」都不引入新的轮询:刷新只是让 TanStack Query 失效当前读请求,实时主题仍然负责后台更新。

下面按导航顺序说明路由与用途。

侧边栏可以折叠成只有图标的窄栏,折叠状态保存在浏览器 localStorage(trajecta.monitor.sidebar.collapsed)。折叠后品牌区块整体让位给展开按钮,因此折叠始终可逆;宽度不足 1180px 时布局固定为窄栏,与折叠状态无关,此时展开按钮不显示。窄栏里每个导航项只剩图标:文字标签被移出布局但仍留在无障碍树中(因此链接的可访问名称不变),并作为 title 在悬停时显示——此前标签留在文档流里被 overflow: hidden 从中间截断,既像渲染故障,又让图标没有可读的名字。

每个页面顶部只有一个页头(web/monitor-ui/src/components/common/PageHeader.jsx):一行标题,右侧放作用于整页的控件(如今 / 近 7 天 / 近 30 天 / 全部)。标题上方不再有任何一行:原先由 CSS ::before 画出的英文 "CONTROL PLANE" 已经去掉,一度替换它的导航分组名(监控 / 配置 / 系统)也已去掉——侧边栏已经标出读者在哪一组、在哪一项,再在每页标题上重复一次只是读正文前多读一行。页头用 position: sticky 贴在内容区顶部,滚动时留下一条半透明(backdrop-filter)的横条,而不是把页面切成两段。粘性页头会盖住浏览器自己滚动到的位置,而这正是焦点向上移动时控件落脚的地方(WCAG 2.2「焦点不被遮挡」),因此 AppShell 用 ResizeObserver 量出当前页头的真实高度写进 --sticky-chrome-h,html 的 scroll-padding-top 据此留出边距——列表页 71px、trace 详情页 155px、390px 手机上页头换行后 121px,都按实测值而不是猜一个常数。标题同时写入 document.title(概览 · Trajecta),这是多个标签页同时打开时能分辨它们的原因。键盘读者按 Tab 的第一站是「跳到主要内容」链接,它指向页头之外的内容元素;路由切换后焦点也落在同一个元素上,用来告诉屏幕阅读器换页了。

trace 列表、trace 详情、会话详情与上游详情上的指标统一渲染为「图标 + 数值」的紧凑标签:可见部分只有图标和格式化后的数值,人类标签与未格式化的原始值放在 title 悬停提示里(以 · 连接)。数值按可读宽度自动换算——耗时超过 60 秒改用分钟/小时(125000 ms → 2m 5s,3725000 ms → 1h 2m),token 速率超过 1000 后按 k/M 缩写且百位以上取整(112055 tok/s → 112k tok/s),token 数量沿用 k/M/B 缩写(112055 → 112.1K)。

监控

概览 /overview

时间窗口内的请求量、成功/失败数、Token、平均 TTFT、延迟、发现项和系统事件概览。趋势图按请求量、Token、延迟三个面板展示,1200px 以上按 1.25:1:1 分宽,请求/失败图是主图;主要拆分收敛为一个面板,用分段控件在模型、模型服务商、端点、上游、路由失败、发现类别之间切换,而不是六列并排。概览的数据由实时 socket 推送(见「实时推送」一节),并给出两条去向链接:质量页的发现项与事件收件箱。已配置模型服务商的卡片列表与派生数据健康度不再放在这里——前者的完整版本在「模型服务商」页,后者是解析管道的属性而不是最近一小时流量的属性,现已移到「质量 → 数据健康」标签页。概览页只向 GET /api/overview 请求 sections=traffic 这一半,解析管道的 sections=health 由数据健康页单独请求:健康段按设计覆盖全部 trace,两页都不再为对方不需要的聚合扫描整张 logs / trace_findings。右侧系统事件角标与导航栏共用同一个 GET /api/events/summary?window=all,因此一次页面加载只有一次摘要请求,两处数字也必然一致。六列 KPI 读数条在 1440px 一排;所有统计条在 900px 以下折成两列,720px 以下单元格内边距与数值再缩小一档;「需要复查」队列只保留模型、状态、预览和行操作,延迟与 token 读数留在流量列表与详情页,避免同一页重复两次指标。

所有统计组共用同一条读数条(src/styles/console.css 的 .hero-grid):4/5/6 个数值等分一排、1px 分隔线、12px 外圆角,每个单元格不再有自己的边框和阴影;独立于 Card 的组(概览、事件、流量、模型服务商、模型)带外框,Card 内的组(路由、质量→数据健康、系统、详情页、请求信息)只保留分隔线,由所在 Card 提供外框,避免卡片套卡片。单元格内边距 12px/16px(Card 内 10px/16px),数值 1.5rem,弱化文字 11px、详情 12px,整条高度约 58–89px;模型服务商与模型的汇总从原 Card 提到页头下方,和概览/事件同处一个位置。

流量 /traces

页面标题右侧是统一的 今天 / 近 7 天 / 近 30 天 / 全部 选择:请求与会话两个标签页共用同一个窗口,默认与全站一致为 今天,切换窗口会回到第 1 页。窗口在服务端是可选参数,GET /api/traces 不带 window 时仍然表示全部记录(该默认值面向早于窗口控件的调用方,控制台自己总是显式传参)。会话页顶部的 token 汇总与请求页一样按 K/M/B 压缩显示。

请求与会话两个标签页。

请求(默认):逐请求 trace 列表,支持过滤与分页(page_size 上限 200,超出按 200 处理,非正数按默认 50),展示 endpoint、model、状态码、duration、TTFT、token。1100px 以上五列弹性网格按 1.9fr / 72px / 1.3fr / 1.45fr / 118px 分配宽度,模型名不再在 1280px 被压到 100px,表头 sticky 在页头下方;1100px 以下每行变成一张紧凑卡(状态与操作在右轨、延迟一行),600px 以下只保留输入/输出/总量 token 与总耗时/TTFT 两条延迟读数,50 条 trace 不再把手机页面推到一万多像素。失败行不再整行染色,状态码仍是红色。可以进入 trace detail,也可以跳到对应的模型、模型服务商或路由上下文。列表数据来自应用库索引;分页器带页码输入(314k traces / 50 条一页时可直接跳到第 6,000 页),不再是只能前后翻。

  • 每行在 endpoint / 上游 / stream 标签之后还有一枚审计标签:数值是该 trace 已落库的审计事件总数(credential_mask_audits 的替换事件加 trace_findings 的发现项)。含身份证、手机号或敏感 Key 时标签为红色,只有拒答、工具错误等其它发现项时为黄色,没有任何审计事件时整枚标签不显示;悬停给出身份证/手机号、敏感 Key、其它发现、脱敏事件与发现项的分项。计数来自 GET /api/traces 的 audit 字段,每个来源对整个列表页各做一次分组查询,不逐条读 cassette。
  • 时间与会话那行下面显示该 trace 的第一条 user 消息(前 80 个字符,超出以 … 截断;system 提示不参与),由服务端从 cassette 的请求段读出后随列表的 request_preview 返回。请求体超过 4 MiB、读不到用户消息或 cassette 不可读时,该行不显示预览。
  • 过滤语义:model、endpoint、upstream 三个过滤项都按子串匹配且大小写不敏感,过滤值里的 %、_ 与反斜杠按字面量处理,不作为 LIKE 通配符——因此按 gpt_oss 过滤只会命中包含 gpt_oss 的 trace。统计、trace id 导出与 session 列表共用同一套过滤条件,结论必须与列表一致。
  • status 过滤只识别 success、error 与 failed(failed 是 error 的同义值,大小写不敏感;session 列表用的是同一套取值),其它取值不参与过滤、也不报错。trace 级别的 error/failed 还包含「HTTP 已返回 2xx、但记录里带 error_text」的 trace(例如流式响应中途断掉),而 session 列表的 failed 按状态码统计(与页面展示的 success_request/failed_request 同源),两者口径不同是刻意的。

会话:按 session 聚合最近 50 个会话,展示健康度、成功率、模型、模型服务商、流式标记与耗时。列表页顶部的时间窗口与统计只覆盖当前这一页的 50 条会话(分页器带页码输入,23,960 个会话/ 480 页可直接跳页),页面上的「本页范围」提示即说明这一点;它不是全库聚合。

  • 过滤语义:provider 匹配会话使用过的任一 provider;model 与搜索框 q 匹配会话中任一请求(因此一个「先 gpt-alpha、后 claude-beta」的会话按 gpt-alpha 也能查到);status 是会话级判断——success 表示该会话没有任何失败请求(非 2xx),failed / error 表示至少有一个,与列表展示的 success_request / failed_request 计数同源。
  • database.use_session_summary_read(TRAJECTA_DATABASE_USE_SESSION_SUMMARY_READ)打开后,会话列表改由派生读模型 session_summaries 提供;该模型只保存会话的最后一个模型、provider 列表与请求计数,因此无法表达「任一请求命中」的 model / q 过滤,这两类过滤始终走日志路径。两个读路径对同一过滤条件必须给出相同结果,门禁 TestSessionFiltersAgreeAcrossReadPaths 会同时跑两条路径并逐项比对。 代码默认关闭,仓库自带 config/config.yaml 已显式打开(参考部署已核对两条读路径一致);未完成 session_summaries 回填的部署应先核对再打开,方法见 PostgreSQL 运维。

session ID 的抽取顺序为:

  1. Session-Id / Session_id
  2. X-Claude-Code-Session-Id
  3. X-Codex-Turn-Metadata 中的 session_id
  4. X-Codex-Window-Id 的前缀

适合分析 Codex、Claude Code 等 agent 在一轮任务中多次模型调用的整体行为。

手动导出会话轨迹

会话详情页提供两个按钮:导出轨迹 (ATIF)(默认,带上限)与 完整导出 (NDJSON 流)(完整会话,流式)。两者都从该会话的客户端可见 cassette 重建轨迹并下载文件,不会调用模型、修改 cassette 或自动提交分析任务。

  • 格式固定为 ATIF-v1.8;默认响应是单个 trajectory 对象的 JSONL(一行,以换行结束),可拼接成多会话数据集。
  • 默认只重建最早的 500 条 trace(trajectoryTraceCap),以便默认视图在慢盘上仍有界;响应 extra 增加 trace_count(会话总 trace 数)、included_traces(本次导出条数)、truncated(是否被截断)与 trace_cap。被截断时页面提示"默认只导出最早的 N / M 条 trace",并引导使用完整导出。
  • ?full=1 取消上限,导出完整会话(仍是单对象 JSON)。
  • ?stream=1 使用 NDJSON 流式导出,每行一个 JSON 对象并逐条 flush,不在服务端累积整个会话:第一行 type=header(schema_version、session_id、agent、trace_count、included_traces、truncated、trace_cap);随后每个 trace 一行 type=trace(trace_id、steps、results、warnings、可选的 error),其中 results 是晚到、需要回挂到更早步骤的工具结果,带 step_id;从未收到结果的工具调用最后以 type=missing_tool_result 行报告;最后一行 type=final(final_metrics 与会话级 extra)。?stream=1&full=1 流式导出完整会话。服务端每读完一条 cassette 就 flush,请求 context 取消(客户端断开)时立即停止后续读取。
  • 默认(带上限)导出的结果缓存到 <trace.output_dir>/trajectory-cache/,键为 (session_id, last_trace_id, trace_count, limit),因此重看同一会话无需重建。会话只会追加 trace,且已写入的索引行不可变,所以只要轨迹会变,键就必然变化:命中即证明是同一条 trace 序列,无需失效逻辑。条目以临时文件加 rename 原子写入,最多保留 512 个条目 / 512 MiB,超出按写入时间淘汰;?full=1 与 ?stream=1 不读写该缓存。容量上限只影响命中率,未命中一律回落到重建。
  • 当前语义重建支持 Codex 使用的 OpenAI Responses generation(/responses、/v1/responses)及 SSE。其他 endpoint(包括 compact)保留源 trace 引用,并报告 unsupported_endpoint;不伪装成已解析的对话。
  • 请求按 recorded_at 与 trace ID 排序,输出按 Responses output_index 排序。会话归组依据保存在 extra.session_source,无法从 HTTP 证明全部事件的因果关系或任务已完成。
  • 相邻请求的历史上下文按有序重叠合并,不全局删除相同文本。previous_response_id 请求按增量输入处理。工具结果按调用 ID 回挂到发起调用的步骤。
  • 每个录制响应合并为一个 agent 步骤,而不是每个 SSE 事件一个步骤。多模态内容保留文本与占位引用,不生成外部附件;未知内容报告警告。reasoning summary 放在 extra.reasoning_summary,加密内容仅标记为已省略,不当作完整可读思维链。
  • 每步携带 trace_id、origin 与归一化 item 路径(流式输出先重建)。标准 metrics 每个响应记录一次 token 计数,final_metrics 汇总;不重复导出逐项 usage attribution、原生 item 或请求配置。内部 model child exchanges 不重复计入这份客户端视角导出;仅历史恢复的内容不推测 usage。
  • extra.warnings 报告文件缺失、无法解析、上下文不连续、缺失或孤立工具结果、流式中断等问题,下载完成后页面显示警告数量。completion=unknown 不将 HTTP 成功解释为任务成功。
  • 导出以一次查询得到的请求集合为快照,生成期间新增请求不进入本次文件。默认与 ?full=1 仍在服务端组装完整响应;极大会话应使用 ?stream=1。

接口为 GET /api/sessions/:sessionID/trajectory,可选参数 full 与 stream(1/true/yes/on 视为真),使用现有 Monitor JWT 登录鉴权,返回 application/x-ndjson 和附件下载头;默认文件名 session-<id>.atif.jsonl,流式为 session-<id>.atif.ndjson。无请求的会话返回 404。导出不依赖异步 Observation 是否已生成,而是从 V2/V3 cassette 重建。

事件 /events

Trajecta 自身的事件收件箱:

  • 来源:代码当前发出 parser、analyzer、router、upstream、secretmask;UI 的来源过滤下拉额外提供 proxy、recorder、monitor、store、auth、mcp,即过滤项比实际发出的来源更宽。
  • 类别:parse_failure、analysis_failure、analysis_job_failure、routing_failure、transport_error、credential_masked。
  • 状态:unread、read、resolved、ignored。
  • 级别过滤提供 critical、error、warning、info。

支持按状态、级别、来源过滤和搜索 fingerprint / trace / model / 消息;状态过滤里的「全部」是独立取值(status=all)而不是「未指定」,因为事件页默认落在未读,只有选择「全部」才能看到此前标为已读、已解决或已忽略的事件。支持单条标记已读、解决、忽略,以及「全部标为已读」——后者只把未读事件转为已读,status=all 不会把已解决事件改回已读。重复事件按 fingerprint 合并。导航上的未读角标由 GET /api/events/summary 与 SSE GET /api/events/stream 驱动。

credential_masked(source: secretmask)是代理把请求里的凭据、身份证号或手机号换成保形假值后发出的通知,每条规则一条,重复命中累加次数。事件本身不含凭据字节,只带 rule、vendor、kind、长度、偏移和 trace_id;真值与上游实际收到的假值从 GET /api/secret-masks 取(见 凭据遮蔽),事件详情面板会就地列出该规则的替换对(原始值与替换值各一行、完整打印,并带偏移、长度、置信度与落盘形态),因此不必离开事件页去确认;详情里的「追踪」链接指向该 trace 的「审计」标签页而不是默认的协议页,因为替换对属于审计面。其他事件保持默认落点。

credential_masked 的读/解决/忽略按 occurrence 生效:状态以 (trace_id, rule) 存在 credential_mask_event_states,没有行就是未读。事件行仍按规则一行,只缓存最近一次 occurrence 的状态,供全局列表和未读角标使用;对某条 trace 的操作只写该 trace 的状态,不会影响其他会话/trace 中同一规则的事件。全局列表上的操作默认作用于最近一次 occurrence;从 trace 搜索进入时作用于命中的那条 trace。trace 侧「全部标为已读」也只清该 trace 的未读 occurrence。

事件 API 的 trace 范围统一用 query 参数,与 GET /api/secret-masks?trace_id= 保持一致;事件 id 仍是路径资源:

  • GET /api/events?trace_id=<trace>:该 trace 的事件(credential 事件会被投影成该 trace 的 occurrence,状态/时间/详情来自该 occurrence)。
  • GET /api/events/<eventID>?trace_id=<trace>:该事件在该 trace 上的详情。
  • POST /api/events/<eventID>/read|resolve|ignore?trace_id=<trace>:只改该 occurrence 的状态。

trace_id 必须是完整 UUID;q 仍支持 UUID 前缀用于发现,前缀命中多条 trace 时按规则取最近一条。credential_masked 的「忽略」在 UI 中显示为「忽略本次」,只作用于当前 (trace, rule) occurrence;同一规则在另一条 trace 的 occurrence 默认仍是未读,同一 trace 内新增的值仍属于同一 occurrence,保持已处理状态。其他来源的忽略仍是规则级永久静音。升级到本版本时,服务启动会先一次性把历史上被忽略的 credential_masked 事件改回未读,再把旧的规则级 read/resolved/ignored 状态迁到该规则最近一次 occurrence(app_settings 记录两次修复已执行;更早的 occurrence 没有旧状态可继承,按未读处理;重启不会重复撤销之后的人工忽略)。

这条通知与审计行在同一事务里写入,写入由录制收尾的旁路队列完成并支持重试,因此不会占用 LLM 转发或 cassette finalize;提交后立即通过 events 主题推送。服务启动时和每 5 分钟还会按 rule 对账一次,为已有审计行补上缺失的事件(只插入、不累加、不覆盖状态),所以升级或异常退出留下的历史缺口会在打开事件页或页面保持连接时自动出现。

质量 /audit

四个标签页:发现项、请求链路、分析任务、数据健康。带 ?response_id= 或 ?request_audit_id= 的深链直接打开「请求链路」,否则默认「发现项」。

发现项(GET /api/findings):按类别和级别(critical、high、medium、low)过滤,25 条/页;服务端返回过滤后的 total 并用 page/limit 分页,页码输入可直接跳转;每条发现项给出可读的定位($.input[4] · #5)并直接跳到对应 trace 会话中的那一步(?node=),另有入口进入该 trace 的审计页与协议页。severity 与 category 都是大小写不敏感的整值匹配,all 表示不过滤(与事件列表 GET /api/events 的 severity/category/status 同一套规则);未识别的取值按字面量匹配、返回空列表,不会退化成"不过滤"。

请求链路:输入 response_id 或 request_audit_id 加载本地 Responses runtime 的 request audit、execution events 与 upstream exchanges(GET /api/responses/audit/trace)。

分析任务:展示已持久化的 analysis run 与 analysis job 队列,并提供两个批量操作,都以异步 job 提交(POST /api/analysis/batch/reanalyze),不调用上游模型:

  • 「修复分析数据」:对失败或未解析的 observation 重新解析。
  • 「修复 Token 统计」:对缺失 usage 的记录重新抽取 token 统计。

数据健康:派生数据管道的健康度——已解析/未解析 trace、解析队列深度与失败的分析任务数,未解析项可跳到按 observation=unparsed 过滤的流量列表。数据由实时 socket 推送。这一项原先挤在概览页,但它是解析管道的属性,与「最近一小时的流量」无关,所以它没有时间窗口控件,统计覆盖全部已索引 trace,并只向 GET /api/overview 请求 sections=health。

整个页面调用的接口只有 GET /api/findings、GET /api/responses/audit/trace、GET /api/analysis/* 和概览/事件摘要接口;GET /api/responses/audit/tool-calls(工具调用审计)虽已在 management server 注册,但 Monitor UI 从不调用它,属于 API-only 接口。server-side function executor 状态面板已移到「系统 → 服务端工具」。

配置

模型 /models

按模型查看时间窗口内的流量:模型覆盖哪些模型服务商,以及请求数、错误数、Token 与趋势。详情路由为 /models/:model,详情页可编辑该模型的 display_name、enabled、upstream_model、context_window、max_output_tokens、compact_history_item_threshold 与 profile_adoption_status,以及三态的 supports_responses / supports_chat_completions / supports_embeddings 能力覆盖(模型级声明优先于 provider 级配置)。

模型页把汇总读数条放在页头下方,模型卡按 18 个/页分页;卡片的 head/footer 允许换行,长 channel id 在自己的卡内省略而不是撑宽两列网格。真实数据的 97 个模型现在没有横向溢出。

模型服务商 /providers

管理上游模型服务商。支持创建与编辑 provider preset、base URL、API key、headers、routing 字段,以及 API surface:api_type、mode、Responses/Chat Completions/tool calling/models 等 capability。模型级的 supports_responses / supports_chat_completions / supports_embeddings 覆盖不在这里编辑,见上面的模型页。

创建前可以先做探测:

  • POST /api/provider-probe 返回只读的 Detect provider 建议,不落库。
  • POST /api/provider-setup/validate 组合 base URL、API key、preset、模型发现与 capability 字段做一次探测,并把归一化配置写回表单,但不落库;响应不回显 API key,只返回 api_key_hint、secret storage mode 和脱敏 header 状态。表单字段变化会清空旧验证结果。
  • POST /api/provider-setup/apply 才真正把配置写入 store。
  • 列表页的 Batch probe and apply 先跑 POST /api/provider-probe/report 预览,再用 POST /api/provider-probe/report/apply 对 detected 且存在可补字段的 provider 批量应用;批量应用只补缺失的 api_type、protocol_family 和未设置的 capability,不覆盖显式配置,也不覆盖显式 false。

还可以启停模型服务商、启停单个模型、探测模型、查看用量/Token/失败与 probe 结果。

配置归属:模型服务商、模型与别名的长期配置保存在 application store;YAML 只作为首次 bootstrap 输入。由 YAML 显式 credentials 列表管理的配置下,Monitor 的这三类写操作返回 409。配置来源、bootstrap 标记与状态机见 路由、渠道与凭据 与 架构与代码地图。

接入 /connect

两个标签页:客户端接入、API 令牌。

客户端接入展示当前部署的 base URL 与三种协议入口,并给出可复制的 curl 示例:

  • /v1/chat/completions(OpenAI-compatible)
  • /responses(OpenAI Responses / Codex;/v1/responses 仍然支持)
  • /anthropic/messages(Anthropic Messages / Claude Code)

API 令牌管理当前用户的个人 API token,见上文「个人 API token」。

路由 /routing

页面级标签条包含四个标签页(和其它合并页一样是标题下的一行下划线 tab,绑定在 ?tab= 上;这里曾经是「工作区」卡片里的分段控件,同一件事在两个页面长得不一样,还多花了一张只装这一行的卡片):

  • Decisions:最近选中的路由(数据来自 GET /api/routing/exchanges),可按模型、通道/上游、状态、耗时、TTFT、Token 过滤。摘要仍基于最近 200 条,列表按 50 条/页显示,页码输入可跳转。
  • Settings:编辑路由设置(PATCH /api/settings/routing),包括 responses_strategy、selection_policy、missing_model_policy。
  • Aliases:模型别名的增删改与校验(/api/model-aliases、/api/model-aliases/validate)。
  • Inspector:POST /api/routing/inspect 预演某个请求/模型会如何被路由。候选的能力判定与转发热路径共用同一个谓词(upstream.ResolvedUpstream.SupportsRawPath:协议族 → API surface → adapter),因此「只写 provider preset、不写 api_type」的渠道(如 config/examples/anthropic.yaml、google_genai.yaml、vertex.yaml)会按解析后的协议族回答,而不是按空的 api_type 被当成 Chat Completions 渠道:Anthropic 渠道对 /v1/messages 报可服务、对 /v1/chat/completions 报 requires_chat_completions,Google/Vertex 渠道对这三个可预演 endpoint 都报不可服务。模型级能力覆盖同样按转发路径的投影读取(channel.ChannelModelCapabilities:别名键携带其目标模型行的覆盖),因此「别名名恰好也是该渠道已声明模型名」时,Inspector 与代理都会用别名目标行的覆盖,而不会用被遮蔽模型行自己的声明。未声明模型的可路由性也按同一条规则判断:只有渠道的 allow_unknown_models 为真时才会规划出路由,否则候选会以 model_not_matched 被排除——与转发热路径一致,不会因为该渠道的模型集合暂时为空就放行。

摘要面板来自 GET /api/routing/summary,按 failure reason、selected route target、credential 和 sticky 状态聚合,并区分有事件与旧数据/缺失事件的 trace。该端点不解析任何文件,所以响应里没有解析失败计数,页面也不再显示这一项。

系统

实时推送

Monitor 不再有前端定时刷新。整个控制台开一条 WebSocket(GET /api/events/ws),页面告诉服务端自己正在显示哪些主题,服务端在某主题的数据变化时推一条只带主题名的消息,浏览器据此只重新获取自己注册在这个主题下的请求:

  • traffic:有请求被索引、解析或重分析——trace / 会话列表、概览聚合、发现项、凭据替换对(GET /api/secret-masks 与 trace 索引在同一收尾步骤写入)、分析队列、路由与上游视图都归它;
  • events:运行时事件收据变化(侧边栏未读角标与事件页);
  • system:主机、进程与数据库采样到期,这是唯一真正周期性的读数,由服务端定时器产生,且只在有标签页停在系统页时采样,多个标签页共用一份。

单条记录的详情读不注册主题:/api/traces/<id> 及其 raw、observation、findings、resources、performance 子资源,以及 /api/sessions/<id>、/api/models/<id>、/api/channels/<id>、/api/upstreams/<id>,都是已录制内容的快照。cassette 是详情的事实源,页面打开后不会因为别的请求被录制而变化;把它们挂在 traffic 前缀下会让每次录制都重新读取详情和五个子资源(其中 observation 还要现场重解析 cassette),读者看到的就是一个反复出现的「加载中」。这些页面只由它们自己的写操作(重解析、重分析、修复统计)显式失效并重取。列表和聚合读仍然按主题实时更新。

侧边栏底部那一行里用一个图标显示这条连接的状态(连接中 / 已连接 / 断开重连中),与版本号和仓库图标同行,鼠标悬停给出文字说明;连接失败时浏览器控制台会打印一次端点与重试间隔。断线后前端按指数退避自动重连,最长 5 分钟一次,不需要手动刷新页面。

发布主题的写路径包括:录制请求落库、观测与发现项写入、重分析更新 token 用量、分析任务与解析任务的状态迁移、运行时事件收据变化。也就是说,服务端后台自己跑的解析与重分析也会让页面自己刷新,不需要读者做任何操作。

主题与请求的对应关系写在 web/monitor-ui/src/lib/realtime.js:每条读请求经 useJSON 按其 URL 前缀自动注册主题,因此页面不需要知道 socket 存在;集合根路径(如 /api/traces)按前缀注册,根路径下的单条记录(如 /api/traces/<id>)按快照处理,不注册主题。GET /api/events/stream 这个 SSE 端点仍然提供,供已经在用 SSE 的外部集成使用,控制台本身不再使用它。浏览器无法在 new WebSocket() 上带 Authorization 头,所以这条路径(以及 SSE 端点)允许把访问令牌放在 ?access_token= 查询串里,其余接口仍然只认请求头。

系统 /system

管理员专属页面:导航项只对 role=admin 的登录用户显示,未启用认证时显示给 local 伪用户(此时 Monitor 本身不校验身份);后端对未认证请求返回 401、对已认证的非管理员返回 403。页面分六个标签页,各标签页只读取自己需要的接口;运行时 曾经一个标签页同时装主机与进程,长度是其它标签页的两倍,也是这一页读起来像把同一件事说了好几遍的原因——主机指标来自 /proc,在没有 /proc 的平台上会失败,Go 运行时与连接池来自进程本身、不会失败,两者是两个问题,因此拆成 系统信息 与服务端(主机标签页沿用 runtime 这个 id,旧链接仍落在原处)。这一页是唯一由服务端定时的数据:只要还有一个标签页停在系统页,服务端就每 30 秒推送一次 system 主题(见「实时推送」一节),系统信息、服务端、数据库与慢查询共用这一份采样:

  • 系统信息:GET /api/system/host。
  • 服务端:GET /api/system/runtime + GET /api/system/host(只取其中的进程段)。
  • 网络代理:GET/PATCH /api/settings/network-proxy。
  • 数据库:GET /api/system/db。
  • 慢查询:GET /api/system/slow-queries。
  • 服务端工具:GET /api/responses/function-executors。

网络代理是出站请求的服务端策略,保存在 application database 的 app_settings 键 network.proxy,默认关闭。它按 Linux 的 http_proxy / https_proxy / no_proxy 语义解释三个字段:http_proxy 用于 HTTP 目标,https_proxy 用于 HTTPS 目标且留空时等于 http_proxy,no_proxy 是逗号分隔的例外列表。no_proxy 支持精确主机名、域名后缀(example.com 与 .example.com 都匹配自身及子域)、IP、CIDR(10.0.0.0/8、fd00::/8)、可选的 host:port,以及表示全部直连的 *;回环地址(localhost、127.0.0.0/8、::1)始终直连。代理地址接受裸 host:port,也接受 http://、https://、socks5://(含 user:pass@ 认证);写入时统一规范化为带 scheme 与端口的形式。关闭开关后所有出站请求直连,不读取进程环境变量,因此内网上游不会因为启动 shell 里的 HTTP_PROXY 而意外绕行;需要环境变量行为时把同样的值填进这一页即可。策略在每个请求上读取,保存后立即生效、无需重启。

适用范围是进程内所有没有自带 transport 的出站 HTTP client:LLM 上游转发(透传路径的反向代理 transport 直接绑定该策略)、本地 Responses 运行时的内部 Chat Completions 调用、tokenize 计数、模型发现与 provider probe(含启动期 probe)、web 搜索与 GitHub 版本检查。保存成功后 PATCH 返回规范化后的策略与派生的 effective_https_proxy;服务端会在启动时把该行读入进程并按需打印脱敏后的代理地址。

GET /api/system/host 报告主机资源事实,全部来自 /proc 与 syscall.Statfs 等系统调用,不需要额外 agent:主机名、uptime、内核版本;CPU 核数、总使用率与 user/system/iowait/idle 分解、1/5/15 分钟负载、每核使用率;内存总量/已用/可用/页缓存与 swap;每个挂载点的文件系统、设备、总量/已用/可用/使用率;网络接口的累计收发字节数与瞬时速率;以及本进程的 PID、RSS、虚拟内存、CPU 占用、线程数与打开文件数。非 Linux 平台返回 unsupported=true 与 reason,页面显示说明而不是报错。瞬时速率在两个采样点之间计算,同一进程内两次请求间隔不足 500 ms 时复用上一次结果,间隔超过 5 分钟则重置基线(此时速率显示为 0 而不是把休眠期间的平均值当成当前速率)。页面展示时会折叠噪音:同一设备上的多个挂载点(容器 overlay 层与 bind mount)按设备折叠后再汇总成一张磁盘占用环图(环心显示已用比例与「已用 / 总容量」,悬停显示该扇形代表的具体容量),从未传输过字节的网络接口不显示。每个资源在页面上只有一个落点:CPU 与内存的历史画成一条 资源趋势 曲线(打开本页时服务端每 5 秒采样一次,样本随 system 主题推送到达,页面因此不需要手动刷新;历史只保留最近 5 分钟(60 个采样点),没有标签页在看时不采样),内存与磁盘的构成画成并排的两张环图(同一行、同一控件,因为它们回答的是同一个「还剩多少」)。内存环的三片是按内核三个数的实际嵌套关系切的:used_bytes 是 MemTotal 减 MemAvailable,因此不含可回收的页缓存(这也是长时间运行的机器上页缓存常常是已用的好几倍的原因:写这份代码的机器上是 24 GiB 对 5 GiB),而 cached_bytes(Cached + SReclaimable)属于可用那一侧,所以「已用 / 页缓存 / 剩余可用」三片正好等于总量;把已用、页缓存、可用三片直接对着总量画会重复计算页缓存,环图总和会超过机器内存。环心的可用值仍单列一行,因为它才是回答「新进程还能拿到多少」的那个数,CPU 的 user/system/iowait/idle 分解与主机名、内核、uptime、1/5/15 分钟负载合成一张事实网格。逐核心使用率不再展示:八条细条复述的正是趋势图第一行已经给出的总量,在核多的机器上是一片噪音。网络速率同样画成趋势:systemHostPoint 现在除了 CPU、内存百分比与负载,还带聚合的 rx_bytes_per_sec / tx_bytes_per_sec,接口表因此只留接口名与两个速率列,累计收发字节数落到行的 title 上——累计值只会单调增长,回答不了「现在多快」。进程的线程数与打开文件数属进程内部细节,页面不再展示;网络带宽不是实时采集项,按页面请求读取当时数值。

GET /api/system/runtime 报告 Go 进程事实:Go 版本、GOOS/GOARCH、CPU 核数、GOMAXPROCS、协程数、进程运行时长、堆的累计分配/在用/存活对象/进程总内存/栈内存、GC 次数与最近 256 次 GC 暂停的 min/p25/p50/p75/max,以及 database/sql 连接池计数(最大连接、已打开、使用中、空闲、等待次数、等待累计、因空闲或超时被关闭)。它不查询任何数据库行。

GET /api/system/db 报告 Postgres 统计视图;非 Postgres 驱动(如 SQLite)返回 HTTP 200、supported=false、unsupported=true 和 reason,页面显示说明而不是报错。采集全部来自 pg_stat_* 与系统视图,在一条只读事务内完成(SET LOCAL statement_timeout = 2000,外层 5 秒超时),每个列表最多 20 行,pg_stat_activity.query 先在 SQL 里截到 4000 字符,再在 Go 侧脱敏并截断到 500 字符。它从不执行 EXPLAIN、EXPLAIN ANALYZE、VACUUM、ANALYZE 或任何写语句;单个分节失败只进入响应的 warnings。页面展示:

  • 数据库大小、缓存命中率(低于 95% 标黄、低于 90% 标红)、临时文件与临时写入字节、死锁、事务提交/回滚。
  • 连接与活动:本库连接数、实例总连接数、按 state 与 wait_event_type 的分组计数、运行最久的非 idle 语句。
  • public 下最大的关系(总大小/堆大小/估算行数)、未被使用的索引(idx_scan = 0,按大小高亮,附维护成本提示)、读放大最严重的索引(idx_tup_read / idx_scan)、写入最频繁的表。
  • 检查点计数(Postgres 17 来自 pg_stat_checkpointer 的 num_timed/num_requested,响应中的 source 标明来源)与 pg_settings 的关键参数(shared_buffers、work_mem、maintenance_work_mem、effective_cache_size、random_page_cost、max_wal_size、max_connections、statement_timeout)。

GET /api/system/slow-queries 返回进程内慢语句环形缓冲区。采集默认关闭:debug.slow_query_threshold(TRAJECTA_DEBUG_SLOW_QUERY_THRESHOLD)为 0 时不记录任何语句,热路径只有一次原子读取,页面显示「collector disabled」空状态并提示设置该配置。设为正数(例如 "200ms")并重启后,慢于阈值且经过数据库驱动的语句进入容量 50 的缓冲区,保存时间、耗时、操作类型(query/exec)以及脱敏并截断到 500 字符的语句。

pprof 只在 debug.pprof_enabled(TRAJECTA_DEBUG_PPROF_ENABLED,默认 false)为 true 时挂载到 Monitor 的 /debug/pprof/;为 false 时该前缀返回 404,不会落到前端 SPA。

关于 /about

关于页回答「这是哪个构建、它有没有更新」。它与 /system 并列在系统分组里,但不属于系统页:系统页报告的是机器,关于页报告的是构建本身,因此它没有管理员门槛——后端对未认证请求返回 401(与其它 Monitor 接口一致),任何已登录角色都可以打开,未启用认证时显示给 local 伪用户。

页面读两个接口:

  • GET /api/about:只读构建信息与配置摘要,不发起任何外部请求。字段包括 name、version(完整版本串,例如 v3.3.1-2-g4e766d6)、base_version(去掉 git-describe 后缀的标签,例如 v3.3.1)、commits_since_tag、commit、build_date、branch、go_version、goos、goarch、repository、issues_url、releases_url、docs_url、license、license_url、update_check_enabled、telemetry,以及数据库可用时的 install_id。这些值在编译期由 ldflags 注入(见 cmd/server/version.go 与 Taskfile 的 git describe --tags --always --dirty),不是运行期探测出来的。页头右上角的 GitHub 按钮、页面里的仓库 / 发布记录 / 问题反馈 / 文档四个链接、以及侧边栏底部的版本号都取自同一个响应,改一处不会各说各话。
  • GET /api/version/latest:只在读者按下「检查新版本」时请求,没有任何后台轮询,进程启动也不外联。它读取项目固定的 releases Atom feed https://github.com/kingfs/Trajecta/releases.atom 并比较版本。

页面本身只渲染 name 与标语、四项构建事实(version、commit、build_date、base_version)以及项目链接;branch、go_version、goos/goarch 和许可证不在这一页重复,运行时的 Go 版本与平台由系统页报告。按下「检查新版本」打开一个弹窗,弹窗里只给当前版本与最新版本(以及一行状态),不再在页面上另起一个节重复构建信息。

发布检查配置:

update_check:
  enabled: true

仓库地址与 release feed 是项目事实,不是部署配置,因此没有 endpoint,也没有对应的环境变量。旧配置里的 update_check.endpoint 不再读取,启动时会作为未知键警告并忽略。enabled: false 时 /api/about 的 update_check_enabled 为 false,页面不画那个按钮——不画一个按下去只会说「无法检查」的按钮。开关的环境变量是 TRAJECTA_UPDATE_CHECK_ENABLED。

选择 Atom feed 而不是 api.github.com 的 REST 接口,是因为未认证的 GitHub API 配额按出口 IP 计算(每小时 60 次),同一个代理后的多个部署会互相消耗;feed 是公开的发布列表,不带这个配额。请求带 8 秒超时、Accept: application/atom+xml、User-Agent: Trajecta/<version>,响应体读取上限 1 MiB。

/api/version/latest 总是返回 HTTP 200,结果放在响应体的 status 里,取值与含义:

  • update_available:远端有更新的版本,响应带 latest_version、latest_name、published_at、release_url 与截断到 6000 字符的 notes(feed 里的 HTML 会转成纯文本)。
  • up_to_date:远端版本不高于当前构建。比较把 latest <= current 都算作最新——带 commits_since_tag > 0 的构建比最新发布领先,不是落后。
  • unknown:某一侧的版本串解析不了(开发构建、非 semver 标签),因此无法比较。
  • no_release:feed 正常返回,但里面没有任何 release。页面显示「项目尚未发布 GitHub Release」,这不是请求失败。
  • unavailable:网络失败、超时、非 2xx、feed 无法解析,或最新条目没有版本标签。
  • disabled:开关关闭。

比较只按 vX.Y.Z 的三段数字,忽略预发布后缀。响应里的 cached 说明这次结果是否来自缓存:成功结果缓存 10 分钟,失败从不缓存,因此上游恢复后下一次点击立刻能拿到结果。这个端点不验证任何东西、不下载任何东西、也不写入任何文件。

unavailable 的错误会尽量给出可操作的原因:无法连接 GitHub 时说明「could not reach GitHub」;带速率限制标记的 403/429 报告 GitHub is limiting release checks from this network 与重置时间(X-RateLimit-Reset);feed 返回 404 时说明找不到项目的 release feed;其余 5xx 说明 GitHub 暂时不可用。GitHub 的 HTML/XML 错误正文不会回显到页面。

匿名装机上报

Web UI 已经不再展示「使用统计」,也不再发送匿名装机上报:关于页的说明卡与 lib/telemetry.js 都已移除,打开任意页面都不会向 telemetry.endpoint 发出请求。后端的 telemetry 配置与 /api/about 的 telemetry / install_id 字段仍然保留,以兼容已有配置和读取该响应的集成,但目前没有发送方,配置它们不会产生任何外发请求。

字段含义保持不变:telemetry.enabled 是总开关,telemetry.endpoint 是收集端地址,provider / path_prefix / interval 仍在响应里原样返回;install_id 由服务端生成并存在 app_settings.telemetry.install_id,数据库不可用时省略。telemetry 的环境变量 TRAJECTA_TELEMETRY_* 也保持原样。

兼容路由与详情路由

  • / 重定向到 /overview。
  • 合并掉的旧地址全部保留重定向,并且保留原有查询参数:/requests → /traces,/sessions → /traces?tab=sessions,/analysis → /audit?tab=analysis,/tokens → /connect?tab=tokens。
  • /channels 重定向到 /providers;/channels/:channelID 仍渲染模型服务商详情页。
  • 详情路由:/traces/:traceID、/sessions/:sessionID、/models/:model、/providers/:providerID、/upstreams/:upstreamID。
  • 未匹配的路径重定向到 /overview。

Trace 详情

一条 trace 有七个标签页,同一时刻只挂载正在读的那一个。标签条固定在页头下方(页头本身也是 sticky),因此在「会话」默认落到最后一轮、页面已经滚到底之后,切换标签仍然不需要先滚回顶部;页面滚动时内容从这条带子下面穿过。

  • 会话:由 Observation IR 渲染出的对话。当一次 trace 的推理/工具循环很长时,连续的 reasoning / tool_call / tool_result 会折叠成一条「执行过程 · N 步」;默认只渲染消息,展开、定位或查找命中会自动打开对应的一段。超过 200 步的 trace 连很短的 work run 也折叠,因此一条 1,107 步、21k DOM 节点的真实 agent trace 默认只挂载 53 个步骤、约 2.5k DOM 节点;工具条上的「显示 X / Y 步」只计算已挂载的步骤,展开一段后 X 随之增长。顶层节点(depth: 0)各是一段以细线分隔的行、按 payload 顺序排列;行内的整棵子树都会被读进去——消息里的文本成为正文,工具调用成为行内的一个调用块(名称、call_id、参数),应答它的工具结果并进同一个调用块(按 call_id 配对),因此 OpenAI Chat 那种「调用写在 assistant 消息里」的记录也能正确显示,而不是显示成空行或原始 JSON。响应侧的两层嵌套($.choices[0] → .message → .content)同样会被展开,行首的角色点与名称取自其中第一条带 role 的节点。失败的一步由错误标志、非零退出码或状态词判定并标红,tool_declaration 与 usage 不作为对话步骤。打开页面时默认落在最后一次「输入」上(user / human 角色的步骤或 instruction 节点),最新一轮输入与应答它的输出直接可见;URL 里的 node=、正在使用的查找与「只看失败」优先,不会被这个默认落点覆盖。右侧常驻一条窄导航轨(随页面滚动保持在视口内,≤720px 时隐藏):顶部箭头跳到最早的消息、底部箭头跳到最末一条,中间的刻度是每一轮输入在正文里的真实位置(不是等距编号),点击跳到该轮。工具条可以查找文本(消息、参数、输出,见下条)、只看失败、全部展开/收起、切换 Markdown,并显示「已显示 / 总数」;查找不再隐藏步骤,只有「只看失败」会筛选,且筛选不改变步骤编号。≤720px 时导航轨没有位置可放,它的两个跳转(最早 / 最新)改由工具条上的按钮承担,因此手机上仍能一键回到开头或跳到最新一轮。
  • 协议:解析结果分成「请求解析」与「响应解析」两棵树,默认折叠,因此长请求只占顶层几个键的高度,响应侧不用翻到页尾才看得到。每行是 payload 里的一个值,行上带解析器给它的名字(message、tool_call、tool_result…,角色跟在后面),有文本的节点直接在行上显示那句文本(消息正文、工具名),没有名字的节点按值本身的形态标注(future_item、object、array…),不会出现无意义的 unknown。点开一行展开它,点行本身则用弹窗(蒙版)显示该值:弹窗标题是 JSON 路径,内容仍是可继续点开的同一棵树,解析器读不出 JSON 的载荷(错误响应体、流分片)按原始文本显示。请求侧是整段已解析历史(每一轮消息、内容块与工具声明),响应侧是本次响应的推理、工具调用与最终输出;此前只渲染响应树,因此一整个会话看起来像是只解析了最后一轮。
  • 工具调用:请求声明的工具,每个工具占满一整行(名称、来源、调用次数或「未调用」、描述),右侧不留空白列。工具多于 3 个时面板顶部出现按名称/描述筛选的输入框,没有匹配项时给出空状态而不是空列表。点击行打开工具定义弹窗(960px 宽):参数按 JSON Schema 描述的表单展示——每个参数一行,给出名称、类型、必填/选填、描述,以及枚举取值、默认值、格式、范围、additionalProperties: false,嵌套对象与数组元素缩进在声明它的那一行下面;原始 JSON Schema 仍保留在同一弹窗的折叠块里,供表单画不出的关键字查阅。调用次数大于 0 时该行另有「查看 N 次调用」按钮,打开更宽的弹窗(1180px)逐条列出调用输入与按 tool_call_id 配对到的输出。这一块原本是「请求信息」的最后一张卡片,左列选工具、右列显示概览,而右列在常见情况下(工具没被调用、或定义只有一段 schema)几乎是空的。
  • 审计:这条请求被替换的凭据(GET /api/secret-masks?trace_id=,真值与发往上游的假值成对列出,带偏移、长度、置信度与落盘形态),下面是确定性 findings;每一项给出可读的定位($.input[4] · tool result · tool),点「定位」会在当前 trace 的会话页展开对应步骤,并把该发现实际命中的字节高亮出来(身份证号、凭据、危险命令等可判定跨度的类别;工具错误、安全拦截这类整节点判断仍只定位步骤)。匹配值按需重算、只在这次读取中返回,不写回 findings 表,URL 只带节点锚点,避免把号码或凭据放进地址栏与浏览历史。替换对排在发现项之上,因为「哪些字节离开了这台机器」是请求自身的事实,而 finding 是检测器的判断。每条替换的原始值后面有一个定位按钮:点击会在当前 trace 的会话页展开所有包含该原始值的步骤,并高亮每一处匹配;匹配值是本次读取从 cassette 现查的,不写回审计表,也不进入 URL(原始凭据不应出现在地址栏与浏览历史),服务端找不到时提示「当前解析中找不到这个原始值」。没有命中凭据规则时该卡片显示空状态,不会因此隐藏 findings。标签页上的计数是替换事件与 findings 的合计,与列表行的审计标签同源;只有替换、没有 finding 的请求也会显示数字。
  • 资源:本次交换中模型工具调用访问过的本地路径与 URL(GET /api/traces/{id}/resources),按值去重,以目录树呈现:本地路径按根分组,URL 按域名 / IP / 主机名分类后按 authority 与路径分层,数字是该节点及其下级的出现步骤数;展开/折叠与全局目录一致。悬停叶子名称右侧的定位按钮会在当前 trace 的会话页展开检出该值的对话轮次并高亮该值,用于核对识别结果。只记录访问对象,不保存文件内容;同一路径被多个请求访问时,全局目录只有一行。历史 trace 还没有索引时显示空状态与「解析资源」按钮,按钮触发一次 reparse 写入索引。
  • 请求信息:已选路由目标、路由决策、上游健康、请求工具与延迟/TTFT/Token 吞吐/缓存比例等本次请求自身的事实,以及记录事件流(事件类型、时间、状态/方法、URL、header/body 字节数与 attributes 里的路由决策与失败原因)。事件的 timeline_items 树不再展开,因为它复述的正是「会话」与「工具调用」两个标签页已经给出的内容。
  • 原始数据:只有原始 HTTP 请求/响应字节与 headers。它不再承载统一事件时间线。

会话工具栏的搜索框是查找器,不是筛选器:输入后在所有已渲染的消息、工具参数与输出里做大小写不敏感的子串匹配,N / M 显示当前第几处/共几处,按钮或 Enter 跳到下一处、Shift+Enter 上一处、Esc 清空;Ctrl/⌘+F 在会话标签页内聚焦查找框(弹模态框时不接管,留给浏览器原生查找)。命中所处的长步骤会自动展开;右侧竖轨在输入轮次刻度之外叠一层命中圆点,位置按命中在会话中的真实偏移计算,点击圆点直接跳到该处,当前命中用描边标出。滚动条刻度本身无法画在浏览器原生滚动条上,这条竖轨就是等价物。查找命中用 content-find 标出,与定位高亮的 content-hit 分开:定位仍是精确值 + 独立边界,查找是普通子串匹配;hl/定位值存在时先不显示定位高亮,清空查找后恢复。

会话的定位锚点是每个节点的地址:trace#<traceID>#node#<nodeID>#path#<JSON 路径>。审计行、质量页的发现项列表和概览的高风险队列都用它跳转,点击后进入 /traces/:traceID?node=<锚点>,页面会滚动到对应步骤、短暂标记并展开被折叠的长文本。锚点落在步骤内部(消息里的调用、并进调用块的结果)时同样定位到承载它的那段步骤。带 hl(资源定位)或命中值(finding 定位)时只高亮匹配的字节,不再给整个步骤描边,避免读成「整条消息都命中」;没有可高亮的值时才保留整段定位提示。node= 接受四种写法:裸节点 ID、$.input[159] 形式的 JSON 路径、trace#<id>#node#<nodeID> 和完整的证据路径;node_id 为空的「整条 trace」级 findings 也能正确落到会话顶部。

可执行动作:

  • Refresh analysis(POST /api/traces/:id/reanalyze):从本地 cassette 重新生成 Observation 和 findings,仅在自动处理异常或结果明显不对时使用。
  • Repair stats(POST /api/traces/:id/repair-usage):从本地响应重新抽取 usage/token 统计,仅在 token/cost 统计缺失或错误时使用。

Deep link 支持 query 参数 tab(conversation、protocol、tools、audit、resources、request、raw)、node、from_session、view(sessions / requests)和 focus(failure、timeline、timeline_error、observation、request、response)。focus=observation 落在「协议」标签页并把解析面板滚到视口内、加一圈强调描边,这是解析失败通知里那条链接的目标。tab=timeline 与 tab=performance 是旧地址,分别按 conversation 与 request 处理。当 trace 携带 Responses audit id,或后端能通过 upstream_exchanges.trace_id 反查到 audit id 时,页头动作区显示 Responses audit 入口,跳转到 /audit 中同一条请求链路。

Responses function executors

GET /api/responses/function-executors 返回当前进程的 server-side function executor 摘要,供「系统 → 服务端工具」标签页使用;响应不返回 static_response 的 output 或 external_command 的 command 等敏感内容。

POST /api/responses/function-executors 是 Monitor 的写入口:默认 validate_only=true,只返回归一化摘要和 warnings;validate_only=false 时把非敏感 overlay 持久化到应用库 app_settings,并热更新当前进程的 executor registry,后续新请求生效。字段、redaction 与进程约束语义见 本地 Responses Runtime。

排障建议

请求失败时按顺序查看:

  1. trace detail 的 Routing & Conversation 与 Raw。
  2. Routing 页面的 Decisions,或 trace 中的 routing context。
  3. Events 页面是否有 parser / analyzer / router / upstream 事件。
  4. 模型与模型服务商页面确认模型启用状态和模型服务商健康。
  5. 只有在派生结果明显不对时才运行 Refresh analysis;Token 统计异常时运行 Repair stats。

前端实现

Monitor 的前端是一个客户端 SPA,源码在 web/monitor-ui(React 19、react-router-dom、Vite 8、TypeScript)。vite build 输出到 internal/monitor/ui/dist,由 internal/monitor/server.go 的 //go:embed ui/dist/* 嵌进二进制,再由 internal/monitor 用 http.FileServer 提供,生产环境不需要额外的 Node 进程。task ui:test 依次跑 bun run typecheck(tsc --noEmit)、node --test 单元测试与 Playwright 浏览器测试;只跑类型检查用 task ui:typecheck。

样式是 Tailwind CSS v4(@tailwindcss/vite 插件)加一份 OKLCH 调色板。src/styles/tokens.css 现在把中性色收紧了:画布、表面、描边与三级文字是自定义的暖石墨值,不再直接取 slate 色阶;深色主题的默认面是近黑石墨,浅色主题是暖纸色,卡片的层级靠 1.05:1 以上的明度步进而非阴影。主色是自定义的 signal copper 值,状态色(green、amber、red、cyan、violet)保留 Radix 色阶;主色用于主操作、当前选中、焦点与录制标记,状态色只表达状态,两者不混用。tests-unit/tokens.test.js 仍逐条校验正文、弱化文字、状态色与实心填充的对比度,以及每层表面的可见步进,因此 token 被手工改动或色阶升级后会在测试里失败而不是在眼睛里失败。tokens.css 留在 cascade layer 之外:它只声明自定义属性,而它使用的主题属性选择器必须继续压过 :root 默认值。getComputedStyle 对这些 color-mix() 属性返回 oklab(...) 或 color(srgb …) 而不是 rgb(),按字符串正则解析颜色的断言必须改成交给浏览器解析(tests/monitor-smoke.spec.js 里的 window.__paint 就是把颜色画进 1×1 canvas 再读回像素)。字体栈以系统 UI 字体为先(ui-sans-serif, system-ui, -apple-system, "Segoe UI Variable Text", …),数字统一使用等宽数字;自定义样式集中在 src/styles/console.css,它在 pages.css 之后加载,负责把命令面板、六列指标条、概览里的内层数据区与 trace 失败行压平,避免卡片套卡片;同一层里还放着 trace 列表的弹性列、sticky 表头、1100px 以下的紧凑行、会话步骤的细线分隔、finding/tool/timeline 行的去卡片化,以及 390px 下的 64px 侧栏与 12px 页面留白。其余历史样式表由 src/styles/tailwind.css 导入到最低优先级的 legacy 层(层序 legacy < theme < base < components < utilities),所以迁移期间一个 utility 类总能盖过同名的旧页面规则,不需要考虑特异性或源码顺序。Tailwind 的 preflight 故意不引入:它会重置 styles.css 等历史样式表所依赖的元素默认值,引入等于重排每个页面,base.css 已经带着 Monitor 实际需要的 reset。

可复用的界面原语在 src/components/ui/:dialog、tabs、switch、separator、skeleton、popover、radio-group、segmented-control、sonner 已在页面上使用,类名合并走 clsx 加 tailwind-merge。button 也已有调用点:工具栏上的图标按钮全部是 variant="default" size="icon",原语的变体按它替换掉的类命名并使用同一批 token,因此迁移过的按钮和旁边没迁移的按钮尺寸、配色一致。目录里另有 badge、card、dropdown-menu、input、select、tooltip 已经写好但还没有页面接入——它们是 shadcn/ui 式的拷贝进源码的原语,接入才算完成,未接入的按死代码看待。按钮与文本控件已经迁完:95 处 .ghost-button 是 variant="ghost"/variant="primary"(其中 26 处是 <Link>/<a>,用 <Button asChild> 包住,链接仍是链接),55 处受元素规则影响的 <input> 是 input 原语,三种筛选宽度(180/120/260px,原 .filter-input、-small、-wide)成为调用点的工具类。表单与设置面板里的 <select> 也已经换成 Radix 的 select 原语(触发器用与 input 相同的盒子,宽度留在调用点,因为 .provider-form select 原本把它撑满网格单元,而按钮不会继承那条规则;空字符串选项的翻译规则写在原语里);页面区块也已经换成 card 原语:70 个 <section className="panel"> 是 <Card as="section">(as 存在的理由正是它们原本是 <section>,换成卡片不该把文档大纲压平)。这次迁移用几何而不是眼睛验收——15 条路由上 87 个 panel 与 stat 卡的位置、尺寸、内边距、外边距、边框、圆角在迁移前后完全一致;也正是这次对比找出了唯一的真实差异。两个承重细节现在有测试钉住:区块之间的 16px 间距写在旧样式表里([data-slot="card"])而不是工具类,因为 .page-body > .panel 会为直接位于页面主体下的区块取消它,而工具类会盖过那条规则(第一版正是这样,让 11 条路由的内容下移了 16px)。仍留在旧实现上的是列表筛选用的 6 个原生 <select>,保留的理由是它们筛选列表、其中一个随外层表单提交、且手机上是系统选择器;.stat-card 也保留自己的类,它是 4px 间距的 flex 列,不是这个表面。服务端状态走 TanStack Query,客户端实例在 src/lib/queryClient.ts,其默认值刻意保持被替换掉的自写 useJSON 的行为(retry: false、refetchOnWindowFocus: false)。

读查询全部经 src/hooks/useJSON.ts 走 TanStack Query。AppShell 里挂着一个 PageLoadingMask,由 useIsFetching 统计当前仍在进行中的读取,但只统计仍挂在屏幕上、且还没有任何内容可显示的读取(查询处于 active 且 data === undefined):任一这样的读取持续超过 200 ms 未返回时,内容区(不含左侧导航)盖上一层带转圈与「加载中」的蒙版,因此首次加载、30 天窗口、冷会话轨迹、大型审计表以及切换筛选产生的新查询都有同一个「还没好」的信号。实时推送触发的后台重取已经有数据在屏幕上,只静默替换,不再把蒙版盖回读者正在看的内容;这正是单条 trace 详情页此前反复闪「加载中」的原因。延迟 200 ms 是为了让快速的筛选切换不闪出蒙版,而读取一结束蒙版立即消失;它不拦截鼠标事件,不会挡住读者对已有内容的操作。首次加载仍保留各页原有的加载空状态。

写操作统一走 src/lib/mutations.js 的 useWriteMutation:它自己持有 pending 状态,成功后按需失效调用方声明的读查询,并把结果交给 sonner 的 toast 播出;页面不再各自维护 busy/error/saved 之类的状态。播报力度是有约定的——成功且行内已经能看到变化(事件操作、token 增删)保持静默,被截断的导出用 warning,失败一律 error 并带上服务端消息。toast 容器是一个 aria-live 区域,所以结果既看得见也读得到;登录表单是唯一例外,它的错误留在表单里,因为那条错误出现在 shell 之前。分段选择器(窗口、分布、trace 过滤)统一为 src/components/ui/segmented-control.tsx,底层是 Radix 的 radio group:单选语义、aria-checked、方向键移动并选中。Radix 的 radio group 只移动焦点不选中,原因是 React 19 从根元素派发事件而 Radix 在 document 上监听冒泡阶段的 keydown,它判断“方向键按下了吗”时按键还没被记下;原语补上了缺的那一半。日期与时间按当前语言格式化(Intl.DateTimeFormat/Intl.NumberFormat,src/lib/monitor.js),不再固定 zh-CN。Markdown 内容由 react-markdown 加 remark-gfm 渲染(src/components/common/MarkdownBlock.jsx,懒加载),它是 React 元素而不是 HTML 字符串,因此不再需要 dangerouslySetInnerHTML 和手写转义。系统事件流由 @microsoft/fetch-event-source 读取,替代手写的 SSE 解析。

构建产物按需拆包,入口之外只加载用到的部分。当前 vite build 输出(3342 个 module):

  • 语言词典是每种语言一个 chunk:src/locales/<language>.js 由 loadMessages 动态 import(),Vite 为每个匹配模块生成一个 chunk。bootstrapI18n() 在渲染前取到当前语言,取不到时回退英文而不是白屏。zh-CN chunk 58.07 kB(gzip 17.41)、en chunk 58.96 kB(gzip 15.82)。
  • 图表库是独立 chunk,只在真正画图的页面加载:src/components/common/Charts.jsx 用 React.lazy 在渲染时才 import("./ChartsImpl"),该 chunk 369.84 kB(gzip 106.87)。
  • motion 的特性集同样是独立 chunk(37.86 kB,gzip 14.53),首屏不等它;在它到达之前 m 元素就是普通元素,只是动画从起点瞬间到位。
  • Markdown 渲染器是独立 chunk:src/components/common/Display.jsx 用 React.lazy 在真正渲染内容时才 import("./MarkdownBlock"),该 chunk 155.32 kB(gzip 46.44),因此只有带 Markdown 内容的 trace 详情页会付它的代价。
  • 入口 index chunk 约 923 kB(gzip 约 252 KiB),样式 index 约 151 kB(gzip 约 26 KiB)。这一轮的主色与快速导航面板增加了约 52 kB 入口体积(gzip 约 12 KiB),主要来自 Radix Dialog 与新增词条;面板只在按 Ctrl K 时打开,但 Dialog primitive 随入口一起加载,这是后续可以按需拆分的部分。引入 Popover 那一次往入口里加了 76.6 kB(gzip 24.9):约 29.7 kB(gzip 11.1)是 popper/floating-ui——把一个浮层按视口定位、空间不足时翻转、并让它盖出侧边栏所要付的一次性成本,radio-group 6.2 kB(gzip 1.7),sonner 与 toast 约 14 kB(gzip 5),fetch-event-source 约 6 kB(gzip 2.3),其余是 Radix Collapsible 与新增的词典条目。把 Markdown 与图表从入口挪出去是这笔账的另一半:手写渲染器换成 react-markdown 时入口净减,代价转到按需加载的 chunk 上。

主题

账号面板里的主题单选组提供深色、浅色、跟随系统三个选项。保存的是偏好本身,键为 localStorage 的 trajecta.monitor.theme,取值可能是 dark、light 或 system;applyTheme()(src/lib/theme.ts)把 system 用 matchMedia("(prefers-color-scheme: dark)") 解析成实际主题,只把解析后的 dark 或 light 写进 <html> 的 data-theme,因此 data-theme 从不出现 system。index.html 里的内联脚本在样式表加载前做同一件事,这是页面加载时不再先闪出另一个主题的原因;watchSystemTheme() 监听 matchMedia 的 change 事件,让 system 偏好在页面打开期间继续跟随操作系统。

因为 data-theme 始终是实际主题而不是偏好,CSS 只需要两组规则——深色写在 :root, :root[data-theme="dark"](属性缺失时就是深色),浅色写在 :root[data-theme="light"]——不需要第三份写在 prefers-color-scheme 媒体查询里的浅色主题;Tailwind v4 侧对应的是 src/styles/tailwind.css 里的 @custom-variant dark,所以 dark: 工具类也只有一个选择器。

非目标与未实现

  • 代理不做协议族之间的转换,唯一例外是 /v1/responses 的本地 Responses runtime;边界见 架构与代码地图 与 实现状态。
  • Monitor 没有用户管理界面:用户由 auth init-user、auth reset-password、auth create-token 等 CLI 命令创建和维护,界面只支持修改自己的密码。
  • Monitor API 不接受个人 API token;个人 token 面向 proxy 与 MCP。
  • Monitor 不能修改由 YAML 显式 credentials 管理的模型服务商、模型和别名,这类写操作返回 409。
  • 写接口不接受 output、command、args、env 等敏感可执行字段,Monitor overlay 不能创建新的可执行 command;字段语义见 本地 Responses Runtime。
  • external_command 只有轻量进程约束(工作目录、绝对 command、允许目录、拒绝 root),不等同于容器或 namespace 沙箱。
  • Monitor 不轮询上游、也不做后台外联:版本检查只在读者按下按钮时发生;匿名装机上报已从 Web UI 移除(见「关于 /about」)。Monitor 没有管理员界面之外的版本管理能力,也不下载或安装更新。

相关文档

存储与部署 ./STORAGE_AND_DEPLOYMENT.md、Postgres 运维 ./POSTGRES_OPERATIONS.md、路由与凭据 ./ROUTING_AND_CREDENTIALS.md、观测与审计 ./OBSERVATION_AND_AUDIT.md、Responses runtime ./RESPONSES_RUNTIME.md、MCP 使用 ./MCP_GUIDE.md。

架构 ./ARCHITECTURE.md、实现状态 ./IMPLEMENTATION_STATUS.md、协议与模型服务商 ./PROTOCOLS_AND_PROVIDERS.md、协议参考 ./protocol-reference/README.md、代理接入示例 ./PROXY_USAGE_EXAMPLES.md、开发命令 ./DEVELOPMENT.md、文档总入口 ./README.md。