feat: paginate trace history with Vuetify table controls

This commit is contained in:
Soulter
2026-10-14 12:52:19 +08:00
parent df3f8550aa
commit e4aa4c5d41
17 changed files with 106 additions and 42 deletions
+10 -4
View File
@@ -423,16 +423,18 @@ class TraceStore:
self,
*,
limit: int = 50,
page: int = 1,
cursor: str | None = None,
query: str = "",
errors: bool = False,
models: bool = False,
plugins: bool = False,
) -> dict:
"""Read one keyset page of summaries, never all stored span bodies.
"""Read one numbered or keyset page, never all stored span bodies.
Args:
limit: Maximum summaries, capped at 100.
page: One-based page number; mutually exclusive with a cursor.
cursor: Previous page's timestamp and trace ID.
query: Message, session, sender, or plugin search.
errors: Require an error.
@@ -444,11 +446,13 @@ class TraceStore:
Raises:
RuntimeError: If initialization failed.
ValueError: If the cursor is invalid.
ValueError: If the page or cursor is invalid.
"""
if not self._ready.wait(5) or self._failed:
raise RuntimeError(self.last_error or "Trace database is not ready.")
limit = min(100, max(1, limit))
if page < 1 or (cursor and page != 1):
raise ValueError("Invalid trace page.")
conditions, values = [], []
for key, enabled in (
("has_errors", errors),
@@ -473,6 +477,7 @@ class TraceStore:
total = connection.execute(
"SELECT COUNT(*) FROM traces" + where, values
).fetchone()[0]
page = min(page, max(1, (total + limit - 1) // limit))
if cursor:
stamp, trace_id = cursor.split(":", 1)
if len(trace_id) != 32 or any(
@@ -485,12 +490,13 @@ class TraceStore:
rows = connection.execute(
"SELECT data, start_ns, trace_id FROM traces"
+ where
+ " ORDER BY start_ns DESC, trace_id DESC LIMIT ?",
[*values, limit + 1],
+ " ORDER BY start_ns DESC, trace_id DESC LIMIT ? OFFSET ?",
[*values, limit + 1, (page - 1) * limit],
).fetchall()
return {
"traces": [json.loads(row[0]) for row in rows[:limit]],
"total": total,
"page": page,
"cursor": f"{rows[limit - 1][1]}:{rows[limit - 1][2]}"
if len(rows) > limit
else None,
+2
View File
@@ -91,6 +91,7 @@ async def get_trace_settings(
@router.get("/trace/history")
async def list_traces(
limit: int = Query(default=50, ge=1, le=100),
page: int = Query(default=1, ge=1),
cursor: str | None = Query(default=None, max_length=64),
query: str = Query(default="", max_length=256),
errors: bool = False,
@@ -104,6 +105,7 @@ async def list_traces(
return ok(
await service.get_trace_history(
limit=limit,
page=page,
cursor=cursor,
query=query,
errors=errors,
+1 -1
View File
@@ -81,7 +81,7 @@ class LogService:
"""Read a bounded database page.
Args:
**filters: Validated cursor, keyword, and feature filters.
**filters: Validated page/cursor, keyword, and feature filters.
Returns:
Trace summaries and pagination metadata.
+2
View File
@@ -1,5 +1,7 @@
# Unreleased
- Load more → Vuetify server-side datatable pagination: support page navigation and 25/50/100 rows, reset on search/filter changes, and retain only the current page. Keep first-page live refresh without inserting into historical pages. Remove borders from the three trace statistics blocks while retaining their subtle backgrounds.
- Remove persisted trace action/end-action and redundant duration attributes. Keep transition matching internal, set call kinds explicitly, retain handler types, and derive UI call completion and durations from canonical span timestamps/status. Preserve reads of legacy history.
- Hide the redundant AdapterMessageEvent type label on plugin listener nodes; keep handler names, category chips, and hook event types.
@@ -3733,10 +3733,14 @@ export type GetLogHistoryError = unknown;
export type ListTracesData = {
query?: {
/**
* Legacy keyset cursor; use page for numbered pagination.
*/
cursor?: string;
errors?: boolean;
limit?: number;
models?: boolean;
page?: number;
plugins?: boolean;
query?: string;
};
+1 -1
View File
@@ -688,7 +688,7 @@ export const apiKeyApi = {
};
export const traceApi = {
history(query?: { limit?: number; cursor?: string; query?: string; errors?: boolean; models?: boolean; plugins?: boolean }) {
history(query?: { limit?: number; page?: number; cursor?: string; query?: string; errors?: boolean; models?: boolean; plugins?: boolean }) {
return typed<OpenConfig>(openApiV1.listTraces({ query }));
},
detail(traceId: string) {
@@ -42,7 +42,8 @@ const detailBusy = ref(false);
const historyError = ref("");
const detailError = ref("");
const totalRecords = ref(0);
const nextCursor = ref(null);
const historyPage = ref(1);
const pageSize = ref(50);
const storageWarning = ref(false);
let metadataLoaded = false;
let historyRequest = 0;
@@ -469,8 +470,7 @@ function processNewTraces(newTraces) {
}
}
async function fetchTraceHistory(reset = true, reloadMetadata = false, preserveLoaded = false) {
if (!reset && (historyBusy.value || !nextCursor.value || events.value.length >= maxItems)) return;
async function fetchTraceHistory(reset = true, reloadMetadata = false) {
const request = ++historyRequest;
historyBusy.value = true;
historyError.value = "";
@@ -497,7 +497,7 @@ async function fetchTraceHistory(reset = true, reloadMetadata = false, preserveL
}
try {
const response = await traceApi.history({
limit: 50, cursor: reset ? undefined : nextCursor.value,
limit: pageSize.value, page: historyPage.value,
query: searchText.value.trim().slice(0, 256),
errors: selectedFilters.value.includes("errors"),
models: selectedFilters.value.includes("models"),
@@ -506,15 +506,13 @@ async function fetchTraceHistory(reset = true, reloadMetadata = false, preserveL
if (request !== historyRequest) return;
if (response.data?.status !== "ok") throw new Error("Trace history request failed");
const data = response.data.data;
if (reset && !preserveLoaded) {
const keep = new Set((data.traces || []).map((trace) => trace.trace_id));
if (selectedSpanId.value) keep.add(selectedSpanId.value);
events.value = events.value.filter((event) => keep.has(event.span_id));
for (const id of eventIndex.keys()) if (!keep.has(id)) eventIndex.delete(id);
}
const keep = new Set((data.traces || []).map((trace) => trace.trace_id));
events.value = events.value.filter((event) => keep.has(event.span_id));
if (selectedSpanId.value) keep.add(selectedSpanId.value);
for (const id of eventIndex.keys()) if (!keep.has(id)) eventIndex.delete(id);
processNewTraces((data.traces || []).map((trace) => ({ trace })));
totalRecords.value = data.total || 0;
nextCursor.value = data.cursor;
historyPage.value = data.page || historyPage.value;
storageWarning.value = Boolean(data.storage?.error || data.storage?.dropped);
if (reset && selectedSpanId.value) loadTraceDetail(selectedSpanId.value);
} catch (error) {
@@ -546,6 +544,7 @@ async function loadTraceDetail(traceId) {
}
watch([searchText, selectedFilters], () => {
historyPage.value = 1;
if (queryTimer) clearTimeout(queryTimer);
queryTimer = setTimeout(() => fetchTraceHistory(), 250);
}, { deep: true });
@@ -562,19 +561,19 @@ function connectSSE() {
source.onopen = () => {
connected.value = true;
retryAttempts.value = 0;
fetchTraceHistory(true, false, true);
fetchTraceHistory();
};
source.onmessage = (event) => {
if (event.lastEventId) lastEventId.value = event.lastEventId;
try {
const payload = JSON.parse(event.data);
if (payload?.type === "trace") {
processNewTraces([payload]);
if (eventIndex.has(payload.trace?.trace_id || payload.span_id)) processNewTraces([payload]);
if (payload.storage_accepted === false) storageWarning.value = true;
if (payload.span && (!payload.span.parent_span_id || selectedFilters.value.length) && !historyRefreshTimer) {
if (historyPage.value === 1 && payload.span && (!payload.span.parent_span_id || selectedFilters.value.length) && !historyRefreshTimer) {
historyRefreshTimer = setTimeout(() => {
historyRefreshTimer = null;
fetchTraceHistory(true, false, true);
fetchTraceHistory();
}, 1000);
}
}
@@ -816,14 +815,27 @@ onBeforeUnmount(() => {
<div class="trace-explorer-body" :class="{ 'has-detail': selectedEvent }">
<section class="trace-list" :aria-label="tm('traceList')">
<div v-if="loading" class="trace-empty-state">{{ tm("loading") }}</div>
<div v-else-if="filteredEvents.length === 0" class="trace-empty-state"><Activity :size="22" aria-hidden="true" /><span>{{ tm("empty") }}</span></div>
<v-data-table
<v-data-table-server
v-else
class="trace-list-table"
:headers="traceHeaders"
:items="traceRows"
item-value="event.span_id"
:items-per-page="-1"
:page="historyPage"
:items-per-page="pageSize"
:items-length="totalRecords"
:items-per-page-options="[25, 50, 100]"
:items-per-page-text="tm('pagination.perPage')"
:page-text="tm('pagination.range')"
:loading="historyBusy"
:no-data-text="tm('empty')"
@update:options="({ page, itemsPerPage }) => {
if (page === historyPage && itemsPerPage === pageSize) return;
historyPage = itemsPerPage !== pageSize ? 1 : page;
pageSize = itemsPerPage;
selectEvent(null);
fetchTraceHistory();
}"
:mobile="false"
hover
:row-props="({ item }) => ({
@@ -841,7 +853,6 @@ onBeforeUnmount(() => {
@click:row="(_, { item }) => selectEvent(item.event.span_id)"
fixed-header
height="100%"
hide-default-footer
disable-sort
>
<template #header.spans><span :title="tm('columns.spansHint')">Spans</span></template>
@@ -876,11 +887,7 @@ onBeforeUnmount(() => {
<template #item.spans="{ item }">
<span :title="tm('columns.spansHint')">{{ item.spans }}</span>
</template>
</v-data-table>
<div v-if="nextCursor" class="trace-page-footer">
<v-btn v-if="events.length < maxItems" size="small" variant="tonal" :loading="historyBusy" @click="fetchTraceHistory(false)">{{ tm("history.loadMore") }}</v-btn>
<span v-else>{{ tm("history.limitHint", { count: maxItems }) }}</span>
</div>
</v-data-table-server>
</section>
<main v-if="selectedEvent" class="trace-detail" @keydown.esc.stop="selectEvent(null)">
@@ -1075,7 +1082,6 @@ onBeforeUnmount(() => {
.trace-list { display: flex; flex-direction: column; flex: 1; isolation: isolate; min-height: 0; min-width: 0; overflow: hidden; }
.has-detail .trace-list { border-right: 1px solid var(--trace-border); }
.trace-list-table { background: transparent; border-radius: 0; flex: 1; min-height: 0; height: 100%; }
.trace-page-footer { align-items: center; color: var(--trace-muted); display: flex; justify-content: center; padding: 8px; font-size: 12px; }
.trace-list-table :deep(.trace-list-row.selected) { background: rgba(var(--v-theme-primary), 0.1); }
.trace-list-table :deep(.trace-list-row:focus-visible) { outline: 2px solid rgb(var(--v-theme-primary)); outline-offset: -2px; }
.trace-cell-text { display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1089,7 +1095,7 @@ onBeforeUnmount(() => {
.trace-detail-time { color: var(--trace-muted); font-family: var(--astrbot-font-cjk-mono), monospace; font-size: 11px; white-space: nowrap; }
.trace-status-badge { border-radius: 999px; font-size: 10px; padding: 3px 7px; text-transform: none; } .trace-status-badge.is-live { background: rgba(245, 158, 11, 0.14); color: #f59e0b; } .trace-status-badge.is-success { background: rgba(34, 197, 94, 0.14); color: #22c55e; } .trace-status-badge.is-error { background: rgba(239, 68, 68, 0.14); color: #ef4444; }
.trace-stat-grid { display: grid; gap: 8px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 18px 0 22px; }
.trace-stat { background: rgba(var(--v-theme-on-surface), 0.04); border: 1px solid var(--trace-border); border-radius: 8px; display: grid; gap: 4px; grid-template-columns: 16px 1fr; padding: 9px 10px; } .trace-stat svg { color: var(--trace-muted); grid-row: span 2; margin-top: 2px; } .trace-stat span { color: var(--trace-muted); font-size: 10px; } .trace-stat strong { font-size: 13px; font-weight: 650; }
.trace-stat { background: rgba(var(--v-theme-on-surface), 0.04); border: 0; border-radius: 8px; display: grid; gap: 4px; grid-template-columns: 16px 1fr; padding: 9px 10px; } .trace-stat svg { color: var(--trace-muted); grid-row: span 2; margin-top: 2px; } .trace-stat span { color: var(--trace-muted); font-size: 10px; } .trace-stat strong { font-size: 13px; font-weight: 650; }
.trace-timeline { max-width: 900px; } .timeline-record { display: grid; gap: 12px; grid-template-columns: 26px minmax(0, 1fr); margin-left: calc(min(var(--timeline-depth, 0), 3) * 18px); min-height: 44px; padding-top: 8px; } .timeline-rail { align-items: center; display: flex; flex-direction: column; }
.timeline-icon { --timeline-icon-background: rgba(var(--v-theme-on-surface), 0.035); align-items: center; background: var(--timeline-icon-background); border: 1px solid var(--trace-border); border-radius: 50%; display: inline-flex; height: 26px; justify-content: center; width: 26px; z-index: 1; } .timeline-icon.is-system { color: var(--trace-muted); } .timeline-icon.is-plugin, .timeline-icon.is-agent { color: rgb(var(--v-theme-primary)); } .timeline-icon.is-response { color: rgb(var(--v-theme-success)); } .timeline-icon.is-error { --timeline-icon-background: rgba(var(--v-theme-error), 0.08); color: rgb(var(--v-theme-error)); }
.timeline-line { background: var(--trace-border); flex: 1; width: 1px; } .timeline-content { min-width: 0; padding-bottom: 10px; } .timeline-heading { align-items: center; display: flex; gap: 10px; justify-content: space-between; } .timeline-heading strong { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } .timeline-heading time { color: var(--trace-muted); font-family: var(--astrbot-font-cjk-mono), monospace; font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; } .timeline-subtitle { color: var(--trace-muted); font-size: 11px; line-height: 1.5; margin-top: 5px; overflow-wrap: anywhere; }
@@ -34,6 +34,10 @@
"details": "View details",
"selectTrace": "Select a trace to inspect the complete execution chain",
"closeDetails": "Close details",
"pagination": {
"perPage": "Rows per page",
"range": "{0}–{1} of {2}"
},
"messagePreview": {
"expand": "Show full text",
"collapse": "Collapse"
@@ -34,6 +34,10 @@
"details": "詳細を表示",
"selectTrace": "トレースを選択すると実行チェーンを確認できます",
"closeDetails": "詳細を閉じる",
"pagination": {
"perPage": "ページあたりの件数",
"range": "{2} 件中 {0}–{1} 件"
},
"messagePreview": {
"expand": "全文を表示",
"collapse": "折りたたむ"
@@ -34,6 +34,10 @@
"details": "Подробности",
"selectTrace": "Выберите трассировку, чтобы увидеть цепочку выполнения",
"closeDetails": "Закрыть подробности",
"pagination": {
"perPage": "Строк на странице",
"range": "{0}–{1} из {2}"
},
"messagePreview": {
"expand": "Показать весь текст",
"collapse": "Свернуть"
@@ -34,6 +34,10 @@
"details": "查看详情",
"selectTrace": "选择一条追踪查看完整执行链",
"closeDetails": "关闭详情",
"pagination": {
"perPage": "每页条数",
"range": "第 {0}–{1} 条,共 {2} 条"
},
"messagePreview": {
"expand": "展开全文",
"collapse": "收起"
+13 -8
View File
@@ -31,7 +31,7 @@ function setup(t) {
"eventIndex", "maxItems", "selectedEvent", "normalizedSearch", "filteredEvents", "traceRows", "traceHeaders",
"processNewTraces", "normalizeTracePayload", "matchesFilter", "recordSearchText", "timelineRecords", "formatDuration", "selectEvent",
"fetchTraceHistory", "loadTraceDetail", "historyBusy", "detailBusy", "historyError", "detailError",
"totalRecords", "nextCursor", "storageWarning", "metadataLoaded", "historyRequest", "detailRequest", "plugins", "loading",
"totalRecords", "historyPage", "pageSize", "storageWarning", "metadataLoaded", "historyRequest", "detailRequest", "plugins", "loading",
]);
const statements = ast.statements.filter((node) =>
ts.isFunctionDeclaration(node) ? names.has(node.name?.text) :
@@ -41,7 +41,7 @@ function setup(t) {
vm.runInContext(statements.map((node) => node.getText(ast)).join("\n"), context);
const watcher = ast.statements.find((node) => node.getText(ast).startsWith("watch(filteredEvents,"));
t.after(vm.runInContext(watcher.getText(ast), context));
return { focused, historyRequests, detailRequests, ...vm.runInContext("({ events, selectedSpanId, selectedEvent, selectedFilters, traceRows, traceHeaders, clockNow, processNewTraces, selectEvent, searchText, fetchTraceHistory, loadTraceDetail, totalRecords, nextCursor, historyBusy, detailBusy, historyError, detailError })", context) };
return { focused, historyRequests, detailRequests, ...vm.runInContext("({ events, selectedSpanId, selectedEvent, selectedFilters, traceRows, traceHeaders, clockNow, processNewTraces, selectEvent, searchText, fetchTraceHistory, loadTraceDetail, totalRecords, historyPage, pageSize, historyBusy, detailBusy, historyError, detailError })", context) };
}
function record(span, action, time, fields = {}) {
@@ -84,7 +84,7 @@ test("canonical live updates keep summaries only and count real spans, not event
assert.equal(state.events.value[0].records.length, 0);
});
test("history uses keyset pages and ignores a superseded search response", async (t) => {
test("history replaces numbered pages and ignores a superseded search response", async (t) => {
const state = setup(t);
const first = state.fetchTraceHistory();
await new Promise((resolve) => setImmediate(resolve));
@@ -93,13 +93,15 @@ test("history uses keyset pages and ignores a superseded search response", async
} } });
await first;
assert.equal(state.totalRecords.value, 51);
const more = state.fetchTraceHistory(false);
assert.equal(state.historyRequests[1].options.cursor, "next");
state.historyPage.value = 2;
const more = state.fetchTraceHistory();
assert.equal(state.historyRequests[1].options.page, 2);
assert.equal(state.historyRequests[1].options.limit, 50);
state.historyRequests[1].resolve({ data: { status: "ok", data: {
traces: [canonical("b".repeat(32))], total: 51, cursor: null, storage: {},
} } });
await more;
assert.equal(state.events.value.length, 2);
assert.equal(state.events.value.length, 1);
const oldRequest = state.fetchTraceHistory();
state.searchText.value = "New";
const newRequest = state.fetchTraceHistory();
@@ -215,8 +217,11 @@ test("Vuetify datatable keeps the requested columns and explicit detail selectio
assert.equal(zh.columns.duration, "时长");
assert.match(descriptor.template.content, /<v-data-table/);
assert.match(descriptor.template.content, /item-value="event.span_id"/);
assert.match(descriptor.template.content, /:items-per-page="-1"/);
assert.match(descriptor.template.content, /hide-default-footer/);
assert.match(descriptor.template.content, /<v-data-table-server/);
assert.match(descriptor.template.content, /:items-per-page="pageSize"/);
assert.match(descriptor.template.content, /:page="historyPage"/);
assert.match(descriptor.template.content, /:items-length="totalRecords"/);
assert.doesNotMatch(descriptor.template.content, /hide-default-footer|history.loadMore/);
assert.doesNotMatch(descriptor.template.content, /#item=/);
for (const key of ["status", "platform", "timestamp", "duration", "message", "umo", "sender", "spans"]) {
assert.match(descriptor.template.content, new RegExp("#item\\." + key + "="));
+1 -1
View File
@@ -190,7 +190,7 @@ Configure the trace database path, capacity budget (MB), and retention days unde
The trace tab has no outer gray card; its toolbar and list sit directly on the page with only the necessary separators. The former hint and switch are replaced by a toolbar button showing Pause (enabled and connected), Connecting (enabled but disconnected), or Start (disabled). Filters, recording, and refresh use matching light-gray icon-and-text buttons sharing the input's background color, all 36px high. Search fills the remaining width, pushing actions to the right. Its placeholder includes the message count instead of a separate counter. Click to pause or resume. Updates show loading and prevent repeated clicks; failures preserve the previous state and display an error. Pausing retains the list and open details in both themes.
The `Trace` tab shows message execution chains: receipt, plugin handlers, Agent requests, responses, and completion. It opens as a full-width Vuetify datatable with columns ordered as status chip, platform logo, timestamp, duration, message, UMO, sender, and Spans. Headers, row height, and spacing use Vuetify defaults; only cell content such as status, platform icons, and messages is customized. The platform column has a minimum width and a non-wrapping header. Duration updates while execution is active. Click a row to open its timeline details on the right; use the close button or Escape to return to the list. History loading, incoming records, and filtering never automatically open another trace. Scroll horizontally on narrow screens. Spans counts actual spans including the message root, excluding span events; collapsing does not change the count. The search placeholder shows the total message traces matching the current filters. History is searched and filtered server-side in pages of 50; click Load more to fetch another page. The browser retains at most 300 summaries and loads complete detail only for the selected trace, releasing it on close. Open `Filters` next to search to select `Has errors`, `Has model calls`, and `Has plugin calls`. Multiple selections require all conditions and can be combined with search. No selection shows all traces; `Reset` clears selections, and closing the sidebar preserves them. This sidebar replaces the former `All / Errors / Agent / Plugins` single-select tabs. Use the toolbar recording button to enable or disable trace recording.
The `Trace` tab shows message execution chains: receipt, plugin handlers, Agent requests, responses, and completion. It opens as a full-width Vuetify datatable with columns ordered as status chip, platform logo, timestamp, duration, message, UMO, sender, and Spans. Headers, row height, and spacing use Vuetify defaults; only cell content such as status, platform icons, and messages is customized. The platform column has a minimum width and a non-wrapping header. Duration updates while execution is active. Click a row to open its timeline details on the right; use the close button or Escape to return to the list. History loading, incoming records, and filtering never automatically open another trace. Scroll horizontally on narrow screens. Spans counts actual spans including the message root, excluding span events; collapsing does not change the count. The search placeholder shows the total message traces matching the current filters. History is searched, filtered, and paginated server-side. Vuetify's datatable pager replaces Load more, with 50 rows by default, options of 25/50/100, and previous/next and first/last page controls. The browser retains only the current page's summaries and loads full detail only for the selected trace, releasing it on close. Search/filter changes return to page one. New records refresh the first page without inserting into historical pages. Open `Filters` next to search to select `Has errors`, `Has model calls`, and `Has plugin calls`. Multiple selections require all conditions and can be combined with search. No selection shows all traces; `Reset` clears selections, and closing the sidebar preserves them. This sidebar replaces the former `All / Errors / Agent / Plugins` single-select tabs. Use the toolbar recording button to enable or disable trace recording.
Session aliases replace raw UMOs when available; hover over the dotted underline to see the full UMO. Plugins use their display name when available; hover to see the plugin ID. Handler names and duration are also shown. Listener nodes omit the redundant `AdapterMessageEvent` type; other hook event types remain visible. Plugin handler nodes use the plugin logo when available and the plugin card's default blue star icon if it is missing or fails to load. Neutral chips next to the title show the category, matching the plugin detail page: command/command group, event listener, event hook, or LLM tool.
+1 -1
View File
@@ -190,7 +190,7 @@ Agent 默认折叠内部执行链,点击“Agent 执行”可展开请求准
追踪标签页不使用外层灰色卡片,工具栏和列表直接铺在页面上,仅保留必要的分隔线。原来的顶部说明和独立记录开关已移除;工具栏按钮显示“暂停”(启用且已连接)、“连接中”(启用但未连接)或“启动”(停用)。过滤器、记录按钮、刷新均采用图标加文字的浅灰按钮,与搜索框共用背景色,统一为 36px 高;搜索框填满剩余宽度,将操作按钮靠右排列。记录数量并入占位文字:“在 X 条记录中搜索消息、会话、插件”,不单独显示。点击记录按钮可暂停或恢复,更新期间显示加载状态并禁用重复点击;失败时保持原状态并提示错误。暂停不会清除已有列表或关闭详情,明暗主题保持相同布局。
`追踪` 页面实时显示消息执行链:收到消息、触发插件处理器、Agent 相关请求、发送响应和完成。默认只展示全宽 Vuetify datatable 追踪列表,列顺序为状态标签(运行中/已完成/错误)、平台 logo、时间戳、时长、消息、UMO、发送人和 Spans;表头、行高和间距采用 Vuetify 默认样式,仅定制状态、平台图标及消息等单元格内容。平台列设有最小宽度,表头不会换行。时长在执行期间持续更新。点击某行后才打开右侧时间线详情,点击关闭按钮或按 Escape 返回列表;加载历史记录、新记录到达及筛选变化都不会自动打开其他记录的详情。窄屏列表可横向滚动。Spans 统计实际 Span 数(包括消息根 Span),不包含挂载在 Span 上的事件,折叠不改变计数;搜索占位文字显示满足当前条件的消息 trace 总数。历史记录在服务端搜索和筛选,每页 50 条,可点击“加载更多”;浏览器最多保留 300 条摘要,只有当前打开的 trace 加载完整详情,关闭后释放详情。点击搜索框左侧的“过滤器”,勾选“有错误”“有模型调用”“有插件调用”;多选需同时满足所有条件,并可叠加搜索。不勾选则显示全部,“重置”清除勾选,关闭侧栏仍保留条件。原先的“全部 / 错误 / Agent / 插件”单选标签已改为多选侧栏。工具栏记录按钮可启用或禁用追踪记录。
`追踪` 页面实时显示消息执行链:收到消息、触发插件处理器、Agent 相关请求、发送响应和完成。默认只展示全宽 Vuetify datatable 追踪列表,列顺序为状态标签(运行中/已完成/错误)、平台 logo、时间戳、时长、消息、UMO、发送人和 Spans;表头、行高和间距采用 Vuetify 默认样式,仅定制状态、平台图标及消息等单元格内容。平台列设有最小宽度,表头不会换行。时长在执行期间持续更新。点击某行后才打开右侧时间线详情,点击关闭按钮或按 Escape 返回列表;加载历史记录、新记录到达及筛选变化都不会自动打开其他记录的详情。窄屏列表可横向滚动。Spans 统计实际 Span 数(包括消息根 Span),不包含挂载在 Span 上的事件,折叠不改变计数;搜索占位文字显示满足当前条件的消息 trace 总数。历史记录在服务端搜索、筛选和分页;原来的“加载更多”改为 Vuetify datatable 分页器,默认每页 50 条,可选 25、50、100 条,并支持前后翻页和首尾页。浏览器只保留当前页摘要,只有当前打开的 trace 加载完整详情,关闭后释放详情。搜索或筛选会返回第一页;新记录只实时刷新第一页,不打乱正在查看的历史页。点击搜索框左侧的“过滤器”,勾选“有错误”“有模型调用”“有插件调用”;多选需同时满足所有条件,并可叠加搜索。不勾选则显示全部,“重置”清除勾选,关闭侧栏仍保留条件。原先的“全部 / 错误 / Agent / 插件”单选标签已改为多选侧栏。工具栏记录按钮可启用或禁用追踪记录。
UMO 有别名时显示别名,带点划线的文字可悬停查看完整 UMO。插件优先显示 display name,悬停可查看插件 ID,同时展示 Handler 名称及耗时。事件监听器不再重复显示底层类型 `AdapterMessageEvent`,其他事件钩子的类型仍保留。插件处理器节点优先使用插件 logo,没有 logo 或加载失败时使用插件卡片的默认蓝色星形图标;标题旁的中性标签展示与插件详情页一致的分类:指令/指令组、事件监听器、事件钩子或 LLM 工具。
+4
View File
@@ -5098,8 +5098,12 @@ paths:
in: query
schema: {type: integer, minimum: 1, maximum: 100, default: 50}
- name: cursor
description: Legacy keyset cursor; use page for numbered pagination.
in: query
schema: {type: string, maxLength: 64}
- name: page
in: query
schema: {type: integer, minimum: 1, default: 1}
- name: query
in: query
schema: {type: string, maxLength: 256, default: ""}
+8
View File
@@ -4372,6 +4372,14 @@ async def test_v1_persistent_trace_routes(asgi_client, monkeypatch, tmp_path):
)
assert response.status_code == 200
assert response.json()["data"]["total"] == 1
response = await asgi_client.get(
"/api/v1/trace/history", params={"limit": 1, "page": 999}, headers=headers
)
assert response.json()["data"]["page"] == 1
assert len(response.json()["data"]["traces"]) == 1
assert (
await asgi_client.get("/api/v1/trace/history?page=0", headers=headers)
).status_code == 422
response = await asgi_client.get(f"/api/v1/trace/{trace_id}", headers=headers)
assert response.status_code == 200
assert response.json()["data"]["spans"][0]["span_id"] == span_id
+11
View File
@@ -265,6 +265,17 @@ def test_database_pages_filters_details_and_restart(persisted, capture):
assert persisted.flush()
first = persisted.list_traces(limit=2)
second = persisted.list_traces(limit=2, cursor=first["cursor"])
numbered = persisted.list_traces(limit=2, page=2)
assert numbered["page"] == 2
assert [item["trace_id"] for item in numbered["traces"]] == [
item["trace_id"] for item in second["traces"]
]
assert persisted.list_traces(limit=2, page=999)["page"] == 2
assert persisted.list_traces(limit=2, errors=True, page=999)["page"] == 1
with pytest.raises(ValueError):
persisted.list_traces(page=0)
with pytest.raises(ValueError):
persisted.list_traces(page=2, cursor=first["cursor"])
assert first["total"] == 4
assert len(first["traces"]) == len(second["traces"]) == 2
assert {item["trace_id"] for item in first["traces"]}.isdisjoint(