diff --git a/astrbot/core/utils/trace_store.py b/astrbot/core/utils/trace_store.py index 7cd4a734d..5783b3453 100644 --- a/astrbot/core/utils/trace_store.py +++ b/astrbot/core/utils/trace_store.py @@ -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, diff --git a/astrbot/dashboard/api/logs.py b/astrbot/dashboard/api/logs.py index b99069fe0..c8b975061 100644 --- a/astrbot/dashboard/api/logs.py +++ b/astrbot/dashboard/api/logs.py @@ -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, diff --git a/astrbot/dashboard/services/log_service.py b/astrbot/dashboard/services/log_service.py index 11a2cfead..cf923e53e 100644 --- a/astrbot/dashboard/services/log_service.py +++ b/astrbot/dashboard/services/log_service.py @@ -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. diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 600e1522a..fa768bb95 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -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. diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 1a39a5538..243c2f886 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -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; }; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 76fa37d99..191120421 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -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(openApiV1.listTraces({ query })); }, detail(traceId: string) { diff --git a/dashboard/src/components/shared/TraceDisplayer.vue b/dashboard/src/components/shared/TraceDisplayer.vue index c0b7d7e51..dcc57f588 100644 --- a/dashboard/src/components/shared/TraceDisplayer.vue +++ b/dashboard/src/components/shared/TraceDisplayer.vue @@ -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(() => {
{{ tm("loading") }}
-
- selectEvent(item.event.span_id)" fixed-header height="100%" - hide-default-footer disable-sort > @@ -876,11 +887,7 @@ onBeforeUnmount(() => { - - +
@@ -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; } diff --git a/dashboard/src/i18n/locales/en-US/features/trace.json b/dashboard/src/i18n/locales/en-US/features/trace.json index b71440acd..56e1ffb8b 100644 --- a/dashboard/src/i18n/locales/en-US/features/trace.json +++ b/dashboard/src/i18n/locales/en-US/features/trace.json @@ -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" diff --git a/dashboard/src/i18n/locales/ja-JP/features/trace.json b/dashboard/src/i18n/locales/ja-JP/features/trace.json index 4a331b6d9..35ab70f37 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/trace.json +++ b/dashboard/src/i18n/locales/ja-JP/features/trace.json @@ -34,6 +34,10 @@ "details": "詳細を表示", "selectTrace": "トレースを選択すると実行チェーンを確認できます", "closeDetails": "詳細を閉じる", + "pagination": { + "perPage": "ページあたりの件数", + "range": "{2} 件中 {0}–{1} 件" + }, "messagePreview": { "expand": "全文を表示", "collapse": "折りたたむ" diff --git a/dashboard/src/i18n/locales/ru-RU/features/trace.json b/dashboard/src/i18n/locales/ru-RU/features/trace.json index 1122f9a4c..f3ae52756 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/trace.json +++ b/dashboard/src/i18n/locales/ru-RU/features/trace.json @@ -34,6 +34,10 @@ "details": "Подробности", "selectTrace": "Выберите трассировку, чтобы увидеть цепочку выполнения", "closeDetails": "Закрыть подробности", + "pagination": { + "perPage": "Строк на странице", + "range": "{0}–{1} из {2}" + }, "messagePreview": { "expand": "Показать весь текст", "collapse": "Свернуть" diff --git a/dashboard/src/i18n/locales/zh-CN/features/trace.json b/dashboard/src/i18n/locales/zh-CN/features/trace.json index 5d582e4f8..72f8ba834 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/trace.json +++ b/dashboard/src/i18n/locales/zh-CN/features/trace.json @@ -34,6 +34,10 @@ "details": "查看详情", "selectTrace": "选择一条追踪查看完整执行链", "closeDetails": "关闭详情", + "pagination": { + "perPage": "每页条数", + "range": "第 {0}–{1} 条,共 {2} 条" + }, "messagePreview": { "expand": "展开全文", "collapse": "收起" diff --git a/dashboard/tests/traceList.test.mjs b/dashboard/tests/traceList.test.mjs index bcedab4d6..a41b7fd57 100644 --- a/dashboard/tests/traceList.test.mjs +++ b/dashboard/tests/traceList.test.mjs @@ -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, /