feat(frontend): migrate runtime log page and restore build
Move the runtime log screen into the new pages structure, add compact page navigation, and apply the minimal component fixes needed to keep the refactored frontend buildable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { type FolderTree } from '@/api/folder'
|
import { type FolderTree as FolderTreeNode } from '@/api/folder'
|
||||||
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
folders: FolderTree[]
|
folders: FolderTreeNode[]
|
||||||
selectedId?: string | null
|
selectedId?: string | null
|
||||||
onSelect: (folder: FolderTree) => void
|
onSelect: (folder: FolderTreeNode) => void
|
||||||
onCreate: (parentId: string | null) => void
|
onCreate: (parentId: string | null) => void
|
||||||
onRename: (folder: FolderTree) => void
|
onRename: (folder: FolderTreeNode) => void
|
||||||
onDelete: (folder: FolderTree) => void
|
onDelete: (folder: FolderTreeNode) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const expandedIds = ref<Set<string>>(new Set())
|
const expandedIds = ref<Set<string>>(new Set())
|
||||||
@@ -22,7 +22,7 @@ function toggleExpand(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
function handleContextMenu(e: MouseEvent, _folder: FolderTreeNode) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// 显示右键菜单
|
// 显示右键菜单
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ watch(() => props.isExpanded, (expanded, wasExpanded) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => props.model, (model) => {
|
||||||
|
if (!props.isExpanded) {
|
||||||
|
editingModel.value = { ...model }
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(editingModel, (model) => {
|
||||||
|
emit('update', { ...model })
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// 当 test 通过后,props.model.enabled 会更新,需要同步到 editingModel
|
// 当 test 通过后,props.model.enabled 会更新,需要同步到 editingModel
|
||||||
watch(() => props.model.enabled, (enabled) => {
|
watch(() => props.model.enabled, (enabled) => {
|
||||||
editingModel.value.enabled = enabled
|
editingModel.value.enabled = enabled
|
||||||
|
|||||||
760
frontend/src/pages/logs/index.vue
Normal file
760
frontend/src/pages/logs/index.vue
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { logApi, type Log, type LogQueryParams, type LogStats } from '@/api/log'
|
||||||
|
import { Terminal, RefreshCw, Bot, MessageSquare, Settings, AlertCircle, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
type TimePreset = '1h' | '6h' | '24h' | '7d' | 'custom'
|
||||||
|
|
||||||
|
interface LogFilters {
|
||||||
|
preset: TimePreset
|
||||||
|
start_at: string
|
||||||
|
end_at: string
|
||||||
|
request_id: string
|
||||||
|
route: string
|
||||||
|
operation: string
|
||||||
|
status_code: string
|
||||||
|
log_type: string
|
||||||
|
level: string
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = ref<Log[]>([])
|
||||||
|
const stats = ref<LogStats | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const expandedLogId = ref<string | null>(null)
|
||||||
|
const autoRefresh = ref(false)
|
||||||
|
let refreshInterval: ReturnType<typeof window.setInterval> | null = null
|
||||||
|
|
||||||
|
const pageSizeOptions = [20, 50, 100]
|
||||||
|
const timePresets: Array<{ value: TimePreset; label: string }> = [
|
||||||
|
{ value: '1h', label: '1h' },
|
||||||
|
{ value: '6h', label: '6h' },
|
||||||
|
{ value: '24h', label: '24h' },
|
||||||
|
{ value: '7d', label: '7d' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logTypes = [
|
||||||
|
{ value: '', label: '全部', icon: null },
|
||||||
|
{ value: 'agent', label: '智能体', icon: Bot },
|
||||||
|
{ value: 'system', label: '系统', icon: Settings },
|
||||||
|
{ value: 'chat', label: '问答', icon: MessageSquare },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logLevels = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'debug', label: '调试' },
|
||||||
|
{ value: 'info', label: '信息' },
|
||||||
|
{ value: 'warning', label: '警告' },
|
||||||
|
{ value: 'error', label: '错误' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusCodeOptions = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: '200', label: '200' },
|
||||||
|
{ value: '400', label: '400' },
|
||||||
|
{ value: '401', label: '401' },
|
||||||
|
{ value: '404', label: '404' },
|
||||||
|
{ value: '422', label: '422' },
|
||||||
|
{ value: '500', label: '500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
debug: 'var(--text-dim)',
|
||||||
|
info: 'var(--accent-cyan)',
|
||||||
|
warning: 'var(--accent-amber)',
|
||||||
|
error: 'var(--accent-red)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
agent: '智能体',
|
||||||
|
system: '系统',
|
||||||
|
chat: '问答',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(date: Date): string {
|
||||||
|
const offsetMs = date.getTimezoneOffset() * 60 * 1000
|
||||||
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPresetRange(preset: Exclude<TimePreset, 'custom'>) {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end)
|
||||||
|
const hours = preset === '1h' ? 1 : preset === '6h' ? 6 : preset === '24h' ? 24 : 24 * 7
|
||||||
|
start.setHours(start.getHours() - hours)
|
||||||
|
return {
|
||||||
|
start_at: toDatetimeLocalValue(start),
|
||||||
|
end_at: toDatetimeLocalValue(end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultFilters(): LogFilters {
|
||||||
|
return {
|
||||||
|
preset: '24h',
|
||||||
|
...getPresetRange('24h'),
|
||||||
|
request_id: '',
|
||||||
|
route: '',
|
||||||
|
operation: '',
|
||||||
|
status_code: '',
|
||||||
|
log_type: '',
|
||||||
|
level: '',
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = ref<LogFilters>(createDefaultFilters())
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / filters.value.page_size)))
|
||||||
|
const visiblePageNumbers = computed(() => {
|
||||||
|
const totalPageCount = totalPages.value
|
||||||
|
const currentPage = filters.value.page
|
||||||
|
if (totalPageCount <= 5) {
|
||||||
|
return Array.from({ length: totalPageCount }, (_, index) => index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let startPage = Math.max(1, currentPage - 2)
|
||||||
|
let endPage = Math.min(totalPageCount, startPage + 4)
|
||||||
|
|
||||||
|
if (endPage - startPage < 4) {
|
||||||
|
startPage = Math.max(1, endPage - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
|
||||||
|
})
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
function formatTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
if (Number.isNaN(d.getTime())) return dateStr
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (ms == null) return ''
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails(details: Record<string, unknown> | null): string {
|
||||||
|
if (!details) return ''
|
||||||
|
return JSON.stringify(details, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDetails(logId: string) {
|
||||||
|
expandedLogId.value = expandedLogId.value === logId ? null : logId
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilters(): LogQueryParams {
|
||||||
|
return {
|
||||||
|
log_type: filters.value.log_type || undefined,
|
||||||
|
level: filters.value.level || undefined,
|
||||||
|
request_id: filters.value.request_id.trim() || undefined,
|
||||||
|
route: filters.value.route.trim() || undefined,
|
||||||
|
operation: filters.value.operation.trim() || undefined,
|
||||||
|
status_code: filters.value.status_code ? Number(filters.value.status_code) : undefined,
|
||||||
|
start_at: filters.value.start_at ? new Date(filters.value.start_at).toISOString() : undefined,
|
||||||
|
end_at: filters.value.end_at ? new Date(filters.value.end_at).toISOString() : undefined,
|
||||||
|
page: filters.value.page,
|
||||||
|
page_size: filters.value.page_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDateRange() {
|
||||||
|
if (filters.value.start_at && filters.value.end_at && filters.value.start_at > filters.value.end_at) {
|
||||||
|
errorMessage.value = '开始时间不能晚于结束时间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const requestedPage = filters.value.page
|
||||||
|
const res = await logApi.list(normalizeFilters())
|
||||||
|
logs.value = res.data.logs
|
||||||
|
total.value = res.data.total
|
||||||
|
const maxPage = Math.max(1, Math.ceil(res.data.total / filters.value.page_size))
|
||||||
|
if (requestedPage > maxPage) {
|
||||||
|
filters.value.page = maxPage
|
||||||
|
if (maxPage !== requestedPage) {
|
||||||
|
await fetchLogs()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '加载日志失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res = await logApi.getStats(normalizeFilters())
|
||||||
|
stats.value = res.data
|
||||||
|
} catch {
|
||||||
|
if (!errorMessage.value) {
|
||||||
|
errorMessage.value = '加载日志统计失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!validateDateRange()) return
|
||||||
|
await Promise.all([fetchLogs(), fetchStats()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
filters.value.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.value = createDefaultFilters()
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset: TimePreset) {
|
||||||
|
filters.value.preset = preset
|
||||||
|
if (preset !== 'custom') {
|
||||||
|
const range = getPresetRange(preset)
|
||||||
|
filters.value.start_at = range.start_at
|
||||||
|
filters.value.end_at = range.end_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCustomRange() {
|
||||||
|
filters.value.preset = 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh.value = !autoRefresh.value
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
refreshInterval = window.setInterval(() => {
|
||||||
|
loadData()
|
||||||
|
}, 5000)
|
||||||
|
} else if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
refreshInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page < 1 || page > totalPages.value || page === filters.value.page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filters.value.page = page
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
goToPage(filters.value.page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
goToPage(filters.value.page + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(size: number) {
|
||||||
|
filters.value.page_size = size
|
||||||
|
filters.value.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="log-view">
|
||||||
|
<div class="view-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<Terminal :size="16" />
|
||||||
|
<span>运行日志</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-icon" :class="{ active: autoRefresh }" @click="toggleAutoRefresh" title="自动刷新">
|
||||||
|
<RefreshCw :size="14" :class="{ spinning: autoRefresh }" />
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" @click="loadData" title="刷新">
|
||||||
|
<RefreshCw :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats" class="stats-overview">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">总计</div>
|
||||||
|
<div class="stat-value">{{ stats.total }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">智能体</div>
|
||||||
|
<div class="stat-value cyan">{{ stats.by_type.agent }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">系统</div>
|
||||||
|
<div class="stat-value purple">{{ stats.by_type.system }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">问答</div>
|
||||||
|
<div class="stat-value amber">{{ stats.by_type.chat }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">错误</div>
|
||||||
|
<div class="stat-value red">{{ stats.by_level.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="toolbar-row presets-row">
|
||||||
|
<button
|
||||||
|
v-for="preset in timePresets"
|
||||||
|
:key="preset.value"
|
||||||
|
class="filter-btn"
|
||||||
|
:class="{ active: filters.preset === preset.value }"
|
||||||
|
@click="applyPreset(preset.value)"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row fields-row">
|
||||||
|
<input v-model="filters.start_at" class="filter-input" type="datetime-local" @input="markCustomRange" />
|
||||||
|
<input v-model="filters.end_at" class="filter-input" type="datetime-local" @input="markCustomRange" />
|
||||||
|
<input v-model="filters.request_id" class="filter-input" type="text" placeholder="Request ID" />
|
||||||
|
<input v-model="filters.route" class="filter-input" type="text" placeholder="Route" />
|
||||||
|
<input v-model="filters.operation" class="filter-input" type="text" placeholder="Operation" />
|
||||||
|
<select v-model="filters.status_code" class="filter-select">
|
||||||
|
<option v-for="option in statusCodeOptions" :key="option.label" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.log_type" class="filter-select">
|
||||||
|
<option v-for="option in logTypes" :key="option.label" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.level" class="filter-select">
|
||||||
|
<option v-for="option in logLevels" :key="option.label" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="action-btn primary" @click="applyFilters">查询</button>
|
||||||
|
<button class="action-btn" @click="resetFilters">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-list">
|
||||||
|
<div v-if="loading && logs.length === 0" class="loading-state">
|
||||||
|
<RefreshCw :size="20" class="spinning" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="empty-state error-state">
|
||||||
|
<AlertCircle :size="32" />
|
||||||
|
<span>{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="logs.length === 0" class="empty-state">
|
||||||
|
<Terminal :size="32" />
|
||||||
|
<span>暂无日志</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="log-items">
|
||||||
|
<div v-for="log in logs" :key="log.id" class="log-item">
|
||||||
|
<div class="log-summary" @click="toggleDetails(log.id)">
|
||||||
|
<div class="log-meta">
|
||||||
|
<span class="log-type" :class="log.type">{{ typeLabels[log.type] || log.type }}</span>
|
||||||
|
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
|
||||||
|
<span v-if="log.duration_ms !== null" class="log-duration">{{ formatDuration(log.duration_ms) }}</span>
|
||||||
|
<span v-if="log.status_code" class="log-status">HTTP {{ log.status_code }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-message">{{ log.message }}</div>
|
||||||
|
<div class="log-footer">
|
||||||
|
<span v-if="log.source" class="log-source">{{ log.source }}</span>
|
||||||
|
<span v-if="log.operation" class="log-operation">{{ log.operation }}</span>
|
||||||
|
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
||||||
|
<span class="log-expand">
|
||||||
|
<ChevronDown v-if="expandedLogId === log.id" :size="14" />
|
||||||
|
<ChevronRight v-else :size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedLogId === log.id" class="log-details">
|
||||||
|
<div v-if="log.request_id" class="detail-row"><strong>Request ID:</strong> {{ log.request_id }}</div>
|
||||||
|
<div v-if="log.route || log.method" class="detail-row"><strong>Route:</strong> {{ log.method || '-' }} {{ log.route || '-' }}</div>
|
||||||
|
<div v-if="log.error_type" class="detail-row"><strong>Error:</strong> {{ log.error_type }}</div>
|
||||||
|
<pre v-if="log.details" class="detail-json">{{ formatDetails(log.details) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="page-summary">共 {{ total }} 条 · 第 {{ filters.page }} / {{ totalPages }} 页</div>
|
||||||
|
<div class="page-controls">
|
||||||
|
<button class="page-btn" :disabled="filters.page === 1" @click="prevPage">上一页</button>
|
||||||
|
<button
|
||||||
|
v-for="page in visiblePageNumbers"
|
||||||
|
:key="page"
|
||||||
|
class="page-btn page-number"
|
||||||
|
:class="{ active: page === filters.page }"
|
||||||
|
@click="goToPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button class="page-btn" :disabled="filters.page >= totalPages" @click="nextPage">下一页</button>
|
||||||
|
<select class="filter-select page-size-select" :value="filters.page_size" @change="changePageSize(Number(($event.target as HTMLSelectElement).value))">
|
||||||
|
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover, .btn-icon.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon .spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.cyan { color: var(--accent-cyan); }
|
||||||
|
.stat-value.purple { color: var(--accent-purple); }
|
||||||
|
.stat-value.amber { color: var(--accent-amber); }
|
||||||
|
.stat-value.red { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn,
|
||||||
|
.filter-input,
|
||||||
|
.filter-select,
|
||||||
|
.action-btn,
|
||||||
|
.page-btn {
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn,
|
||||||
|
.action-btn,
|
||||||
|
.page-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active,
|
||||||
|
.filter-btn:hover,
|
||||||
|
.action-btn:hover,
|
||||||
|
.page-btn:hover:not(:disabled),
|
||||||
|
.page-btn.active,
|
||||||
|
.filter-input:focus,
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active,
|
||||||
|
.action-btn.primary,
|
||||||
|
.page-btn.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input,
|
||||||
|
.filter-select {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-items {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary {
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item:hover {
|
||||||
|
background: rgba(0, 245, 212, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type.agent {
|
||||||
|
background: rgba(0, 245, 212, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type.system {
|
||||||
|
background: rgba(168, 85, 247, 0.1);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type.chat {
|
||||||
|
background: rgba(249, 168, 37, 0.1);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-duration {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-status,
|
||||||
|
.log-operation,
|
||||||
|
.log-expand {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-details {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-json {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-void);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-number {
|
||||||
|
min-width: 36px;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-select {
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { logApi, type Log, type LogStats } from '@/api/log'
|
|
||||||
import { Terminal, RefreshCw, Filter, Clock, Bot, MessageSquare, Settings, AlertCircle } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const logs = ref<Log[]>([])
|
|
||||||
const stats = ref<LogStats | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(50)
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
const selectedType = ref<string | null>(null)
|
|
||||||
const selectedLevel = ref<string | null>(null)
|
|
||||||
|
|
||||||
// Auto-refresh
|
|
||||||
const autoRefresh = ref(false)
|
|
||||||
let refreshInterval: number | null = null
|
|
||||||
|
|
||||||
const logTypes = [
|
|
||||||
{ value: null, label: '全部' },
|
|
||||||
{ value: 'agent', label: '智能体', icon: Bot },
|
|
||||||
{ value: 'system', label: '系统', icon: Settings },
|
|
||||||
{ value: 'chat', label: '问答', icon: MessageSquare },
|
|
||||||
]
|
|
||||||
|
|
||||||
const logLevels = [
|
|
||||||
{ value: null, label: '全部' },
|
|
||||||
{ value: 'debug', label: '调试' },
|
|
||||||
{ value: 'info', label: '信息' },
|
|
||||||
{ value: 'warning', label: '警告' },
|
|
||||||
{ value: 'error', label: '错误' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const levelColors: Record<string, string> = {
|
|
||||||
debug: 'var(--text-dim)',
|
|
||||||
info: 'var(--accent-cyan)',
|
|
||||||
warning: 'var(--accent-amber)',
|
|
||||||
error: 'var(--accent-red)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
|
||||||
agent: '智能体',
|
|
||||||
system: '系统',
|
|
||||||
chat: '问答',
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(ms: string | null): string {
|
|
||||||
if (!ms) return ''
|
|
||||||
const msNum = parseInt(ms)
|
|
||||||
if (msNum < 1000) return `${msNum}ms`
|
|
||||||
return `${(msNum / 1000).toFixed(1)}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLogs() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await logApi.list({
|
|
||||||
log_type: selectedType.value || undefined,
|
|
||||||
level: selectedLevel.value || undefined,
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value,
|
|
||||||
})
|
|
||||||
logs.value = res.data.logs
|
|
||||||
total.value = res.data.total
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch logs:', e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStats() {
|
|
||||||
try {
|
|
||||||
const res = await logApi.getStats(24)
|
|
||||||
stats.value = res.data
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch stats:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
await Promise.all([fetchLogs(), fetchStats()])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
|
||||||
autoRefresh.value = !autoRefresh.value
|
|
||||||
if (autoRefresh.value) {
|
|
||||||
refreshInterval = window.setInterval(() => {
|
|
||||||
fetchLogs()
|
|
||||||
}, 5000)
|
|
||||||
} else if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval)
|
|
||||||
refreshInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByType(type: string | null) {
|
|
||||||
selectedType.value = type
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByLevel(level: string | null) {
|
|
||||||
selectedLevel.value = level
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevPage() {
|
|
||||||
if (currentPage.value > 1) {
|
|
||||||
currentPage.value--
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if (currentPage.value * pageSize.value < total.value) {
|
|
||||||
currentPage.value++
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadData)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="log-view">
|
|
||||||
<div class="view-header">
|
|
||||||
<div class="header-title">
|
|
||||||
<Terminal :size="16" />
|
|
||||||
<span>运行日志</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="btn-icon" :class="{ active: autoRefresh }" @click="toggleAutoRefresh" title="自动刷新">
|
|
||||||
<RefreshCw :size="14" :class="{ spinning: autoRefresh }" />
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon" @click="loadData" title="刷新">
|
|
||||||
<RefreshCw :size="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Overview -->
|
|
||||||
<div v-if="stats" class="stats-overview">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">总计</div>
|
|
||||||
<div class="stat-value">{{ stats.total }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">智能体</div>
|
|
||||||
<div class="stat-value cyan">{{ stats.by_type.agent }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">系统</div>
|
|
||||||
<div class="stat-value purple">{{ stats.by_type.system }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">问答</div>
|
|
||||||
<div class="stat-value amber">{{ stats.by_type.chat }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card" v-if="stats.by_level.error > 0">
|
|
||||||
<div class="stat-label">错误</div>
|
|
||||||
<div class="stat-value red">{{ stats.by_level.error }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="filters">
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">类型:</span>
|
|
||||||
<button
|
|
||||||
v-for="t in logTypes"
|
|
||||||
:key="t.value ?? 'all'"
|
|
||||||
class="filter-btn"
|
|
||||||
:class="{ active: selectedType === t.value }"
|
|
||||||
@click="filterByType(t.value)"
|
|
||||||
>
|
|
||||||
<component :is="t.icon" :size="12" v-if="t.icon" />
|
|
||||||
{{ t.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">级别:</span>
|
|
||||||
<button
|
|
||||||
v-for="l in logLevels"
|
|
||||||
:key="l.value ?? 'all'"
|
|
||||||
class="filter-btn"
|
|
||||||
:class="{ active: selectedLevel === l.value }"
|
|
||||||
@click="filterByLevel(l.value)"
|
|
||||||
>
|
|
||||||
{{ l.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Log List -->
|
|
||||||
<div class="log-list">
|
|
||||||
<div v-if="loading && logs.length === 0" class="loading-state">
|
|
||||||
<RefreshCw :size="20" class="spinning" />
|
|
||||||
<span>加载中...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="logs.length === 0" class="empty-state">
|
|
||||||
<Terminal :size="32" />
|
|
||||||
<span>暂无日志</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="log-items">
|
|
||||||
<div v-for="log in logs" :key="log.id" class="log-item">
|
|
||||||
<div class="log-meta">
|
|
||||||
<span class="log-type" :class="log.type">{{ typeLabels[log.type] }}</span>
|
|
||||||
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
|
|
||||||
<span v-if="log.duration_ms" class="log-duration">{{ formatDuration(log.duration_ms) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="log-message">{{ log.message }}</div>
|
|
||||||
<div class="log-footer">
|
|
||||||
<span v-if="log.source" class="log-source">{{ log.source }}</span>
|
|
||||||
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="pagination" v-if="total > pageSize">
|
|
||||||
<button class="page-btn" :disabled="currentPage === 1" @click="prevPage">上一页</button>
|
|
||||||
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
|
|
||||||
<button class="page-btn" :disabled="currentPage * pageSize >= total" @click="nextPage">下一页</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.log-view {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--bg-void);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover, .btn-icon.active {
|
|
||||||
background: var(--accent-cyan-dim);
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon .spinning {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Overview */
|
|
||||||
.stats-overview {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 12px 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value.cyan { color: var(--accent-cyan); }
|
|
||||||
.stat-value.purple { color: var(--accent-purple); }
|
|
||||||
.stat-value.amber { color: var(--accent-amber); }
|
|
||||||
.stat-value.red { color: var(--accent-red); }
|
|
||||||
|
|
||||||
/* Filters */
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn.active {
|
|
||||||
background: var(--accent-cyan-dim);
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log List */
|
|
||||||
.log-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state,
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 60px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-items {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
transition: background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item:hover {
|
|
||||||
background: rgba(0, 245, 212, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type.agent {
|
|
||||||
background: rgba(0, 245, 212, 0.1);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type.system {
|
|
||||||
background: rgba(168, 85, 247, 0.1);
|
|
||||||
color: var(--accent-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type.chat {
|
|
||||||
background: rgba(249, 168, 37, 0.1);
|
|
||||||
color: var(--accent-amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-level {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-duration {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-message {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-info {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user