This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/ai-history-and-enhancement in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 1f893158b329bd1d45a118eb28a5577ed2a9dc24 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 18:39:53 2026 +0800 feat(ai): capture-replay for the triage list blocks + a shared replay tag The four AI triage blocks (traces / zipkin-traces / logs / browser-errors) now capture their list server-side (frozen-always) and replay it on reload with zero re-query — matching the five map blocks. The tools fetch the exact route response and ship it as spec.replayData; the composables render it locally (never seeding the vue-query cache, which would contaminate live views); the views + blocks gate every query in replay (main list, row-detail, facets, landing/instance/endpoint aux feeds) so a reloaded block fires no OAP request on mount or interaction. Capture shape per the trace-query flavor and the operator's ask: - native v2 (queryTraces) + Zipkin carry spans inline → freeze 30. - native v1 (queryBasicTraces) rows carry no spans → freeze 10 and hydrate each with queryTrace(traceId) so the v1 waterfall replays offline too. - logs / browser-errors → freeze up to 100 rows. The captured window is the chat's global range (derived, like the maps); the vestigial per-tool windowMinutes arg is dropped. Logs/browser query at SECOND step (format matched to step). fetch_pod_logs default look-back → 2 min. A shared ChatCapturedTag (new 'replay' icon + "captured <when>") replaces the per-block inline caption across ALL captured blocks incl. pod logs — every AI block is a point-in-time snapshot the agent captured, so it always reads as replay, icon shown from first render. Review fixes folded in: logs + browser views were missing the aux-query replay gate (fired 3 live requests on mount) — now gated like the traces view; the logs pager keeps its count hint in replay. The list fetchers are reused from http/query (load-bearing rule holds: client/graphqlPost is the only OAP talker). Validated live: traces froze 30 v2 rows, logs 100, both reachable. --- apps/bff/src/ai/resources/tools/visualization.yaml | 16 +-- apps/bff/src/ai/skill/kubernetes/tools.ts | 2 +- apps/bff/src/ai/skill/visualization/tools.ts | 120 ++++++++++++++++++--- apps/bff/src/ai/types.ts | 13 +++ apps/bff/src/client/zipkin.ts | 7 +- apps/bff/src/http/query/trace.ts | 8 ++ apps/ui/src/ai/ChatBrowserErrorsBlock.vue | 9 +- apps/ui/src/ai/ChatCapturedTag.vue | 52 +++++++++ apps/ui/src/ai/ChatDeploymentBlock.vue | 14 +-- apps/ui/src/ai/ChatEndpointDependencyBlock.vue | 14 +-- apps/ui/src/ai/ChatFigureBlock.vue | 17 +-- apps/ui/src/ai/ChatHierarchyBlock.vue | 14 +-- apps/ui/src/ai/ChatInstanceTopologyBlock.vue | 14 +-- apps/ui/src/ai/ChatLogsBlock.vue | 9 +- apps/ui/src/ai/ChatPodLogsBlock.vue | 4 +- apps/ui/src/ai/ChatProfilingBlock.vue | 12 +-- apps/ui/src/ai/ChatTopologyBlock.vue | 16 +-- apps/ui/src/ai/ChatTracesBlock.vue | 9 +- apps/ui/src/ai/ChatTranscript.vue | 10 +- apps/ui/src/ai/ChatZipkinTracesBlock.vue | 9 +- apps/ui/src/ai/types.ts | 18 +++- apps/ui/src/ai/useAiConversations.ts | 10 +- apps/ui/src/components/icons/Icon.vue | 9 +- apps/ui/src/i18n/locales/en.json | 3 + .../browser-errors/LayerBrowserErrorsView.vue | 25 +++-- .../layer/browser-errors/useLayerBrowserErrors.ts | 28 +++-- apps/ui/src/layer/logs/LayerLogsView.vue | 31 +++++- apps/ui/src/layer/logs/useLayerLogs.ts | 32 +++++- apps/ui/src/layer/traces/LayerTracesView.vue | 27 ++++- apps/ui/src/layer/traces/LayerZipkinTracesView.vue | 24 ++++- apps/ui/src/layer/traces/useLayerTraces.ts | 28 ++++- apps/ui/src/layer/traces/useZipkinTraces.ts | 24 +++-- 32 files changed, 449 insertions(+), 179 deletions(-) diff --git a/apps/bff/src/ai/resources/tools/visualization.yaml b/apps/bff/src/ai/resources/tools/visualization.yaml index 5390d61..375c62a 100644 --- a/apps/bff/src/ai/resources/tools/visualization.yaml +++ b/apps/bff/src/ai/resources/tools/visualization.yaml @@ -159,7 +159,7 @@ show_endpoint_dependency: show_traces: description: >- - Mount the native distributed-tracing view inline for a service — the real trace LIST plus the span WATERFALL, which the operator browses (click a trace to open its spans). Use it to surface slow / erroring traces for a service so a human can inspect them; there is no tool to read individual span data yourself. Provide the layer and service name; optionally a look-back windowMinutes (default 30). ONLY for a layer whose traces run NATIVE (SkyWalking segments) — for a Zipkin-tracing lay [...] + Mount the native distributed-tracing view inline for a service — the real trace LIST plus the span WATERFALL, which the operator browses (click a trace to open its spans). Use it to surface slow / erroring traces for a service so a human can inspect them; there is no tool to read individual span data yourself. The captured list is frozen over the current chat time range. Provide the layer and service name. ONLY for a layer whose traces run NATIVE (SkyWalking segments) — for a Zipkin- [...] params: layer: >- OAP layer key, e.g. GENERAL @@ -167,8 +167,6 @@ show_traces: service NAME (from list_services) title: >- optional heading for the block - windowMinutes: >- - look-back window in minutes (default 30) list_zipkin_services: description: >- @@ -179,7 +177,7 @@ list_zipkin_services: show_zipkin_traces: description: >- - Mount the Zipkin distributed-tracing view inline — the real trace LIST + span WATERFALL for a ZIPKIN service, which the operator browses. `service` MUST be a Zipkin service name from list_zipkin_services (NOT the SkyWalking name — they differ). Use this for a layer whose traces run on Zipkin (Envoy ALS / rover) after matching the service. Provide the layer (for context) and the matched Zipkin service name; optionally a look-back windowMinutes (default 30). + Mount the Zipkin distributed-tracing view inline — the real trace LIST + span WATERFALL for a ZIPKIN service, which the operator browses. `service` MUST be a Zipkin service name from list_zipkin_services (NOT the SkyWalking name — they differ). Use this for a layer whose traces run on Zipkin (Envoy ALS / rover) after matching the service. The captured list is frozen over the current chat time range. Provide the layer (for context) and the matched Zipkin service name. params: layer: >- OAP layer key, e.g. MESH @@ -187,12 +185,10 @@ show_zipkin_traces: ZIPKIN service name (from list_zipkin_services) title: >- optional heading for the block - windowMinutes: >- - look-back window in minutes (default 30) show_logs: description: >- - Mount the layer LOGS view inline for a service — the real log stream the operator browses (click a row for its detail). Use it to surface a service's logs for a human to read. This is the layer Logs tab (stored logs); it is NOT fetch_pod_logs (that is the Kubernetes on-demand live tail). Provide the layer and service name; optionally a look-back windowMinutes (default 30). + Mount the layer LOGS view inline for a service — the real log stream the operator browses (click a row for its detail). Use it to surface a service's logs for a human to read. This is the layer Logs tab (stored logs); it is NOT fetch_pod_logs (that is the Kubernetes on-demand live tail). The captured stream (up to 100 rows) is frozen over the current chat time range. Provide the layer and service name. params: layer: >- OAP layer key, e.g. GENERAL @@ -200,12 +196,10 @@ show_logs: service NAME (from list_services) title: >- optional heading for the block - windowMinutes: >- - look-back window in minutes (default 30) show_browser_logs: description: >- - Mount the browser-monitoring ERROR list inline for a browser app — the client-side JS error stream the operator browses (click a row for its stack trace). Use it for a BROWSER-family layer's app to surface front-end errors for a human. Provide the layer and service (browser-app) name; optionally a look-back windowMinutes (default 30). + Mount the browser-monitoring ERROR list inline for a browser app — the client-side JS error stream the operator browses (click a row for its stack trace). Use it for a BROWSER-family layer's app to surface front-end errors for a human. The captured list (up to 100 rows) is frozen over the current chat time range. Provide the layer and service (browser-app) name. params: layer: >- OAP browser-layer key @@ -213,5 +207,3 @@ show_browser_logs: browser-app service NAME (from list_services) title: >- optional heading for the block - windowMinutes: >- - look-back window in minutes (default 30) diff --git a/apps/bff/src/ai/skill/kubernetes/tools.ts b/apps/bff/src/ai/skill/kubernetes/tools.ts index 0e00553..6afe473 100644 --- a/apps/bff/src/ai/skill/kubernetes/tools.ts +++ b/apps/bff/src/ai/skill/kubernetes/tools.ts @@ -45,7 +45,7 @@ import { graphqlPost } from '../../../client/graphql.js'; import { fmtSecond, getServerOffsetMinutes } from '../../../util/window.js'; import { toolPrompt } from '../../resources/loader.js'; -const DEFAULT_WINDOW_SEC = 300; // 5m trailing look-back for the agent's read +const DEFAULT_WINDOW_SEC = 120; // 2m trailing look-back for the agent's read const MAX_WINDOW_SEC = 30 * 60; // 30m — same ceiling as the Pod Logs tab const MODEL_LINE_CAP = 80; // lines returned to the model (operator sees the full fetched result) const MODEL_CHAR_CAP = 8_000; diff --git a/apps/bff/src/ai/skill/visualization/tools.ts b/apps/bff/src/ai/skill/visualization/tools.ts index 2fb3331..10eee0d 100644 --- a/apps/bff/src/ai/skill/visualization/tools.ts +++ b/apps/bff/src/ai/skill/visualization/tools.ts @@ -34,6 +34,8 @@ import type { DashboardWidgetResult, DashboardWidgetType, TopologyResponse, + TraceListResponse, + ZipkinTraceListResponse, } from '@skywalking-horizon-ui/api-client'; import type { AiRequestContext } from '../../context.js'; import type { HierarchyGroup, SubPageKind, TopoPeer } from '../../types.js'; @@ -53,9 +55,30 @@ import { buildServiceTopology, emptyTopologyResponse } from '../../../logic/oap/ import { buildDeployment } from '../../../logic/oap/deployment.js'; import { buildInstanceTopology } from '../../../logic/oap/instance-topology.js'; import { buildEndpointDependency } from '../../../logic/oap/endpoint-dependency.js'; -import { zipkinFetchServices } from '../../../client/zipkin.js'; +import { zipkinFetchServices, zipkinFetchTraces } from '../../../client/zipkin.js'; +// Reuse the exported list fetchers so a captured triage block freezes the EXACT +// response the interactive route produces (no re-derived query, no drift). They +// take a resolved serviceId + OAP-local window and reach OAP only through +// client/graphqlPost — the load-bearing rule holds. They live in http/query only +// because their trace core (detectTraceQueryApi / TraceListBody / fetchZipkinList) +// is entangled there; a move to logic/oap would relocate that whole core. +import { fetchNativeList, fetchNativeTraceSpans } from '../../../http/query/trace.js'; +import { fetchLogs } from '../../../http/query/log.js'; +import { fetchBrowserErrors } from '../../../http/query/browser-errors.js'; +import { getServerOffsetMinutes, fmtSecond } from '../../../util/window.js'; import { toolPrompt } from '../../resources/loader.js'; +// Capture caps for the frozen triage lists. Native v2 (queryTraces) + Zipkin +// carry spans inline so 30 replays cheaply; v1 (queryBasicTraces) needs a +// per-trace span fetch, so cap to 10. Logs/browser freeze up to 100 rows. +const TRACE_CAP = 30; +const V1_TRACE_CAP = 10; +const LIST_CAP = 100; +// Derive the captured window (minutes) from the chat's global range — the same +// value the map tools emit, so the frozen block's window matches what was read. +const rangeWindowMinutes = (r: { startMs: number; endMs: number }): number => + Math.max(1, Math.round((r.endMs - r.startMs) / 60_000)); + const ri = toolPrompt('visualization', '_render_input'); const renderInput = z.object({ title: z.string().describe(ri.p('title')), @@ -716,19 +739,44 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa // list→detail interaction. const tracesPrompt = toolPrompt('visualization', 'show_traces'); const tracesTool = tool( - async ({ layer, service, title, windowMinutes }: { layer: string; service: string; title?: string; windowMinutes?: number }): Promise<string> => { + async ({ layer, service, title }: { layer: string; service: string; title?: string }): Promise<string> => { if (!ctx.hasVerb('traces:read')) return 'Permission denied: the current user lacks traces:read.'; const cat = await catalog(); const row = (cat.byLayer.get(layer.toUpperCase()) ?? []).find((s) => s.name === service); if (!row) return `Unknown service "${service}" in layer ${layer}. Use list_services first.`; + // Freeze the native list (frozen-always) so the block replays offline. + const windowMinutes = rangeWindowMinutes(ctx.range); + const offsetMinutes = await getServerOffsetMinutes(ctx.config, ctx.fetch); + const native = await fetchNativeList( + ctx.opts, + { service, serviceId: row.id, startMs: ctx.range.startMs, endMs: ctx.range.endMs, pageSize: TRACE_CAP }, + layer.toUpperCase(), + false, + offsetMinutes, + TRACE_CAP, + ); + // v2 rows carry inline spans (keep 30); v1 rows have none, so cap to 10 and + // hydrate each with its spans so the waterfall replays offline. + if (native.api === 'queryBasicTraces') { + native.traces = native.traces.slice(0, V1_TRACE_CAP); + for (const t of native.traces) { + const tid = t.traceIds[0]; + if (tid) t.spans = await fetchNativeTraceSpans(ctx.opts, tid); + } + } else { + native.traces = native.traces.slice(0, TRACE_CAP); + } + const replayData: TraceListResponse = { generatedAt: Date.now(), source: 'native', native }; ctx.emitTraces({ title: title || `Traces — ${service}`, layer: layer.toUpperCase(), service, serviceId: row.id, - windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : undefined, + windowMinutes, + replayData, }); - return `Mounted the native Traces view for ${service}. The operator can browse the trace list and open a span waterfall in the chat.`; + if (!native.reachable) return `Traces for ${service} are unavailable (${native.error ?? 'unreachable'}).`; + return `Captured ${native.traces.length} native trace(s) for ${service} (${native.api === 'queryBasicTraces' ? 'v1' : 'v2'}). The operator can browse the frozen list and open a span waterfall in the chat.`; }, { name: 'show_traces', @@ -737,7 +785,6 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa layer: z.string().describe(tracesPrompt.p('layer')), service: z.string().describe(tracesPrompt.p('service')), title: z.string().optional().describe(tracesPrompt.p('title')), - windowMinutes: z.number().optional().describe(tracesPrompt.p('windowMinutes')), }), }, ); @@ -781,15 +828,31 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa // focused on a ZIPKIN service name the model matched via list_zipkin_services. const zipkinTracesPrompt = toolPrompt('visualization', 'show_zipkin_traces'); const zipkinTracesTool = tool( - async ({ layer, service, title, windowMinutes }: { layer: string; service: string; title?: string; windowMinutes?: number }): Promise<string> => { + async ({ layer, service, title }: { layer: string; service: string; title?: string }): Promise<string> => { if (!ctx.hasVerb('traces:read')) return 'Permission denied: the current user lacks traces:read.'; + const oap = ctx.config.current.oap; + const zopts = { queryUrl: oap.zipkinUrl, timeoutMs: oap.timeoutMs, auth: oap.auth, fetch: ctx.fetch }; + // Freeze the Zipkin list WITH spans so the waterfall replays offline. + let replayData: ZipkinTraceListResponse; + try { + const rows = await zipkinFetchTraces( + zopts, + { serviceName: service, endTs: ctx.range.endMs, lookback: ctx.range.endMs - ctx.range.startMs, limit: TRACE_CAP }, + true, + ); + replayData = { source: 'zipkin', traces: rows, reachable: true }; + } catch (err) { + replayData = { source: 'zipkin', traces: [], reachable: false, error: err instanceof Error ? err.message : String(err) }; + } ctx.emitZipkinTraces({ title: title || `Zipkin traces — ${service}`, layer: layer.toUpperCase(), service, - windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : undefined, + windowMinutes: rangeWindowMinutes(ctx.range), + replayData, }); - return `Mounted the Zipkin Traces view for ${service}. The operator can browse the trace list and open a span waterfall in the chat. (If no traces match, the service name may not be a Zipkin service — re-check with list_zipkin_services.)`; + if (!replayData.reachable) return `Could not reach Zipkin for ${service} (${replayData.error}).`; + return `Captured ${replayData.traces.length} Zipkin trace(s) for ${service}. The operator can browse the frozen list and open a span waterfall in the chat. (If empty, the name may not be a Zipkin service — re-check with list_zipkin_services.)`; }, { name: 'show_zipkin_traces', @@ -798,7 +861,6 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa layer: z.string().describe(zipkinTracesPrompt.p('layer')), service: z.string().describe(zipkinTracesPrompt.p('service')), title: z.string().optional().describe(zipkinTracesPrompt.p('title')), - windowMinutes: z.number().optional().describe(zipkinTracesPrompt.p('windowMinutes')), }), }, ); @@ -808,19 +870,33 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa // from fetch_pod_logs (the k8s on-demand live tail); this is the layer Logs tab. const logsPrompt = toolPrompt('visualization', 'show_logs'); const logsTool = tool( - async ({ layer, service, title, windowMinutes }: { layer: string; service: string; title?: string; windowMinutes?: number }): Promise<string> => { + async ({ layer, service, title }: { layer: string; service: string; title?: string }): Promise<string> => { if (!ctx.hasVerb('logs:read')) return 'Permission denied: the current user lacks logs:read.'; const cat = await catalog(); const row = (cat.byLayer.get(layer.toUpperCase()) ?? []).find((s) => s.name === service); if (!row) return `Unknown service "${service}" in layer ${layer}. Use list_services first.`; + const maxLogs = Math.min(LIST_CAP, ctx.config.current.performance.limits.maxPageSize.logs); + // Logs query at SECOND step — format the window in SECOND (ctx.window is the + // chat's MINUTE-step string; mixing step + format throws verifyDateTimeString). + const logOffset = await getServerOffsetMinutes(ctx.config, ctx.fetch); + const logWindow = { start: fmtSecond(ctx.range.startMs, logOffset), end: fmtSecond(ctx.range.endMs, logOffset) }; + const replayData = await fetchLogs( + ctx.opts, + { serviceId: row.id }, + logWindow, + { pageNum: 1, pageSize: maxLogs }, + false, + ); ctx.emitLogs({ title: title || `Logs — ${service}`, layer: layer.toUpperCase(), service, serviceId: row.id, - windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : undefined, + windowMinutes: rangeWindowMinutes(ctx.range), + replayData, }); - return `Mounted the Logs view for ${service}. The operator can browse the log stream and open a record's detail in the chat.`; + if (!replayData.reachable) return `Logs for ${service} are unavailable (${replayData.error ?? 'unreachable'}).`; + return `Captured ${replayData.logs.length} log row(s) for ${service}. The operator can browse the frozen stream and open a record's detail in the chat.`; }, { name: 'show_logs', @@ -829,7 +905,6 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa layer: z.string().describe(logsPrompt.p('layer')), service: z.string().describe(logsPrompt.p('service')), title: z.string().optional().describe(logsPrompt.p('title')), - windowMinutes: z.number().optional().describe(logsPrompt.p('windowMinutes')), }), }, ); @@ -839,19 +914,31 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa // error stream + its row→stack-trace detail (BROWSER-family layers only). const browserErrorsPrompt = toolPrompt('visualization', 'show_browser_logs'); const browserErrorsTool = tool( - async ({ layer, service, title, windowMinutes }: { layer: string; service: string; title?: string; windowMinutes?: number }): Promise<string> => { + async ({ layer, service, title }: { layer: string; service: string; title?: string }): Promise<string> => { if (!ctx.hasVerb('browser-errors:read')) return 'Permission denied: the current user lacks browser-errors:read.'; const cat = await catalog(); const row = (cat.byLayer.get(layer.toUpperCase()) ?? []).find((s) => s.name === service); if (!row) return `Unknown service "${service}" in layer ${layer}. Use list_services first.`; + const maxBrowser = Math.min(LIST_CAP, ctx.config.current.performance.limits.maxPageSize.browserLogs); + const beOffset = await getServerOffsetMinutes(ctx.config, ctx.fetch); + const beWindow = { start: fmtSecond(ctx.range.startMs, beOffset), end: fmtSecond(ctx.range.endMs, beOffset) }; + const replayData = await fetchBrowserErrors( + ctx.opts, + { serviceId: row.id }, + beWindow, + { pageNum: 1, pageSize: maxBrowser }, + false, + ); ctx.emitBrowserErrors({ title: title || `Browser errors — ${service}`, layer: layer.toUpperCase(), service, serviceId: row.id, - windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : undefined, + windowMinutes: rangeWindowMinutes(ctx.range), + replayData, }); - return `Mounted the Browser errors view for ${service}. The operator can browse the client-side error list and open a stack trace in the chat.`; + if (!replayData.reachable) return `Browser errors for ${service} are unavailable (${replayData.error ?? 'unreachable'}).`; + return `Captured ${replayData.logs.length} browser error(s) for ${service}. The operator can browse the frozen list and open a stack trace in the chat.`; }, { name: 'show_browser_logs', @@ -860,7 +947,6 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa layer: z.string().describe(browserErrorsPrompt.p('layer')), service: z.string().describe(browserErrorsPrompt.p('service')), title: z.string().optional().describe(browserErrorsPrompt.p('title')), - windowMinutes: z.number().optional().describe(browserErrorsPrompt.p('windowMinutes')), }), }, ); diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts index ff36791..428a61b 100644 --- a/apps/bff/src/ai/types.ts +++ b/apps/bff/src/ai/types.ts @@ -24,14 +24,18 @@ */ import type { + BrowserErrorsResponse, DashboardWidget, DashboardWidgetResult, DeploymentResponse, EndpointDependencyResponse, InstanceTopologyResponse, + LogsResponse, ProfileAnalyzationTree, ServiceHierarchyResponse, TopologyResponse, + TraceListResponse, + ZipkinTraceListResponse, } from '@skywalking-horizon-ui/api-client'; import type { ProfilingLogLine, ProfilingSummary, ProfilingType } from '../logic/oap/profiling.js'; @@ -271,6 +275,9 @@ export interface TracesSpec { service: string; serviceId?: string; windowMinutes?: number; + /** Captured native trace list (rows carry inline spans on v2). Replay renders + * it frozen; v1 detail (no inline spans) is disabled offline. */ + replayData?: TraceListResponse; } /** A mounted ZIPKIN TRACES view — the real Zipkin trace view embedded read-only, @@ -283,6 +290,8 @@ export interface ZipkinTracesSpec { layer: string; service: string; windowMinutes?: number; + /** Captured Zipkin trace list WITH spans, so a reload replays the waterfall offline. */ + replayData?: ZipkinTraceListResponse; } /** A mounted LOGS view — the real layer Logs view embedded read-only, focused on @@ -293,6 +302,8 @@ export interface LogsSpec { service: string; serviceId?: string; windowMinutes?: number; + /** Captured log rows — self-contained (detail/facets are client-side). */ + replayData?: LogsResponse; } /** A mounted BROWSER-ERRORS view — the real browser-monitoring error list @@ -304,6 +315,8 @@ export interface BrowserErrorsSpec { service: string; serviceId?: string; windowMinutes?: number; + /** Captured error rows — self-contained (stack/detail are client-side). */ + replayData?: BrowserErrorsResponse; } export type SseEvent = diff --git a/apps/bff/src/client/zipkin.ts b/apps/bff/src/client/zipkin.ts index 5740f12..1e2354a 100644 --- a/apps/bff/src/client/zipkin.ts +++ b/apps/bff/src/client/zipkin.ts @@ -140,6 +140,9 @@ export function summariseZipkinTrace(spans: ZipkinSpan[]): ZipkinTraceListRow { export async function zipkinFetchTraces( opts: ZipkinClientOpts, query: ZipkinTracesQuery, + /** Carry each trace's spans through on the row (for a frozen, offline-replayable + * capture); the interactive list omits them and re-fetches on click. */ + carrySpans = false, ): Promise<ZipkinTraceListRow[]> { const qs = new URLSearchParams(); if (query.serviceName) qs.set('serviceName', query.serviceName); @@ -155,7 +158,9 @@ export async function zipkinFetchTraces( qs.set('limit', String(query.limit ?? 20)); const path = `/api/v2/traces?${qs.toString()}`; const arr = await zipkinFetch<ZipkinSpan[][]>(opts, path); - return arr.map(summariseZipkinTrace).filter((r) => r.traceId); + return arr + .map((spans) => (carrySpans ? { ...summariseZipkinTrace(spans), spans } : summariseZipkinTrace(spans))) + .filter((r) => r.traceId); } /** Fetch a single trace by id. Returns the full span array diff --git a/apps/bff/src/http/query/trace.ts b/apps/bff/src/http/query/trace.ts index e9e1ddd..c878122 100644 --- a/apps/bff/src/http/query/trace.ts +++ b/apps/bff/src/http/query/trace.ts @@ -374,6 +374,14 @@ export async function fetchNativeList( } } +/** Fetch one native trace's spans (queryTrace). Hydrates v1 (queryBasicTraces) + * list rows — which carry no inline spans — so a captured list can replay the + * waterfall offline (the AI trace-capture path). */ +export async function fetchNativeTraceSpans(opts: GraphqlOptions, traceId: string): Promise<NativeSpan[]> { + const env = await graphqlPost<{ trace: { spans: NativeSpan[] } | null }>(opts, QUERY_TRACE_DETAIL, { traceId }); + return env.trace?.spans ?? []; +} + export async function fetchZipkinList( opts: GraphqlOptions, body: TraceListBody, diff --git a/apps/ui/src/ai/ChatBrowserErrorsBlock.vue b/apps/ui/src/ai/ChatBrowserErrorsBlock.vue index a65c8a1..e5ee192 100644 --- a/apps/ui/src/ai/ChatBrowserErrorsBlock.vue +++ b/apps/ui/src/ai/ChatBrowserErrorsBlock.vue @@ -21,21 +21,26 @@ <script setup lang="ts"> import { useI18n } from 'vue-i18n'; import LayerBrowserErrorsView from '@/layer/browser-errors/LayerBrowserErrorsView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { BrowserErrorsSpec } from './types'; -defineProps<{ n: number; spec: BrowserErrorsSpec }>(); +defineProps<{ n: number; spec: BrowserErrorsSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); </script> <template> <div class="cbe"> - <div class="cbe__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div> + <div class="cbe__cap"> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> + </div> <div class="cbe__view"> <LayerBrowserErrorsView :embedded="true" :layer-key="spec.layer.toLowerCase()" :focus-service="spec.service" :focus-window-minutes="spec.windowMinutes" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatCapturedTag.vue b/apps/ui/src/ai/ChatCapturedTag.vue new file mode 100644 index 0000000..4602cf3 --- /dev/null +++ b/apps/ui/src/ai/ChatCapturedTag.vue @@ -0,0 +1,52 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- Shared "captured <when>" tag for every frozen/replayed chat block (figures, + maps, profiling, triage lists). The replay icon marks the block as a + point-in-time snapshot replayed from history, not live data. --> +<script setup lang="ts"> +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import Icon from '@/components/icons/Icon.vue'; + +const props = defineProps<{ at?: number }>(); +const { t } = useI18n({ useScope: 'global' }); +const when = computed<string>(() => + props.at + ? new Date(props.at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + : '', +); +</script> + +<template> + <span class="cct" :title="t('Captured snapshot — replayed from history, not live data')"> + <span class="cct__sep">·</span> + <Icon name="replay" :size="11" /> + <span>{{ when ? t('captured {when}', { when }) : t('captured') }}</span> + </span> +</template> + +<style scoped> +.cct { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--sw-fg-3, #6b6f7a); +} +.cct__sep { + margin-right: 1px; +} +</style> diff --git a/apps/ui/src/ai/ChatDeploymentBlock.vue b/apps/ui/src/ai/ChatDeploymentBlock.vue index e26bbc7..754212a 100644 --- a/apps/ui/src/ai/ChatDeploymentBlock.vue +++ b/apps/ui/src/ai/ChatDeploymentBlock.vue @@ -20,24 +20,19 @@ bespoke graph: one renderer across the product, so the instance hexes, ring health, animated edges and node/edge detail are identical to the page. --> <script setup lang="ts"> -import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import LayerDeploymentView from '@/layer/service-map/LayerDeploymentView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { DeploymentSpec } from './types'; -const props = defineProps<{ n: number; spec: DeploymentSpec; capturedAt?: number }>(); +defineProps<{ n: number; spec: DeploymentSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); -const captured = computed<string>(() => - props.capturedAt - ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); </script> <template> <div class="cdp"> <div class="cdp__cap"> - {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" class="cdp__ts"> · captured {{ captured }}</span> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> </div> <div class="cdp__view"> <LayerDeploymentView @@ -69,9 +64,6 @@ const captured = computed<string>(() => text-overflow: ellipsis; white-space: nowrap; } -.cdp__ts { - color: var(--sw-fg-3); -} /* The embedded view is content-sized (its canvas carries a bounded pixel height), so the block just clips any overflow and keeps its own rounded edge. */ .cdp__view { diff --git a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue index d37a0ba..854123d 100644 --- a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue +++ b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue @@ -21,24 +21,19 @@ upstream/downstream chain; node expand + node/edge detail stay intact. No bespoke graph: one renderer across the product. --> <script setup lang="ts"> -import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import LayerEndpointDependencyView from '@/layer/endpoint-dependency/LayerEndpointDependencyView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { EndpointDependencySpec } from './types'; -const props = defineProps<{ n: number; spec: EndpointDependencySpec; capturedAt?: number }>(); +defineProps<{ n: number; spec: EndpointDependencySpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); -const captured = computed<string>(() => - props.capturedAt - ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); </script> <template> <div class="ced"> <div class="ced__cap"> - {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" class="ced__ts"> · captured {{ captured }}</span> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> </div> <div class="ced__view"> <LayerEndpointDependencyView @@ -71,9 +66,6 @@ const captured = computed<string>(() => text-overflow: ellipsis; white-space: nowrap; } -.ced__ts { - color: var(--sw-fg-3); -} /* The embedded view is content-sized (its graph card carries a bounded pixel height); the block clips any overflow and keeps its own rounded edge. */ .ced__view { diff --git a/apps/ui/src/ai/ChatFigureBlock.vue b/apps/ui/src/ai/ChatFigureBlock.vue index 12a404b..83e8ff3 100644 --- a/apps/ui/src/ai/ChatFigureBlock.vue +++ b/apps/ui/src/ai/ChatFigureBlock.vue @@ -20,6 +20,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import ChatWidgetRenderer from './ChatWidgetRenderer.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { FigureBlock } from './types'; const props = defineProps<{ block: FigureBlock }>(); @@ -34,20 +35,12 @@ const label = computed<string>(() => { : t('Figure {n}', { n: props.block.n }); return props.block.title ? `${base} · ${props.block.title}` : base; }); - -// Frozen point-in-time data — stamp when it was captured so a reloaded figure -// isn't read as current. -const captured = computed<string>(() => - props.block.capturedAt - ? new Date(props.block.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); </script> <template> <figure class="cfb"> <figcaption class="cfb__label"> - {{ label }}<span v-if="captured" class="cfb__ts"> · captured {{ captured }}</span> + {{ label }}<ChatCapturedTag :at="block.capturedAt" /> </figcaption> <!-- single --> @@ -99,12 +92,6 @@ const captured = computed<string>(() => color: var(--sw-fg-3); margin-bottom: 8px; } -.cfb__ts { - font-weight: var(--sw-fw-medium); - text-transform: none; - letter-spacing: normal; - color: var(--sw-fg-3); -} .cfb__cap { font-size: var(--sw-fs-sm); font-weight: var(--sw-fw-medium); diff --git a/apps/ui/src/ai/ChatHierarchyBlock.vue b/apps/ui/src/ai/ChatHierarchyBlock.vue index 464394c..425130d 100644 --- a/apps/ui/src/ai/ChatHierarchyBlock.vue +++ b/apps/ui/src/ai/ChatHierarchyBlock.vue @@ -19,18 +19,13 @@ standalone mode: no topology underneath, no dim/modal, the fan centred and auto-fit into its own viewBox. One renderer across the product. --> <script setup lang="ts"> -import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import ServiceHierarchyOverlay from '@/layer/service-map/ServiceHierarchyOverlay.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { HierarchySpec } from './types'; -const props = defineProps<{ n: number; spec: HierarchySpec; capturedAt?: number }>(); +defineProps<{ n: number; spec: HierarchySpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); -const captured = computed<string>(() => - props.capturedAt - ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); // resolveNodePos is unused in standalone mode (the fan centres on the origin), // but the prop is required for the topology-overlay path. @@ -42,7 +37,7 @@ function noNodePos(): null { <template> <div class="chb"> <div class="chb__cap"> - {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" class="chb__ts"> · captured {{ captured }}</span> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> </div> <div class="chb__view"> <ServiceHierarchyOverlay @@ -75,9 +70,6 @@ function noNodePos(): null { text-overflow: ellipsis; white-space: nowrap; } -.chb__ts { - color: var(--sw-fg-3); -} /* Stage for the standalone Smartscape fan (position:relative so the overlay's absolute fill anchors here; the fan's viewBox scales to this box). */ .chb__view { diff --git a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue index 2bc06bb..4ee5d87 100644 --- a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue +++ b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue @@ -21,24 +21,19 @@ instances) with the calls between them; pan/zoom + node/edge detail intact. No bespoke graph: one renderer across the product. --> <script setup lang="ts"> -import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import LayerInstanceTopologyView from '@/layer/service-map/LayerInstanceTopologyView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { InstanceTopologySpec } from './types'; -const props = defineProps<{ n: number; spec: InstanceTopologySpec; capturedAt?: number }>(); +defineProps<{ n: number; spec: InstanceTopologySpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); -const captured = computed<string>(() => - props.capturedAt - ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); </script> <template> <div class="cit"> <div class="cit__cap"> - {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" class="cit__ts"> · captured {{ captured }}</span> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> </div> <div class="cit__view"> <LayerInstanceTopologyView @@ -71,9 +66,6 @@ const captured = computed<string>(() => text-overflow: ellipsis; white-space: nowrap; } -.cit__ts { - color: var(--sw-fg-3); -} /* The embedded view fills its host (.imv is height:100%), so give it a bounded fixed stage; fit-to-screen + the zoom controls handle a wider graph. */ .cit__view { diff --git a/apps/ui/src/ai/ChatLogsBlock.vue b/apps/ui/src/ai/ChatLogsBlock.vue index 7b444d6..6290f5f 100644 --- a/apps/ui/src/ai/ChatLogsBlock.vue +++ b/apps/ui/src/ai/ChatLogsBlock.vue @@ -20,21 +20,26 @@ <script setup lang="ts"> import { useI18n } from 'vue-i18n'; import LayerLogsView from '@/layer/logs/LayerLogsView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { LogsSpec } from './types'; -defineProps<{ n: number; spec: LogsSpec }>(); +defineProps<{ n: number; spec: LogsSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); </script> <template> <div class="clg"> - <div class="clg__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div> + <div class="clg__cap"> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> + </div> <div class="clg__view"> <LayerLogsView :embedded="true" :layer-key="spec.layer.toLowerCase()" :focus-service="spec.service" :focus-window-minutes="spec.windowMinutes" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatPodLogsBlock.vue b/apps/ui/src/ai/ChatPodLogsBlock.vue index 802d36c..73c00e6 100644 --- a/apps/ui/src/ai/ChatPodLogsBlock.vue +++ b/apps/ui/src/ai/ChatPodLogsBlock.vue @@ -23,9 +23,10 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Icon from '@/components/icons/Icon.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { PodLogSpec } from './types'; -const props = defineProps<{ n: number; spec: PodLogSpec }>(); +const props = defineProps<{ n: number; spec: PodLogSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); const target = computed(() => [props.spec.service, props.spec.pod].filter(Boolean).join(' / ')); @@ -49,6 +50,7 @@ const hasFilter = computed(() => includeKw.value.length > 0 || excludeKw.value.l <span v-if="target" class="plog__target">{{ target }}</span> <span v-if="target" class="plog__sep">·</span> <span class="plog__container-name">{{ spec.container }}</span> + <ChatCapturedTag :at="capturedAt" /> </div> <div v-if="hasFilter" class="plog__filter"> diff --git a/apps/ui/src/ai/ChatProfilingBlock.vue b/apps/ui/src/ai/ChatProfilingBlock.vue index 3dce609..8a0090c 100644 --- a/apps/ui/src/ai/ChatProfilingBlock.vue +++ b/apps/ui/src/ai/ChatProfilingBlock.vue @@ -18,17 +18,12 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { ProfilingResultSpec } from './types'; const props = defineProps<{ n: number; spec: ProfilingResultSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); -const captured = computed<string>(() => - props.capturedAt - ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); - const hasData = computed<boolean>(() => props.spec.reachable && props.spec.trees.some((tr) => tr.elements.length)); // Compact task facts — the flame carries no context of its own. @@ -51,7 +46,7 @@ const facts = computed<string[]>(() => { <template> <div class="cpf"> <div class="cpf__cap"> - {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" class="cpf__ts"> · captured {{ captured }}</span> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> </div> <div class="cpf__facts"> <span class="cpf__type">{{ spec.profilingType }}</span> @@ -82,9 +77,6 @@ const facts = computed<string[]>(() => { color: var(--sw-fg-2, #9aa0ac); border-bottom: 1px solid var(--sw-line, #2a2d36); } -.cpf__ts { - color: var(--sw-fg-3, #6b6f7a); -} .cpf__facts { display: flex; flex-wrap: wrap; diff --git a/apps/ui/src/ai/ChatTopologyBlock.vue b/apps/ui/src/ai/ChatTopologyBlock.vue index a679352..ab7f7fa 100644 --- a/apps/ui/src/ai/ChatTopologyBlock.vue +++ b/apps/ui/src/ai/ChatTopologyBlock.vue @@ -20,26 +20,19 @@ one-hop map. No bespoke graph: one renderer across the product, so the hex nodes, edges, health colours and metrics are identical to the page. --> <script setup lang="ts"> -import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import LayerServiceMapView from '@/layer/service-map/LayerServiceMapView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { TopologySpec } from './types'; -const props = defineProps<{ n: number; spec: TopologySpec; capturedAt?: number }>(); +defineProps<{ n: number; spec: TopologySpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); - -// Frozen point-in-time graph — document WHEN it was captured (see ChatFigureBlock). -const captured = computed<string>(() => - props.capturedAt - ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : '', -); </script> <template> <div class="ctp"> <div class="ctp__cap"> - {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" class="ctp__ts"> · captured {{ captured }}</span> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> </div> <div class="ctp__view"> <LayerServiceMapView @@ -74,9 +67,6 @@ const captured = computed<string>(() => text-overflow: ellipsis; white-space: nowrap; } -.ctp__ts { - color: var(--sw-fg-3); -} /* The embedded map fills its host (height:100%), so give it a bounded stage. Taller than a dashboard cell so a focused one-hop graph reads at a usable size; the higher fit-scale cap + zoom controls handle the rest. */ diff --git a/apps/ui/src/ai/ChatTracesBlock.vue b/apps/ui/src/ai/ChatTracesBlock.vue index a91a743..73c27b2 100644 --- a/apps/ui/src/ai/ChatTracesBlock.vue +++ b/apps/ui/src/ai/ChatTracesBlock.vue @@ -27,10 +27,11 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import LayerTracesView from '@/layer/traces/LayerTracesView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import { useLayers } from '@/shell/useLayers'; import type { TracesSpec } from './types'; -const props = defineProps<{ n: number; spec: TracesSpec }>(); +const props = defineProps<{ n: number; spec: TracesSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); const router = useRouter(); const { layers } = useLayers(); @@ -50,7 +51,9 @@ function openZipkinTab(): void { <template> <div class="ctr"> - <div class="ctr__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div> + <div class="ctr__cap"> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> + </div> <div v-if="!hasTraces" class="ctr__note">{{ t('This layer has no traces component.') }}</div> <div v-else-if="isZipkinOnly" class="ctr__note ctr__note--act"> @@ -63,6 +66,8 @@ function openZipkinTab(): void { :layer-key="spec.layer.toLowerCase()" :focus-service="spec.service" :focus-window-minutes="spec.windowMinutes" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatTranscript.vue b/apps/ui/src/ai/ChatTranscript.vue index db7a26d..9ec511a 100644 --- a/apps/ui/src/ai/ChatTranscript.vue +++ b/apps/ui/src/ai/ChatTranscript.vue @@ -96,16 +96,16 @@ function fmtTime(at: number): string { <ChatSubPageBlock v-else-if="b.kind === 'subpage'" :n="b.n" :spec="b.spec" /> <ChatProposalBlock v-else-if="b.kind === 'proposal'" :block="b" /> <ChatProfilingBlock v-else-if="b.kind === 'profiling'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> - <ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" :spec="b.spec" /> + <ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatHierarchyBlock v-else-if="b.kind === 'hierarchy'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatTopologyBlock v-else-if="b.kind === 'topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatDeploymentBlock v-else-if="b.kind === 'deployment'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatInstanceTopologyBlock v-else-if="b.kind === 'instance-topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatEndpointDependencyBlock v-else-if="b.kind === 'endpoint-dependency'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> - <ChatTracesBlock v-else-if="b.kind === 'traces'" :n="b.n" :spec="b.spec" /> - <ChatZipkinTracesBlock v-else-if="b.kind === 'zipkin-traces'" :n="b.n" :spec="b.spec" /> - <ChatLogsBlock v-else-if="b.kind === 'logs'" :n="b.n" :spec="b.spec" /> - <ChatBrowserErrorsBlock v-else-if="b.kind === 'browser-errors'" :n="b.n" :spec="b.spec" /> + <ChatTracesBlock v-else-if="b.kind === 'traces'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> + <ChatZipkinTracesBlock v-else-if="b.kind === 'zipkin-traces'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> + <ChatLogsBlock v-else-if="b.kind === 'logs'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> + <ChatBrowserErrorsBlock v-else-if="b.kind === 'browser-errors'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <span v-else class="tx__tool" :class="b.status"> <span class="tx__tool-dot" />{{ b.name }} </span> diff --git a/apps/ui/src/ai/ChatZipkinTracesBlock.vue b/apps/ui/src/ai/ChatZipkinTracesBlock.vue index 66ae0cd..7502476 100644 --- a/apps/ui/src/ai/ChatZipkinTracesBlock.vue +++ b/apps/ui/src/ai/ChatZipkinTracesBlock.vue @@ -23,21 +23,26 @@ <script setup lang="ts"> import { useI18n } from 'vue-i18n'; import LayerZipkinTracesView from '@/layer/traces/LayerZipkinTracesView.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; import type { ZipkinTracesSpec } from './types'; -defineProps<{ n: number; spec: ZipkinTracesSpec }>(); +defineProps<{ n: number; spec: ZipkinTracesSpec; capturedAt?: number }>(); const { t } = useI18n({ useScope: 'global' }); </script> <template> <div class="czt"> - <div class="czt__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div> + <div class="czt__cap"> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> + </div> <div class="czt__view"> <LayerZipkinTracesView :embedded="true" :layer-key="spec.layer.toLowerCase()" :focus-service="spec.service" :focus-window-minutes="spec.windowMinutes" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts index 8f29a6d..def09cf 100644 --- a/apps/ui/src/ai/types.ts +++ b/apps/ui/src/ai/types.ts @@ -26,6 +26,10 @@ import type { ProfileAnalyzationTree, ServiceHierarchyResponse, TopologyResponse, + TraceListResponse, + ZipkinTraceListResponse, + LogsResponse, + BrowserErrorsResponse, } from '@skywalking-horizon-ui/api-client'; export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid'; @@ -245,6 +249,7 @@ export interface TracesSpec { service: string; serviceId?: string; windowMinutes?: number; + replayData?: TraceListResponse; } // A mounted Zipkin Traces view (real feature view, embedded read-only) focused on @@ -255,6 +260,7 @@ export interface ZipkinTracesSpec { layer: string; service: string; windowMinutes?: number; + replayData?: ZipkinTraceListResponse; } // A mounted layer Logs view (real feature view, embedded read-only) focused on a @@ -265,6 +271,7 @@ export interface LogsSpec { service: string; serviceId?: string; windowMinutes?: number; + replayData?: LogsResponse; } // A mounted browser-monitoring error list (real feature view, embedded @@ -275,6 +282,7 @@ export interface BrowserErrorsSpec { service: string; serviceId?: string; windowMinutes?: number; + replayData?: BrowserErrorsResponse; } export type SseEvent = @@ -306,16 +314,16 @@ export type Block = | { kind: 'subpage'; n: number; spec: SubPageSpec } | { kind: 'proposal'; n: number; spec: ProposalSpec; status: ProposalStatus; taskId?: string; error?: string } | { kind: 'profiling'; n: number; spec: ProfilingResultSpec; capturedAt?: number } - | { kind: 'podlogs'; n: number; spec: PodLogSpec } + | { kind: 'podlogs'; n: number; spec: PodLogSpec; capturedAt?: number } | { kind: 'hierarchy'; n: number; spec: HierarchySpec; capturedAt?: number } | { kind: 'topology'; n: number; spec: TopologySpec; capturedAt?: number } | { kind: 'deployment'; n: number; spec: DeploymentSpec; capturedAt?: number } | { kind: 'instance-topology'; n: number; spec: InstanceTopologySpec; capturedAt?: number } | { kind: 'endpoint-dependency'; n: number; spec: EndpointDependencySpec; capturedAt?: number } - | { kind: 'traces'; n: number; spec: TracesSpec } - | { kind: 'zipkin-traces'; n: number; spec: ZipkinTracesSpec } - | { kind: 'logs'; n: number; spec: LogsSpec } - | { kind: 'browser-errors'; n: number; spec: BrowserErrorsSpec } + | { kind: 'traces'; n: number; spec: TracesSpec; capturedAt?: number } + | { kind: 'zipkin-traces'; n: number; spec: ZipkinTracesSpec; capturedAt?: number } + | { kind: 'logs'; n: number; spec: LogsSpec; capturedAt?: number } + | { kind: 'browser-errors'; n: number; spec: BrowserErrorsSpec; capturedAt?: number } | { kind: 'tool'; name: string; status: 'running' | 'done' | 'denied' }; export type FigureBlock = Extract<Block, { kind: 'figure' }>; diff --git a/apps/ui/src/ai/useAiConversations.ts b/apps/ui/src/ai/useAiConversations.ts index 6629755..a14d8d6 100644 --- a/apps/ui/src/ai/useAiConversations.ts +++ b/apps/ui/src/ai/useAiConversations.ts @@ -242,16 +242,16 @@ async function send(text: string): Promise<void> { else if (ev.type === 'subpage') assistant.blocks.push({ kind: 'subpage', n: ev.n, spec: ev.spec }); else if (ev.type === 'proposal') assistant.blocks.push({ kind: 'proposal', n: ev.n, spec: ev.spec, status: 'pending' }); else if (ev.type === 'profiling') assistant.blocks.push({ kind: 'profiling', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); - else if (ev.type === 'podlogs') assistant.blocks.push({ kind: 'podlogs', n: ev.n, spec: ev.spec }); + else if (ev.type === 'podlogs') assistant.blocks.push({ kind: 'podlogs', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'hierarchy') assistant.blocks.push({ kind: 'hierarchy', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'topology') assistant.blocks.push({ kind: 'topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'deployment') assistant.blocks.push({ kind: 'deployment', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'instance-topology') assistant.blocks.push({ kind: 'instance-topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'endpoint-dependency') assistant.blocks.push({ kind: 'endpoint-dependency', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); - else if (ev.type === 'traces') assistant.blocks.push({ kind: 'traces', n: ev.n, spec: ev.spec }); - else if (ev.type === 'zipkin-traces') assistant.blocks.push({ kind: 'zipkin-traces', n: ev.n, spec: ev.spec }); - else if (ev.type === 'logs') assistant.blocks.push({ kind: 'logs', n: ev.n, spec: ev.spec }); - else if (ev.type === 'browser-errors') assistant.blocks.push({ kind: 'browser-errors', n: ev.n, spec: ev.spec }); + else if (ev.type === 'traces') assistant.blocks.push({ kind: 'traces', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); + else if (ev.type === 'zipkin-traces') assistant.blocks.push({ kind: 'zipkin-traces', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); + else if (ev.type === 'logs') assistant.blocks.push({ kind: 'logs', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); + else if (ev.type === 'browser-errors') assistant.blocks.push({ kind: 'browser-errors', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'error') appendToken(assistant.blocks, `\n\n⚠ ${ev.message}`); else if (ev.type === 'done') break; } diff --git a/apps/ui/src/components/icons/Icon.vue b/apps/ui/src/components/icons/Icon.vue index 50cad00..a2bff42 100644 --- a/apps/ui/src/components/icons/Icon.vue +++ b/apps/ui/src/components/icons/Icon.vue @@ -57,7 +57,8 @@ export type IconName = | 'ai' | 'external' | 'close' - | 'trash'; + | 'trash' + | 'replay'; </script> <template> @@ -254,5 +255,11 @@ export type IconName = <path d="M5 7h14M10 7V5h4v2M8 7l1 13h6l1-13" /> <path d="M10.5 11v5M13.5 11v5" /> </template> + <!-- history / replay: a rewound arc + a clock hand — frozen point-in-time data --> + <template v-else-if="name === 'replay'"> + <path d="M3.5 12a8.5 8.5 0 1 0 2.4-5.9" /> + <path d="M3 4v4h4" /> + <path d="M12 8v4l2.5 1.5" /> + </template> </svg> </template> diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index dfbefef..8523221 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1640,6 +1640,9 @@ "Stop": "Stop", "Model": "Model", "Suggested action: start {type}": "Suggested action: start {type}", + "captured {when}": "captured {when}", + "captured": "captured", + "Captured snapshot — replayed from history, not live data": "Captured snapshot — replayed from history, not live data", "{n} segments": "{n} segments", "{n} frames": "{n} frames", "all endpoints": "all endpoints", diff --git a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue index 395ee29..9893b02 100644 --- a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue +++ b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue @@ -26,7 +26,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import type { BrowserErrorCategory, BrowserErrorRow, LayerDef } from '@/api/client'; +import type { BrowserErrorCategory, BrowserErrorRow, BrowserErrorsResponse, LayerDef } from '@/api/client'; import { useLayers } from '@/shell/useLayers'; import { useSetupStore } from '@/state/setup'; import { useSelectedService } from '@/layer/useSelectedService'; @@ -49,10 +49,15 @@ const props = defineProps<{ layerKey?: string; focusService?: string; focusWindowMinutes?: number; + /** REPLAY (chat reload): render the frozen captured error list, never query. + * The mount auto-run is skipped and the Prev/Next pager is hidden. */ + replay?: boolean; + replayData?: BrowserErrorsResponse | null; }>(); const route = useRoute(); const { t } = useI18n({ useScope: 'global' }); const embedded = computed(() => Boolean(props.embedded)); +const replay = computed(() => Boolean(props.replay)); const layerKey = computed(() => props.layerKey && props.layerKey.length > 0 ? props.layerKey : String(route.params.layerKey ?? ''), ); @@ -82,7 +87,10 @@ const safeCfg = computed(() => { metrics: layer.value.metrics, }).landing; }); -const landing = useLayerLanding(safeLayer, safeCfg); +// Aux feeds power the hidden version/page filter chrome; in replay they must fire +// NO OAP request. Landing + endpoints take a replay gate; instances has none, so +// starve its service to null in replay. +const landing = useLayerLanding(safeLayer, safeCfg, undefined, replay); // Embedded takes the focus service from the prop; the route resolves it from // the shared layerSelection store — overriding here keeps the chat block from // touching that global selection. @@ -155,7 +163,8 @@ const allCategories = ref<BrowserErrorCategory>('ALL'); // the BROWSER "Versions" are instances → serviceVersionId; "Pages" are // endpoints → pagePathId. Reuse the shared layer instance/endpoint feeds. const selectedVersionId = ref(''); -const { instances: versionList } = useLayerInstances(layerKey, serviceName); +const toolbarService = computed(() => (replay.value ? null : serviceName.value)); +const { instances: versionList } = useLayerInstances(layerKey, toolbarService); // Page (endpoint) is a searchable combobox (shared EndpointCombo), not a // plain dropdown: OAP returns only a top-N endpoint list, so the typed @@ -168,7 +177,7 @@ const selectedPageId = ref(''); const selectedPageLabel = ref(''); const pageQuery = ref(''); const pageLimit = ref(50); -const { endpoints: pageList, isFetching: pagesLoading } = useLayerEndpoints(layerKey, serviceName, pageQuery, pageLimit); +const { endpoints: pageList, isFetching: pagesLoading } = useLayerEndpoints(layerKey, serviceName, pageQuery, pageLimit, replay); function pickPage(name: string): void { selectedPageId.value = pageList.value.find((p) => p.name === name)?.id ?? ''; selectedPageLabel.value = name; @@ -184,7 +193,9 @@ function clearPage(): void { // the query reads that snapshot, so it fires only on Run query — never the // prior service's errors on a fresh tab. page + pageSize stay live (paging // is a direct action, not a staged filter). -const hasQueried = ref(false); +// Replay seeds this true so the frozen rows render straight past the +// "Run query" gate; the composable keeps the query itself disabled. +const hasQueried = ref(replay.value); interface AppliedBrowserConditions { service: string | null; serviceVersionId: string; @@ -219,6 +230,7 @@ const { logs, total, reachable, queryError, isFetching, refetch } = useLayerBrow startMs: computed(() => applied.value.startMs), endMs: computed(() => applied.value.endMs), enabled: hasQueried, + replayData: computed(() => props.replayData ?? null), }); function runQuery(): void { applyConditions(); @@ -231,6 +243,7 @@ function runQuery(): void { // renders self-contained (version/page filters stay All — service-wide errors). onMounted(() => { if (!embedded.value) return; + if (replay.value) return; // frozen snapshot: no query, hasQueried is pre-seeded if (props.focusWindowMinutes) windowMinutes.value = props.focusWindowMinutes; runQuery(); }); @@ -509,7 +522,7 @@ function loc(row: BrowserErrorRow): string { <div class="lg-pager"> <span class="hint">{{ t('page {page} · showing {shown} of {total} loaded', { page, shown: filteredLogs.length, total }) }}</span> - <div class="lg-pager-ctrls"> + <div v-if="!replay" class="lg-pager-ctrls"> <button class="sw-btn small" type="button" :disabled="page <= 1 || isFetching" @click="page--">{{ t('Prev') }}</button> <button class="sw-btn small" type="button" :disabled="!hasMorePages || isFetching" @click="page++">{{ t('Next') }}</button> </div> diff --git a/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts index c5f8d84..89fb26e 100644 --- a/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts +++ b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts @@ -39,9 +39,16 @@ export interface BrowserErrorParams { /** Gate the query — when false it never runs. Manual-fire holds it until * the operator presses Run query. Defaults to always-on. */ enabled?: Ref<boolean>; + /** REPLAY mode: the captured error list to render from. When present the + * query NEVER fetches — a reloaded conversation replays the exact rows with + * zero OAP round-trip. Rendered locally below, not seeded into the query + * cache, so a chat snapshot can't leak into a live view. */ + replayData?: Ref<BrowserErrorsResponse | null>; } export function useLayerBrowserErrors(layerKey: Ref<string>, params: BrowserErrorParams) { + // replay mode is on whenever captured data is supplied. + const replay = computed(() => !!params.replayData?.value); const q = useQuery<BrowserErrorsResponse>({ queryKey: [ 'layer-browser-errors', @@ -72,18 +79,27 @@ export function useLayerBrowserErrors(layerKey: Ref<string>, params: BrowserErro page: params.page.value, pageSize: params.pageSize.value, }), - enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? params.enabled.value : true)), + // Replay is static: never fetch (data comes from replayData below). + enabled: computed( + () => layerKey.value.length > 0 && (params.enabled ? params.enabled.value : true) && !replay.value, + ), staleTime: 15_000, }); // No auto-refresh subscription: this is a triage page that owns its time // range, and the topbar suspends the global ticker for it ("Paused"). + // Replay renders straight from the captured payload — NOT through the shared + // query cache. Seeding initialData under the live query key would let a chat + // snapshot serve a live view during staleTime (and vice-versa). + const data = computed<BrowserErrorsResponse | null>(() => + replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null), + ); return { - data: computed(() => q.data.value ?? null), - logs: computed(() => q.data.value?.logs ?? []), - total: computed(() => q.data.value?.total ?? 0), - reachable: computed(() => q.data.value?.reachable ?? true), - queryError: computed(() => q.data.value?.error ?? null), + data, + logs: computed(() => data.value?.logs ?? []), + total: computed(() => data.value?.total ?? 0), + reachable: computed(() => data.value?.reachable ?? true), + queryError: computed(() => data.value?.error ?? null), isFetching: q.isFetching, error: q.error, refetch: q.refetch, diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue b/apps/ui/src/layer/logs/LayerLogsView.vue index 63a52d2..08d8a36 100644 --- a/apps/ui/src/layer/logs/LayerLogsView.vue +++ b/apps/ui/src/layer/logs/LayerLogsView.vue @@ -27,7 +27,7 @@ <script setup lang="ts"> import { computed, onMounted, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; -import type { LayerDef, LogRow, LogTagFilter } from '@/api/client'; +import type { LayerDef, LogRow, LogsResponse, LogTagFilter } from '@/api/client'; import { useLayerLanding } from '@/layer/useLayerLanding'; import { useLayerLogs, useLayerLogFacets } from '@/layer/logs/useLayerLogs'; import { useLayerInstances } from '@/layer/useLayerInstances'; @@ -56,9 +56,15 @@ const props = defineProps<{ layerKey?: string; focusService?: string; focusWindowMinutes?: number; + /** REPLAY mode: render the captured log list with zero re-query — no mount + * auto-run, no pager (paging would refetch). Implies embedded. */ + replay?: boolean; + replayData?: LogsResponse | null; }>(); const route = useRoute(); const embedded = computed(() => Boolean(props.embedded)); +const replay = computed(() => Boolean(props.replay)); +const replayDataRef = computed<LogsResponse | null>(() => props.replayData ?? null); const layerKey = computed(() => props.layerKey && props.layerKey.length > 0 ? props.layerKey : String(route.params.layerKey ?? ''), ); @@ -78,7 +84,10 @@ const safeCfg = computed(() => { slots: layer.value.slots, caps: layer.value.caps, metrics: layer.value.metrics, }).landing; }); -const landing = useLayerLanding(safeLayer, safeCfg); +// The landing / instance / endpoint feeds power the hidden filter chrome; in +// replay they must fire NO OAP request. Landing + endpoints take a replay gate; +// instances has none, so starve its service to null in replay. +const landing = useLayerLanding(safeLayer, safeCfg, undefined, replay); // Embedded takes the focus service from the prop; the route resolves it from // the shared layerSelection store — overriding here means the chat block never // touches that global selection. @@ -120,7 +129,8 @@ const showEndpointSelector = computed(() => logScope.value !== 'endpoint'); // How the picked instance is used depends on `logScope`: pinned primary // selector under `instance` scope, optional narrower otherwise. const { selectedInstance, setSelectedInstance } = useSelectedInstance(); -const { instances: instanceList } = useLayerInstances(layerKey, serviceName); +const toolbarService = computed(() => (replay.value ? null : serviceName.value)); +const { instances: instanceList } = useLayerInstances(layerKey, toolbarService); // Logs (and traces) intentionally do NOT auto-select an instance. // Default is `All` so the stream starts broad; the operator opts into // narrowing by picking from the dropdown. Auto-selection is reserved @@ -156,6 +166,7 @@ const { endpoints: endpointList, isFetching: endpointsLoading } = useLayerEndpoi serviceName, endpointQuery, endpointLimit, + replay, ); // No endpoint auto-pick on Logs either — same reasoning as the // instance picker above. Default is `All`; operator narrows by hand. @@ -291,6 +302,7 @@ const { logs, total, isFetching, error, refetch } = useLayerLogs(layerKey, { startMs: aStartMs, endMs: aEndMs, enabled: hasQueried, + replayData: replayDataRef, }); const { facets, refetch: refetchFacets } = useLayerLogFacets(layerKey, { @@ -303,6 +315,7 @@ const { facets, refetch: refetchFacets } = useLayerLogFacets(layerKey, { startMs: aStartMs, endMs: aEndMs, enabled: hasQueried, + replay, }); // Run query refetches BOTH the log stream and the facet sample (level @@ -321,6 +334,11 @@ function runQuery(): void { onMounted(() => { if (!embedded.value) return; if (props.focusWindowMinutes) windowMinutes.value = props.focusWindowMinutes; + // Replay renders the captured rows — flip past the Run-query prompt, never fetch. + if (replay.value) { + hasQueried.value = true; + return; + } runQuery(); }); @@ -537,8 +555,11 @@ watch( @jump-trace="jumpToTrace($event.traceId, $event.ts)" /> <div class="lg-pager"> - <span class="hint">page {{ page }} · showing {{ filteredLogs.length }} of {{ total }} total</span> - <div class="lg-pager-ctrls"> + <span class="hint"> + <template v-if="replay">showing {{ filteredLogs.length }} of {{ total }} captured</template> + <template v-else>page {{ page }} · showing {{ filteredLogs.length }} of {{ total }} total</template> + </span> + <div v-if="!replay" class="lg-pager-ctrls"> <button class="sw-btn small" type="button" :disabled="page <= 1" @click="page--">Prev</button> <button class="sw-btn small" diff --git a/apps/ui/src/layer/logs/useLayerLogs.ts b/apps/ui/src/layer/logs/useLayerLogs.ts index 3cd98bd..da1e068 100644 --- a/apps/ui/src/layer/logs/useLayerLogs.ts +++ b/apps/ui/src/layer/logs/useLayerLogs.ts @@ -35,9 +35,15 @@ export interface LogListParams { /** Gate the query — when false it never runs. Manual-fire pages hold it * until the operator presses Run query. Defaults to always-on. */ enabled?: Ref<boolean>; + /** REPLAY mode: the captured log list to render from. When present the query + * NEVER fetches — a reloaded chat block replays the exact rows with zero OAP + * round-trip, so it can't page to a fresh window and survives an offline OAP. */ + replayData?: Ref<LogsResponse | null>; } export function useLayerLogs(layerKey: Ref<string>, params: LogListParams) { + // replay mode is on whenever captured data is supplied. + const replay = computed(() => !!params.replayData?.value); const q = useQuery<LogsResponse>({ queryKey: [ 'layer-logs', @@ -69,14 +75,22 @@ export function useLayerLogs(layerKey: Ref<string>, params: LogListParams) { page: params.page.value, pageSize: params.pageSize.value, }), - enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? params.enabled.value : true)), + // Replay is static: never fetch (rows come from replayData below). + enabled: computed( + () => layerKey.value.length > 0 && !replay.value && (params.enabled ? params.enabled.value : true), + ), staleTime: 15_000, }); + // Replay renders straight from the captured payload — NOT the query cache, so a + // chat snapshot can't serve a live view (or vice-versa) during staleTime. + const data = computed<LogsResponse | null>(() => + replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null), + ); return { - data: computed(() => q.data.value ?? null), - logs: computed(() => q.data.value?.logs ?? []), - total: computed(() => q.data.value?.total ?? 0), + data, + logs: computed(() => data.value?.logs ?? []), + total: computed(() => data.value?.total ?? 0), isLoading: q.isLoading, isFetching: q.isFetching, error: q.error, @@ -100,6 +114,9 @@ export interface LogFacetParams { startMs?: Ref<number | null>; endMs?: Ref<number | null>; enabled?: Ref<boolean>; + /** REPLAY mode: gate the facet fetch off — the view falls back to client-side + * level counts over the replayed rows, so no OAP sample is needed offline. */ + replay?: Ref<boolean>; } export function useLayerLogFacets(layerKey: Ref<string>, params: LogFacetParams) { @@ -129,7 +146,12 @@ export function useLayerLogFacets(layerKey: Ref<string>, params: LogFacetParams) : {}), sampleSize: 200, }), - enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? params.enabled.value : true)), + enabled: computed( + () => + layerKey.value.length > 0 && + !(params.replay?.value ?? false) && + (params.enabled ? params.enabled.value : true), + ), staleTime: 30_000, }); return { diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue b/apps/ui/src/layer/traces/LayerTracesView.vue index 6d3f409..a5b6136 100644 --- a/apps/ui/src/layer/traces/LayerTracesView.vue +++ b/apps/ui/src/layer/traces/LayerTracesView.vue @@ -42,6 +42,7 @@ import type { LayerDef, NativeSpan, NativeTraceListRow, + TraceListResponse, TraceQueryOrder, TraceQueryState, } from '@/api/client'; @@ -69,9 +70,15 @@ const props = defineProps<{ focusEndpointId?: string; focusInstanceId?: string; focusWindowMinutes?: number; + /** REPLAY (captured chat): render the frozen list from `replayData` and fire NO + * OAP query — no auto-run, no aux toolbar fetches, no pager. */ + replay?: boolean; + replayData?: TraceListResponse | null; }>(); const route = useRoute(); const embedded = computed(() => Boolean(props.embedded)); +const replay = computed(() => Boolean(props.replay)); +const replayDataRef = computed<TraceListResponse | null>(() => props.replayData ?? null); const layerKey = computed(() => props.layerKey && props.layerKey.length > 0 ? props.layerKey : String(route.params.layerKey ?? ''), ); @@ -90,7 +97,8 @@ const safeCfg = computed(() => { slots: layer.value.slots, caps: layer.value.caps, metrics: layer.value.metrics, }).landing; }); -const landing = useLayerLanding(safeLayer, safeCfg); +// Replay hides the whole toolbar this rollup feeds, so it fires zero queries. +const landing = useLayerLanding(safeLayer, safeCfg, undefined, replay); // Embedded mode takes the focus service straight from the prop; the route uses // the shared layerSelection store (resolved to a name). Overriding here means // the chat block never touches that global selection. @@ -184,8 +192,12 @@ watch(isCustomRange, (custom) => { const NATIVE_SOURCE = ref<'native'>('native'); const sourceRef = computed<'native'>(() => 'native'); -const { instances } = useLayerInstances(layerKey, serviceName); -const { endpoints } = useLayerEndpoints(layerKey, serviceName, endpointQuery, ref(50)); +// The instance/endpoint lists feed the hidden filter chrome; in replay they must +// fire NO query. Endpoints takes a replay gate; instances has none, so we starve +// it of a service (its `enabled` needs one). +const toolbarService = computed(() => (replay.value ? null : serviceName.value)); +const { instances } = useLayerInstances(layerKey, toolbarService); +const { endpoints } = useLayerEndpoints(layerKey, toolbarService, endpointQuery, ref(50), replay); // '' ↔ null bridges the All sentinel (TypeaheadSelect value is a string). const instanceSelectOptions = computed(() => [ { value: '', label: t('All') }, @@ -234,6 +246,7 @@ const { native, isFetching, refetch } = useLayerTraces(layerKey, { customStart: cCustomStart, customEnd: cCustomEnd, enabled: queryEnabled, + replayData: replayDataRef, }); // Which OAP query answered. `queryBasicTraces` (Trace Query v1 API) @@ -343,6 +356,12 @@ watch(() => route.query.dNonce, applyDrillFromRoute, { immediate: true }); // query on mount, so the block renders self-contained without a Run-query click. onMounted(() => { if (!embedded.value) return; + // Replay: render the captured list (query stays disabled, data comes from + // replayData) — flip `hasQueried` so the list path shows, but fire nothing. + if (replay.value) { + hasQueried.value = true; + return; + } if (props.focusInstanceId) instanceId.value = props.focusInstanceId; if (props.focusEndpointId) endpointId.value = props.focusEndpointId; if (props.focusWindowMinutes) windowMinutes.value = props.focusWindowMinutes; @@ -372,7 +391,7 @@ function changeSelectedTraceId(id: string): void { } const traceIdRef = computed(() => selectedTraceId.value); -const { nativeDetail, isFetching: detailFetching } = useTraceDetail(traceIdRef, sourceRef); +const { nativeDetail, isFetching: detailFetching } = useTraceDetail(traceIdRef, sourceRef, undefined, replay); // Spans fed to the shared detail card: list-embedded (`queryTraces`) // when present, else the on-demand `queryTrace` fetch. const detailSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? nativeDetail.value?.spans ?? []); diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue index 3df956c..8687c20 100644 --- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue +++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue @@ -25,7 +25,12 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; -import type { NativeTraceListRow, ZipkinSpan, ZipkinTraceListRow } from '@skywalking-horizon-ui/api-client'; +import type { + NativeTraceListRow, + ZipkinSpan, + ZipkinTraceListResponse, + ZipkinTraceListRow, +} from '@skywalking-horizon-ui/api-client'; const { t } = useI18n({ useScope: 'global' }); import { useLayerZipkinTraces, useZipkinTrace } from '@/layer/traces/useZipkinTraces'; @@ -57,8 +62,12 @@ const props = defineProps<{ * assistant from list_zipkin_services — it differs from the SkyWalking name. */ focusService?: string; focusWindowMinutes?: number; + /** REPLAY (chat reload): render the captured list+spans, never re-query. */ + replay?: boolean; + replayData?: ZipkinTraceListResponse | null; }>(); const embedded = computed(() => Boolean(props.embedded)); +const replay = computed(() => Boolean(props.replay)); const route = useRoute(); const layerKey = computed(() => @@ -140,6 +149,7 @@ const { traces, isFetching, error, refetch } = useLayerZipkinTraces({ limit: cLimit, annotationQuery: cAnno, enabled: queryEnabled, + replayData: computed(() => props.replayData ?? null), }); const { openTrace } = useZipkinTracePopout(); @@ -198,8 +208,8 @@ const { spanName, remoteServiceName, // Embedded (chat) hides the whole toolbar, so skip the autocomplete fetches - // that only feed its dropdowns. - enabled: !embedded.value, + // that only feed its dropdowns. Replay must never re-query OAP either. + enabled: !embedded.value && !replay.value, }); const serviceSelectOptions = computed(() => [ @@ -247,7 +257,7 @@ const selectedRowSpans = computed<ZipkinSpan[] | null>(() => { const fallbackTraceId = computed<string | null>(() => selectedTraceId.value && !selectedRowSpans.value ? selectedTraceId.value : null, ); -const { spans: fetchedSpans, isLoading: fetchLoading } = useZipkinTrace(fallbackTraceId); +const { spans: fetchedSpans, isLoading: fetchLoading } = useZipkinTrace(fallbackTraceId, replay); const selectedSpans = computed<ZipkinSpan[]>(() => selectedRowSpans.value ?? fetchedSpans.value); const selectedLoading = computed<boolean>(() => Boolean(fallbackTraceId.value) && fetchLoading.value); @@ -278,6 +288,12 @@ onMounted(() => { if (props.focusWindowMinutes && props.focusWindowMinutes > 0) { lookbackMs.value = props.focusWindowMinutes * 60_000; } + // Replay renders the captured list straight from replayData — mark queried so + // the results show, but never fire the query. + if (replay.value) { + hasQueried.value = true; + return; + } runQuery(); }); diff --git a/apps/ui/src/layer/traces/useLayerTraces.ts b/apps/ui/src/layer/traces/useLayerTraces.ts index 723ba53..686c587 100644 --- a/apps/ui/src/layer/traces/useLayerTraces.ts +++ b/apps/ui/src/layer/traces/useLayerTraces.ts @@ -57,11 +57,18 @@ export interface TraceListParams { * skip fetching while the popout is open (shareable URLs land on * trace detail; no need to also pull a list page). */ enabled?: Ref<boolean>; + /** REPLAY: a captured list to render from. Present ⇒ the query never fires and + * data/native/zipkin come straight from it — a reloaded chat replays the exact + * list (with inline spans on v2) with zero OAP round-trip. */ + replayData?: Ref<TraceListResponse | null>; } export function useLayerTraces(layerKey: Ref<string>, params: TraceListParams) { // Preview-only: forward the draft `traces` block (source selector). const previewCfg = usePreviewLayerBlock(layerKey, 'traces'); + // Replay is on whenever a captured list is supplied — the query stays disabled + // and data is served straight from replayData below. + const replay = computed(() => !!params.replayData?.value); const q = useQuery<TraceListResponse>({ queryKey: [ 'layer-traces', @@ -108,14 +115,22 @@ export function useLayerTraces(layerKey: Ref<string>, params: TraceListParams) { ...(previewCfg.value ? { previewConfig: previewCfg.value } : {}), }), enabled: computed( - () => layerKey.value.length > 0 && (params.enabled ? params.enabled.value : true), + () => + layerKey.value.length > 0 && + (params.enabled ? params.enabled.value : true) && + !replay.value, ), staleTime: 15_000, }); + // Replay renders straight from the captured payload — NOT through the query + // cache (seeding it would contaminate live trace views during staleTime). + const list = computed<TraceListResponse | null>(() => + replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null), + ); return { - data: computed(() => q.data.value ?? null), - native: computed(() => q.data.value?.native ?? null), - zipkin: computed(() => q.data.value?.zipkin ?? null), + data: list, + native: computed(() => list.value?.native ?? null), + zipkin: computed(() => list.value?.zipkin ?? null), isLoading: q.isLoading, isFetching: q.isFetching, error: q.error, @@ -138,6 +153,9 @@ export function useTraceDetail( * this lets cold-tier trace IDs resolve. When null, the BFF * defaults to OAP's last-1-day `queryTrace` window. */ atMs?: Ref<number | null>, + /** REPLAY: gate the on-demand detail fetch off. Captured v2 rows carry inline + * spans (detail renders with no query); v1 has no offline detail — acceptable. */ + replay?: Ref<boolean>, ) { const rangeKey = computed(() => atMs?.value ?? null); const q = useQuery<TraceDetailResponse>({ @@ -154,7 +172,7 @@ export function useTraceDetail( : undefined; return bffClient.trace.detail(traceId.value!, source.value, range); }, - enabled: computed(() => !!traceId.value), + enabled: computed(() => !!traceId.value && !(replay?.value ?? false)), staleTime: 60_000, }); return { diff --git a/apps/ui/src/layer/traces/useZipkinTraces.ts b/apps/ui/src/layer/traces/useZipkinTraces.ts index 20b6527..f873933 100644 --- a/apps/ui/src/layer/traces/useZipkinTraces.ts +++ b/apps/ui/src/layer/traces/useZipkinTraces.ts @@ -50,13 +50,20 @@ export interface ZipkinTracesParams { /** Run only when the user has clicked Run query — mirrors the native * trace tab's "explicit fetch" model. */ enabled?: Ref<boolean>; + /** REPLAY mode: the captured list (with inline spans) to render from. When + * present the query NEVER fetches — a reloaded conversation replays the exact + * data with zero OAP round-trip and survives an offline OAP. */ + replayData?: Ref<ZipkinTraceListResponse | null>; } export function useLayerZipkinTraces(params: ZipkinTracesParams) { + // Replay is on whenever captured data is supplied. + const replay = computed(() => !!params.replayData?.value); // Zipkin allows "all services" — a blank serviceName queries every // local endpoint. Only gate on the operator's explicit `enabled` - // flag (Run query click), not on serviceName presence. - const enabled = computed(() => (params.enabled ? params.enabled.value : true)); + // flag (Run query click), not on serviceName presence. Replay is static: + // never fetch (data comes from replayData below). + const enabled = computed(() => (params.enabled ? params.enabled.value : true) && !replay.value); const q = useQuery<ZipkinTraceListResponse>({ queryKey: [ 'zipkin-traces', @@ -87,9 +94,13 @@ export function useLayerZipkinTraces(params: ZipkinTracesParams) { enabled, staleTime: 15_000, }); + // Replay renders straight from the captured payload — NOT through the shared + // query cache. Seeding initialData under the live query key would let a chat + // snapshot serve a live view during staleTime (and vice-versa). + const data = computed(() => (replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null))); return { - data: computed(() => q.data.value ?? null), - traces: computed(() => q.data.value?.traces ?? []), + data, + traces: computed(() => data.value?.traces ?? []), isLoading: q.isLoading, isFetching: q.isFetching, error: q.error, @@ -97,11 +108,12 @@ export function useLayerZipkinTraces(params: ZipkinTracesParams) { }; } -export function useZipkinTrace(traceId: Ref<string | null>) { +export function useZipkinTrace(traceId: Ref<string | null>, replay?: Ref<boolean>) { const q = useQuery<ZipkinTraceDetailResponse>({ queryKey: ['zipkin-trace', traceId], queryFn: () => bffClient.zipkin.trace(traceId.value!), - enabled: computed(() => Boolean(traceId.value)), + // Replay rows carry their spans inline, so the by-id fallback never fires. + enabled: computed(() => Boolean(traceId.value) && !replay?.value), staleTime: 60_000, }); return {
