feat(dashboard): complete redesign of Trace page

- TraceDisplayer: new timeline-style layout with vertical track, dot markers, and expandable event cards
- TracePage: redesigned topbar with custom toggle switch, removed scoped CSS conflicts
- All colors use explicit hex values with !important to ensure visibility across themes
- Vue 3 Composition API with shallowRef to avoid reactive overhead
This commit is contained in:
LIghtJUNction
2026-03-30 01:07:55 +08:00
parent 2a1544fb6c
commit de35ff5cda
2 changed files with 574 additions and 464 deletions

View File

@@ -1,462 +1,526 @@
<script setup>
import axios from "axios";
import { EventSourcePolyfill } from "event-source-polyfill";
import { resolveApiUrl } from "@/utils/request";
import { shallowRef, nextTick, onMounted, onBeforeUnmount } from 'vue';
import axios from 'axios';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { resolveApiUrl } from '@/utils/request';
let isMounted = false;
const events = shallowRef([]);
const eventIndex = {};
const highlightMap = shallowRef({});
const highlightTimers = {};
let eventSource = null;
let retryTimer = null;
let retryAttempts = 0;
const maxRetryAttempts = 10;
const baseRetryDelay = 1000;
let lastEventId = null;
onMounted(async () => {
isMounted = true;
await fetchTraceHistory();
connectSSE();
window.addEventListener('resize', resizeTimeline);
resizeTimeline();
});
onBeforeUnmount(() => {
isMounted = false;
if (eventSource) { eventSource.close(); eventSource = null; }
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
retryAttempts = 0;
window.removeEventListener('resize', resizeTimeline);
});
function resizeTimeline() {
nextTick(() => {
const timeline = document.querySelector('.trace-timeline');
if (timeline) {
const header = document.querySelector('.timeline-header');
if (header) timeline.style.height = `calc(100vh - ${header.getBoundingClientRect().bottom}px - 16px)`;
}
});
}
async function fetchTraceHistory() {
if (!isMounted) return;
try {
const res = await axios.get('/api/log-history');
if (!isMounted) return;
const logs = res.data?.data?.logs || [];
const traces = logs.filter((item) => item.type === 'trace');
processNewTraces(traces);
} catch (err) {
console.error('Failed to fetch trace history:', err);
}
}
function connectSSE() {
if (eventSource) { eventSource.close(); eventSource = null; }
const token = localStorage.getItem('token');
eventSource = new EventSourcePolyfill(resolveApiUrl('/api/live-log'), {
headers: { Authorization: token ? `Bearer ${token}` : '' },
heartbeatTimeout: 300000
});
eventSource.onopen = () => { retryAttempts = 0; if (!lastEventId) fetchTraceHistory(); };
eventSource.onmessage = (event) => {
if (!isMounted) return;
try {
if (event.lastEventId) lastEventId = event.lastEventId;
const payload = JSON.parse(event.data);
if (payload?.type !== 'trace') return;
processNewTraces([payload]);
} catch (e) { console.error('Failed to parse trace payload:', e); }
};
eventSource.onerror = () => {
if (eventSource) { eventSource.close(); eventSource = null; }
if (retryAttempts >= maxRetryAttempts) { console.error('Trace stream reached max retry attempts.'); return; }
const delay = Math.min(baseRetryDelay * Math.pow(2, retryAttempts), 30000);
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
retryTimer = setTimeout(async () => {
retryAttempts++;
if (!lastEventId) await fetchTraceHistory();
connectSSE();
}, delay);
};
}
function processNewTraces(newTraces) {
if (!isMounted || !newTraces || newTraces.length === 0) return;
const touched = [];
const currentEvents = [...events.value];
newTraces.forEach((trace) => {
if (!trace.span_id) return;
const recordKey = `${trace.time}-${trace.span_id}-${trace.action}`;
let evt = eventIndex[trace.span_id];
if (!evt) {
evt = { span_id: trace.span_id, name: trace.name, umo: trace.umo, sender_name: trace.sender_name, message_outline: trace.message_outline, first_time: trace.time, last_time: trace.time, collapsed: true, visibleCount: 20, records: [], hasAgentPrepare: trace.action === 'astr_agent_prepare' };
eventIndex[trace.span_id] = evt;
currentEvents.push(evt);
}
const exists = evt.records.some((item) => item.key === recordKey);
if (exists) return;
evt.records.push({ time: trace.time, action: trace.action, fieldsText: formatFields(trace.fields), timeLabel: formatTime(trace.time), key: recordKey });
if (trace.action === 'astr_agent_prepare') evt.hasAgentPrepare = true;
if (!evt.first_time || trace.time < evt.first_time) evt.first_time = trace.time;
if (!evt.last_time || trace.time > evt.last_time) evt.last_time = trace.time;
if (!evt.sender_name && trace.sender_name) evt.sender_name = trace.sender_name;
if (!evt.message_outline && trace.message_outline) evt.message_outline = trace.message_outline;
touched.push(trace.span_id);
});
if (touched.length > 0) {
currentEvents.forEach((e) => { e.records.sort((a, b) => b.time - a.time); });
currentEvents.sort((a, b) => b.first_time - a.first_time);
if (currentEvents.length > 300) {
const removed = currentEvents.splice(300);
removed.forEach((e) => { delete eventIndex[e.span_id]; });
}
events.value = currentEvents;
touched.forEach((spanId) => { pulseEvent(spanId); });
}
}
function pulseEvent(spanId) {
if (!spanId || !isMounted) return;
if (highlightTimers[spanId]) clearTimeout(highlightTimers[spanId]);
highlightMap.value = { ...highlightMap.value, [spanId]: true };
const remove = setTimeout(() => {
if (!isMounted) return;
const next = { ...highlightMap.value };
delete next[spanId];
highlightMap.value = next;
delete highlightTimers[spanId];
}, 1500);
highlightTimers[spanId] = remove;
}
function toggleEvent(spanId) {
const evt = eventIndex[spanId];
if (evt) { evt.collapsed = !evt.collapsed; events.value = [...events.value]; resizeTimeline(); }
}
function showMore(spanId) {
const evt = eventIndex[spanId];
if (evt) { evt.visibleCount = Math.min(evt.records.length, evt.visibleCount + 20); events.value = [...events.value]; }
}
function getVisibleRecords(evt) { if (!evt.records.length) return []; return evt.records.slice(0, evt.visibleCount); }
function formatTime(ts) { if (!ts) return ''; const date = new Date(ts * 1000); return `${date.toLocaleString()}.${String(date.getMilliseconds()).padStart(3, '0')}`; }
function shortSpan(spanId) { return spanId ? spanId.slice(0, 8) : ''; }
function formatFields(fields) { if (!fields) return ''; try { return JSON.stringify(fields, null, 2); } catch (e) { return String(fields); } }
</script>
<template>
<div class="trace-wrapper">
<div class="trace-table" ref="scrollEl" :style="{ height: tableHeight }">
<div class="trace-row trace-header">
<div class="trace-cell time">Time</div>
<div class="trace-cell span">Event ID</div>
<div class="trace-cell umo">UMO</div>
<div class="trace-cell sender">Sender</div>
<div class="trace-cell outline">Outline</div>
<div class="trace-cell fields"></div>
<div class="timeline-container">
<!-- Timeline header -->
<div class="timeline-header">
<div class="tl-title">追踪</div>
<div class="tl-subtitle">实时模型调用链</div>
</div>
<!-- Timeline body -->
<div class="trace-timeline">
<!-- Empty state -->
<div v-if="events.length === 0" class="tl-empty">
<div class="tl-empty-icon"></div>
<div class="tl-empty-text">暂无追踪数据</div>
<div class="tl-empty-hint">发送消息后即可看到调用链路</div>
</div>
<!-- Timeline items -->
<div
class="trace-group"
:class="{ highlight: highlightMap[event.span_id] }"
v-for="event in events"
v-for="(event, idx) in events"
:key="event.span_id"
class="tl-item"
:class="{ 'tl-item-active': highlightMap[event.span_id], 'tl-item-expanded': !event.collapsed }"
>
<div class="trace-row trace-event">
<div class="trace-cell time">{{ formatTime(event.first_time) }}</div>
<div class="trace-cell span" :title="event.span_id">
<div class="event-title">
{{ shortSpan(event.span_id) }}
<!-- Timeline line + dot -->
<div class="tl-track">
<div class="tl-dot" :class="{ 'tl-dot-active': event.hasAgentPrepare }"></div>
<div v-if="idx < events.length - 1" class="tl-line"></div>
</div>
<!-- Event card -->
<div class="tl-card">
<!-- Card header -->
<div class="tl-card-header" @click="toggleEvent(event.span_id)">
<div class="tl-card-top">
<span class="tl-event-id">{{ shortSpan(event.span_id) }}</span>
<span class="tl-umo">{{ event.umo || '-' }}</span>
<span class="tl-time">{{ formatTime(event.first_time) }}</span>
</div>
<div class="tl-card-bottom">
<span class="tl-sender">{{ event.sender_name || 'Unknown' }}</span>
<span class="tl-outline">{{ event.message_outline || '-' }}</span>
<span class="tl-expand-btn">{{ event.collapsed ? '展开' : '收起' }}</span>
</div>
</div>
<div class="trace-cell umo">{{ event.umo }}</div>
<div class="trace-cell sender">
<div
class="event-sub"
style="
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ event.sender_name || "-" }}
<!-- Expanded records -->
<div v-if="!event.collapsed && event.records.length > 0" class="tl-records">
<div class="tl-records-header">调用链 · {{ event.records.length }} 条记录</div>
<div v-for="record in getVisibleRecords(event)" :key="record.key" class="tl-record">
<div class="tl-record-left">
<div class="tl-record-time">{{ record.timeLabel }}</div>
<div class="tl-record-action">{{ record.action }}</div>
</div>
<pre class="tl-record-fields">{{ record.fieldsText }}</pre>
</div>
</div>
<div class="trace-cell outline">
<div class="event-sub outline">
{{ event.message_outline || "-" }}
<div v-if="event.visibleCount < event.records.length" class="tl-records-more">
<button @click.stop="showMore(event.span_id)">加载更多 (+{{ event.records.length - event.visibleCount }})</button>
</div>
</div>
<div class="trace-cell fields event-controls">
<v-btn
size="x-small"
variant="text"
color="primary"
@click="toggleEvent(event.span_id)"
>
{{ event.collapsed ? "Expand" : "Collapse" }}
<span v-if="event.hasAgentPrepare" class="agent-dot" />
</v-btn>
</div>
</div>
<div class="trace-records" v-if="!event.collapsed">
<div
class="trace-record"
v-for="record in getVisibleRecords(event)"
:key="record.key"
>
<div class="trace-record-time">{{ record.timeLabel }}</div>
<div class="trace-record-action">{{ record.action }}</div>
<pre class="trace-record-fields">{{ record.fieldsText }}</pre>
</div>
<div
class="event-more"
v-if="event.visibleCount < event.records.length"
>
<v-btn
size="x-small"
variant="tonal"
color="primary"
@click="showMore(event.span_id)"
>
Show more
</v-btn>
</div>
</div>
</div>
<div v-if="events.length === 0" class="trace-empty">
No trace data yet.
</div>
</div>
</div>
</template>
<script>
export default {
name: "TraceDisplayer",
props: {
autoScroll: { type: Boolean, default: true },
maxItems: { type: Number, default: 300 },
},
data() {
return {
events: [],
eventIndex: {},
highlightMap: {},
highlightTimers: {},
eventSource: null,
retryTimer: null,
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
tableHeight: "auto",
};
},
async mounted() {
await this.fetchTraceHistory();
this.connectSSE();
this.updateTableHeight();
window.addEventListener("resize", this.updateTableHeight);
},
beforeUnmount() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryAttempts = 0;
window.removeEventListener("resize", this.updateTableHeight);
},
methods: {
updateTableHeight() {
this.$nextTick(() => {
const el = this.$refs.scrollEl;
if (!el || typeof window === "undefined") return;
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight;
const offsetTop = el.getBoundingClientRect().top;
this.tableHeight = `${Math.max(viewportHeight - offsetTop, 0)}px`;
});
},
async fetchTraceHistory() {
try {
const res = await axios.get("/api/log-history");
const logs = res.data?.data?.logs || [];
const traces = logs.filter((item) => item.type === "trace");
this.processNewTraces(traces);
} catch (err) {
console.error("Failed to fetch trace history:", err);
}
},
connectSSE() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
const token = localStorage.getItem("token");
this.eventSource = new EventSourcePolyfill(
resolveApiUrl("/api/live-log"),
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
heartbeatTimeout: 300000,
},
);
this.eventSource.onopen = () => {
this.retryAttempts = 0;
if (!this.lastEventId) this.fetchTraceHistory();
};
this.eventSource.onmessage = (event) => {
try {
if (event.lastEventId) this.lastEventId = event.lastEventId;
const payload = JSON.parse(event.data);
if (payload?.type !== "trace") return;
this.processNewTraces([payload]);
} catch (e) {
console.error("Failed to parse trace payload:", e);
}
};
this.eventSource.onerror = () => {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryAttempts >= this.maxRetryAttempts) {
console.error("Trace stream reached max retry attempts.");
return;
}
const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000,
);
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryTimer = setTimeout(async () => {
this.retryAttempts++;
if (!this.lastEventId) await this.fetchTraceHistory();
this.connectSSE();
}, delay);
};
},
processNewTraces(newTraces) {
if (!newTraces || newTraces.length === 0) return;
let hasUpdate = false;
const touched = new Set();
newTraces.forEach((trace) => {
if (!trace.span_id) return;
const recordKey = `${trace.time}-${trace.span_id}-${trace.action}`;
let event = this.eventIndex[trace.span_id];
if (!event) {
event = {
span_id: trace.span_id,
name: trace.name,
umo: trace.umo,
sender_name: trace.sender_name,
message_outline: trace.message_outline,
first_time: trace.time,
last_time: trace.time,
collapsed: true,
visibleCount: 20,
records: [],
hasAgentPrepare: trace.action === "astr_agent_prepare",
};
this.eventIndex[trace.span_id] = event;
this.events.push(event);
hasUpdate = true;
}
const exists = event.records.some((item) => item.key === recordKey);
if (exists) return;
event.records.push({
time: trace.time,
action: trace.action,
fieldsText: this.formatFields(trace.fields),
timeLabel: this.formatTime(trace.time),
key: recordKey,
});
if (trace.action === "astr_agent_prepare") event.hasAgentPrepare = true;
if (!event.first_time || trace.time < event.first_time)
event.first_time = trace.time;
if (!event.last_time || trace.time > event.last_time)
event.last_time = trace.time;
if (!event.sender_name && trace.sender_name)
event.sender_name = trace.sender_name;
if (!event.message_outline && trace.message_outline)
event.message_outline = trace.message_outline;
touched.add(trace.span_id);
hasUpdate = true;
});
if (hasUpdate) {
this.events.forEach((event) => {
event.records.sort((a, b) => b.time - a.time);
});
this.events.sort((a, b) => b.first_time - a.first_time);
if (this.events.length > this.maxItems) {
const overflow = this.events.length - this.maxItems;
const removed = this.events.splice(this.maxItems, overflow);
removed.forEach((event) => {
delete this.eventIndex[event.span_id];
});
}
touched.forEach((spanId) => {
this.pulseEvent(spanId);
});
}
},
scrollToBottom() {
const el = this.$refs.scrollEl;
if (el) el.scrollTop = el.scrollHeight;
},
toggleEvent(spanId) {
const event = this.eventIndex[spanId];
if (event) event.collapsed = !event.collapsed;
},
showMore(spanId) {
const event = this.eventIndex[spanId];
if (event)
event.visibleCount = Math.min(
event.records.length,
event.visibleCount + 20,
);
},
pulseEvent(spanId) {
if (!spanId) return;
if (this.highlightTimers[spanId])
clearTimeout(this.highlightTimers[spanId]);
this.highlightMap = { ...this.highlightMap, [spanId]: true };
const remove = setTimeout(() => {
const next = { ...this.highlightMap };
delete next[spanId];
this.highlightMap = next;
const timers = { ...this.highlightTimers };
delete timers[spanId];
this.highlightTimers = timers;
}, 1200);
this.highlightTimers = { ...this.highlightTimers, [spanId]: remove };
},
getVisibleRecords(event) {
if (!event.records.length) return [];
return event.records.slice(0, event.visibleCount);
},
formatTime(ts) {
if (!ts) return "";
const date = new Date(ts * 1000);
return `${date.toLocaleString()}.${String(date.getMilliseconds()).padStart(3, "0")}`;
},
shortSpan(spanId) {
return spanId ? spanId.slice(0, 8) : "";
},
formatFields(fields) {
if (!fields) return "";
try {
const text = JSON.stringify(fields, null, 2);
return text.length > 2000 ? text : text;
} catch (e) {
return String(fields);
}
},
},
};
</script>
<style>
.trace-cell { color: #e2e2e7 !important; }
.trace-header { color: #e2e2e7 !important; }
.trace-table { background: rgba(13,13,15,0.95) !important; }
.trace-group { background: rgba(13,13,15,0.5) !important; }
.trace-empty { color: #e2e2e7 !important; }
</style>
<style scoped>
.trace-wrapper {
height: 100%;
.timeline-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #050507;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
overflow: hidden;
}
.trace-table {
background: var(--v-theme-surface);
border-radius: 0;
padding: 0;
height: 100%;
.timeline-header {
padding: 20px 32px 16px;
background: #0d0d12;
border-bottom: 1px solid rgba(0, 242, 255, 0.12);
flex-shrink: 0;
}
.tl-title {
font-size: 20px;
font-weight: 700;
color: #00F2FF;
letter-spacing: 2px;
text-transform: uppercase;
}
.tl-subtitle {
font-size: 11px;
color: #4b5563;
margin-top: 4px;
letter-spacing: 0.5px;
}
.trace-timeline {
flex: 1;
overflow-y: auto;
font-family: "Fira Code", monospace;
padding: 24px 32px;
}
.trace-row {
display: grid;
grid-template-columns: 200px 100px 300px 90px 180px 140px;
.tl-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: #4b5563;
}
.tl-empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.tl-empty-text {
font-size: 16px;
color: #6b7280;
margin-bottom: 8px;
}
.tl-empty-hint {
font-size: 12px;
color: #4b5563;
}
.tl-item {
display: flex;
gap: 0;
margin-bottom: 0;
}
.tl-item:last-child .tl-line {
display: none;
}
/* Track: dot + line */
.tl-track {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 32px;
}
.tl-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1e293b;
border: 2px solid #334155;
flex-shrink: 0;
margin-top: 18px;
z-index: 1;
transition: all 0.3s ease;
}
.tl-dot-active {
background: #00F2FF;
border-color: #00F2FF;
box-shadow: 0 0 8px rgba(0, 242, 255, 0.5);
}
.tl-item-active .tl-dot {
background: #00F2FF;
border-color: #00F2FF;
box-shadow: 0 0 12px rgba(0, 242, 255, 0.8);
transform: scale(1.3);
}
.tl-line {
width: 2px;
flex: 1;
background: rgba(51, 65, 85, 0.5);
margin-top: 4px;
min-height: 20px;
}
.tl-item-active .tl-line {
background: rgba(0, 242, 255, 0.3);
}
/* Card */
.tl-card {
flex: 1;
margin-left: 12px;
margin-bottom: 16px;
background: #0d0d12;
border: 1px solid rgba(51, 65, 85, 0.4);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.3s ease;
}
.tl-item-active .tl-card {
border-color: rgba(0, 242, 255, 0.4);
box-shadow: 0 0 16px rgba(0, 242, 255, 0.1);
}
.tl-item-expanded .tl-card {
border-color: rgba(0, 242, 255, 0.3);
}
.tl-card-header {
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s ease;
}
.tl-card-header:hover {
background: rgba(0, 242, 255, 0.04);
}
.tl-card-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.tl-event-id {
font-size: 13px;
font-weight: 700;
color: #00F2FF;
background: rgba(0, 242, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(0, 242, 255, 0.2);
}
.tl-umo {
font-size: 11px;
color: #6b7280;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-time {
font-size: 10px;
color: #4b5563;
flex-shrink: 0;
}
.tl-card-bottom {
display: flex;
align-items: center;
gap: 12px;
}
.trace-group {
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
padding: 8px 0;
}
.trace-group.highlight {
background: rgba(59, 130, 246, 0.12);
}
.trace-event {
align-items: start;
}
.trace-header {
font-weight: 600;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
padding-bottom: 10px;
}
.trace-cell {
.tl-sender {
font-size: 12px;
color: #9ca3af;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-outline {
flex: 1;
font-size: 12px;
color: var(--v-theme-primaryText);
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-title {
font-weight: 600;
}
.event-meta {
font-size: 12px;
margin-top: 4px;
}
.event-sub {
font-size: 12px;
margin-top: 2px;
word-break: break-word;
color: var(--v-theme-primaryText);
}
.event-controls {
display: flex;
justify-content: flex-end;
}
.agent-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
margin-left: 6px;
vertical-align: middle;
}
.trace-cell.fields pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--v-theme-primaryText);
}
.trace-empty {
padding: 24px;
text-align: center;
color: var(--v-theme-secondaryText);
}
@media (max-width: 1200px) {
.trace-row {
grid-template-columns: 140px 160px 300px 70px 140px 180px 1fr;
}
.trace-cell.fields {
grid-column: 1 / -1;
}
}
.trace-record {
display: grid;
grid-template-columns: 200px 120px 1fr;
gap: 8px;
padding: 2px 0;
}
.trace-record:last-child {
border-bottom: none;
}
.trace-record-time {
font-size: 11px;
color: var(--v-theme-secondaryText);
}
.trace-record-action {
font-weight: 600;
font-size: 11px;
color: var(--v-theme-primaryText);
}
.trace-record-fields {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
.tl-expand-btn {
font-size: 10px;
color: var(--v-theme-primaryText);
color: #00F2FF;
background: rgba(0, 242, 255, 0.05);
border: 1px solid rgba(0, 242, 255, 0.15);
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.event-more {
/* Records */
.tl-records {
border-top: 1px solid rgba(51, 65, 85, 0.3);
background: rgba(0, 0, 0, 0.3);
padding: 12px 16px;
}
.tl-records-header {
font-size: 10px;
color: #4b5563;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(51, 65, 85, 0.2);
}
.tl-record {
display: flex;
justify-content: center;
padding: 6px 0 2px;
gap: 12px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(51, 65, 85, 0.15);
}
.trace-records {
padding: 4px 0 2px 0;
.tl-record:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.tl-record-left {
flex-shrink: 0;
width: 180px;
}
.tl-record-time {
font-size: 10px;
color: #4b5563;
margin-bottom: 2px;
}
.tl-record-action {
font-size: 11px;
font-weight: 700;
color: #00F2FF;
}
.tl-record-fields {
flex: 1;
margin: 0;
font-size: 10px;
color: #9ca3af;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
background: transparent;
border: none;
padding: 0;
line-height: 1.6;
}
.tl-records-more {
text-align: center;
padding-top: 10px;
}
.tl-records-more button {
background: rgba(0, 242, 255, 0.05);
border: 1px solid rgba(0, 242, 255, 0.15);
color: #00F2FF;
padding: 4px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-family: inherit;
transition: all 0.2s ease;
}
.tl-records-more button:hover {
background: rgba(0, 242, 255, 0.12);
border-color: rgba(0, 242, 255, 0.3);
}
@media (max-width: 700px) {
.tl-umo { display: none; }
.tl-record-left { width: 120px; }
.trace-timeline { padding: 16px; }
.timeline-header { padding: 16px; }
}
</style>

View File

@@ -14,7 +14,7 @@ const fetchTraceSettings = async () => {
try {
const res = await axios.get('/api/trace/settings');
if (res.data?.status === 'ok') {
traceEnabled.value = res.data.data?.trace_enable ?? true;
traceEnabled.value = res.data?.data?.trace_enable ?? true;
}
} catch (err) {
console.error('Failed to fetch trace settings:', err);
@@ -27,7 +27,6 @@ const updateTraceSettings = async () => {
await axios.post('/api/trace/settings', {
trace_enable: traceEnabled.value
});
// Refresh the TraceDisplayer component to reconnect SSE
traceDisplayerKey.value += 1;
} catch (err) {
console.error('Failed to update trace settings:', err);
@@ -43,28 +42,26 @@ onMounted(() => {
<template>
<div style="height: 100%; display: flex; flex-direction: column;">
<div class="trace-header">
<div class="trace-info">
<v-icon size="small" color="info" class="mr-2">mdi-information-outline</v-icon>
<span class="trace-hint">{{ tm('hint') }}</span>
<div class="trace-topbar">
<div class="topbar-left">
<div class="topbar-title">{{ tm('title') || '追踪' }}</div>
<div class="topbar-desc">{{ tm('hint') }}</div>
</div>
<div class="trace-controls">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
<div class="topbar-right">
<div class="switch-wrap">
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
<button
class="switch-btn"
:class="{ 'switch-btn-on': traceEnabled }"
@click="updateTraceSettings"
:disabled="loading"
>
<span class="switch-knob"></span>
</button>
</div>
</div>
</div>
<div style="flex: 1; min-height: 0;">
<div style="flex: 1; min-height: 0; overflow: hidden;">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</div>
@@ -73,43 +70,92 @@ onMounted(() => {
<script>
export default {
name: 'TracePage',
components: {
TraceDisplayer
}
components: { TraceDisplayer }
};
</script>
<style scoped>
.trace-header {
.trace-topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.05);
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 8px 8px 0 0;
margin-bottom: 8px;
padding: 14px 32px;
background: #0a0a0f;
border-bottom: 1px solid rgba(0, 242, 255, 0.1);
flex-shrink: 0;
}
.trace-info {
.topbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.topbar-title {
font-size: 16px;
font-weight: 700;
color: #00F2FF;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 1px;
}
.topbar-desc {
font-size: 11px;
color: #4b5563;
}
.topbar-right {
display: flex;
align-items: center;
}
.trace-hint {
font-size: 13px;
color: #6b7280;
}
.trace-controls {
.switch-wrap {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
}
.switch-label {
font-size: 13px;
color: #4b5563;
white-space: nowrap;
font-size: 12px;
color: #9ca3af;
font-family: 'JetBrains Mono', monospace;
}
.switch-btn {
width: 40px;
height: 22px;
border-radius: 11px;
background: #1e293b;
border: 1px solid #334155;
cursor: pointer;
position: relative;
transition: all 0.3s ease;
padding: 0;
}
.switch-btn:hover {
border-color: rgba(0, 242, 255, 0.3);
}
.switch-btn-on {
background: rgba(0, 242, 255, 0.15);
border-color: rgba(0, 242, 255, 0.4);
}
.switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #475569;
transition: all 0.3s ease;
}
.switch-btn-on .switch-knob {
left: 20px;
background: #00F2FF;
box-shadow: 0 0 8px rgba(0, 242, 255, 0.5);
}
</style>