- 修改记录存储格式为 [4B len][8B offset][4B CRC][16B UUID][data] - 修复 TopicProcessor 中 WaitGroup 使用错误导致 handler 不执行的问题 - 修复写入保护逻辑,避免 dirtyOffset=-1 时误判为写入中 - 添加统计信息定期持久化功能 - 改进 UTF-8 字符截断处理,防止 CJK 字符乱码 - 优化 Web UI:显示人类可读的文件大小,支持点击外部关闭弹窗 - 重构示例代码,添加 webui 和 webui_integration 示例 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
569 lines
26 KiB
HTML
569 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Seqlog Dashboard</title>
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<style>
|
||
[v-cloak] { display: none; }
|
||
.log-entry {
|
||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||
font-size: 13px;
|
||
}
|
||
.log-data {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
}
|
||
.log-data:hover {
|
||
background-color: rgba(0, 0, 0, 0.02);
|
||
}
|
||
|
||
/* Dialog 样式 */
|
||
dialog {
|
||
border: none;
|
||
border-radius: 0.5rem;
|
||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||
padding: 0;
|
||
max-width: 56rem;
|
||
width: 90vw;
|
||
max-height: 80vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
dialog .dialog-container {
|
||
max-height: 80vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
dialog .dialog-content {
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
dialog:not([open]) {
|
||
display: none !important;
|
||
}
|
||
|
||
dialog[open] {
|
||
display: block !important;
|
||
}
|
||
|
||
dialog::backdrop {
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-50">
|
||
<div id="app" v-cloak>
|
||
<!-- 头部 -->
|
||
<header class="bg-white shadow-sm border-b">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<h1 class="text-2xl font-bold text-gray-900">Seqlog Dashboard</h1>
|
||
</div>
|
||
<div class="flex items-center space-x-4">
|
||
<span class="text-sm text-gray-500">实时日志查询系统</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||
<div class="grid grid-cols-12 gap-6">
|
||
<!-- 侧边栏 -->
|
||
<div class="col-span-12 lg:col-span-3">
|
||
<div class="bg-white rounded-lg shadow p-4">
|
||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Topics</h2>
|
||
|
||
<div v-if="topics.length === 0" class="text-gray-500 text-sm text-center py-4">
|
||
暂无 Topic
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<button
|
||
v-for="topicInfo in topics"
|
||
:key="topicInfo.topic"
|
||
@click="selectTopic(topicInfo.topic)"
|
||
:class="[
|
||
'w-full text-left px-4 py-2 rounded-md transition-colors',
|
||
selectedTopic === topicInfo.topic
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-gray-50 text-gray-700 hover:bg-gray-100'
|
||
]">
|
||
{{ topicInfo.title }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计信息 -->
|
||
<div v-if="stats" class="bg-white rounded-lg shadow p-4 mt-4">
|
||
<h3 class="text-sm font-semibold text-gray-900 mb-3">统计信息</h3>
|
||
<div class="space-y-2 text-sm">
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600">写入次数:</span>
|
||
<span class="font-medium">{{ stats.write_count }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600">写入大小:</span>
|
||
<span class="font-medium">{{ formatBytes(stats.write_bytes) }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600">已处理:</span>
|
||
<span class="font-medium">{{ stats.processed_count }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600">处理大小:</span>
|
||
<span class="font-medium">{{ formatBytes(stats.processed_bytes) }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-600">错误次数:</span>
|
||
<span class="font-medium text-red-600">{{ stats.error_count }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区 -->
|
||
<div class="col-span-12 lg:col-span-9">
|
||
<div class="bg-white rounded-lg shadow">
|
||
<!-- 控制栏 -->
|
||
<div class="border-b px-4 py-3 flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<button
|
||
@click="loadLogs"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium">
|
||
刷新
|
||
</button>
|
||
<button
|
||
@click="queryFirst"
|
||
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
|
||
最早
|
||
</button>
|
||
<button
|
||
@click="queryLast"
|
||
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
|
||
最新
|
||
</button>
|
||
|
||
<div class="flex items-center space-x-2">
|
||
<label class="text-sm text-gray-600">显示:</label>
|
||
<input
|
||
v-model.number="queryCount"
|
||
type="number"
|
||
min="1"
|
||
max="100"
|
||
class="w-20 px-2 py-1 border rounded text-sm">
|
||
<span class="text-sm text-gray-600">条</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedTopic" class="text-sm text-gray-500">
|
||
Topic: <span class="font-medium text-gray-900">{{ selectedTopic }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 日志列表 -->
|
||
<div class="p-4">
|
||
<div v-if="!selectedTopic" class="text-center py-12 text-gray-500">
|
||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<p class="mt-2">请选择一个 Topic 开始查看日志</p>
|
||
</div>
|
||
|
||
<div v-else-if="loading" class="text-center py-12">
|
||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
<p class="mt-2 text-gray-500">加载中...</p>
|
||
</div>
|
||
|
||
<div v-else-if="logs.length === 0" class="text-center py-12 text-gray-500">
|
||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||
</svg>
|
||
<p class="mt-2">暂无日志记录</p>
|
||
</div>
|
||
|
||
<div v-else class="space-y-2 max-h-[600px] overflow-y-auto" ref="logContainer" @scroll="handleScroll">
|
||
<div
|
||
v-for="log in logs"
|
||
:key="log.index"
|
||
:class="[
|
||
'log-entry p-3 rounded-md border-l-4 transition-colors',
|
||
getLogClass(log.status)
|
||
]">
|
||
<div class="flex items-start justify-between mb-1">
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-xs font-mono text-gray-500">#{{ log.index }}</span>
|
||
<span :class="['px-2 py-0.5 rounded-full text-xs font-medium', getStatusClass(log.status)]">
|
||
{{ getStatusText(log.status) }}
|
||
</span>
|
||
<span class="text-xs text-gray-500">{{ formatBytes(log.dataSize || (log.data ? log.data.length : 0)) }}</span>
|
||
</div>
|
||
<span class="text-xs text-gray-400 font-mono">{{ formatUUID(log.uuid) }}</span>
|
||
</div>
|
||
<div class="log-data text-sm text-gray-800 break-all whitespace-pre-wrap" @click="showLogDetail(log)">
|
||
{{ log.dataPreview || log.data }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 日志详情对话框 -->
|
||
<dialog ref="logDialog">
|
||
<div class="dialog-container">
|
||
<template v-if="selectedLog">
|
||
<!-- 对话框头部 -->
|
||
<div class="flex items-center justify-between p-4 border-b flex-shrink-0">
|
||
<div class="flex items-center space-x-3">
|
||
<h3 class="text-lg font-semibold text-gray-900">日志详情</h3>
|
||
<span class="text-sm text-gray-500">#{{ selectedLog.index }}</span>
|
||
</div>
|
||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 对话框内容 -->
|
||
<div class="dialog-content p-4">
|
||
<div class="space-y-3">
|
||
<div class="flex items-center space-x-4 text-sm">
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-gray-600">UUID:</span>
|
||
<span class="font-mono text-gray-900">{{ selectedLog.uuid }}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-gray-600">状态:</span>
|
||
<span :class="['px-2 py-0.5 rounded-full text-xs font-medium', getStatusClass(selectedLog.status)]">
|
||
{{ getStatusText(selectedLog.status) }}
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-gray-600">大小:</span>
|
||
<span class="font-medium text-gray-900">{{ formatBytes(selectedLog.dataSize || (selectedLog.data ? selectedLog.data.length : 0)) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-2">日志内容</label>
|
||
<pre class="bg-gray-50 p-4 rounded-md text-sm font-mono text-gray-800 whitespace-pre-wrap break-all border border-gray-200">{{ selectedLog.data }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 对话框底部 -->
|
||
<div class="p-4 border-t flex justify-end flex-shrink-0">
|
||
<button @click="closeModal" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors">
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</dialog>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp } = Vue;
|
||
|
||
createApp({
|
||
data() {
|
||
return {
|
||
topics: [],
|
||
selectedTopic: null,
|
||
logs: [],
|
||
stats: null,
|
||
loading: false,
|
||
queryCount: 20,
|
||
selectedLog: null,
|
||
loadingOlder: false, // 正在加载更早的记录
|
||
loadingNewer: false, // 正在加载更新的记录
|
||
scrollThreshold: 50, // 触发加载的阈值(像素)
|
||
loadingDetail: false // 正在加载详情
|
||
}
|
||
},
|
||
methods: {
|
||
async loadTopics() {
|
||
try {
|
||
const response = await fetch('/api/topics');
|
||
this.topics = await response.json();
|
||
} catch (error) {
|
||
console.error('加载 topics 失败:', error);
|
||
}
|
||
},
|
||
|
||
async selectTopic(topic) {
|
||
this.selectedTopic = topic;
|
||
await this.loadStats();
|
||
await this.loadLogs();
|
||
},
|
||
|
||
async loadStats() {
|
||
if (!this.selectedTopic) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/stats?topic=${this.selectedTopic}`);
|
||
this.stats = await response.json();
|
||
} catch (error) {
|
||
console.error('加载统计信息失败:', error);
|
||
}
|
||
},
|
||
|
||
async loadLogs() {
|
||
if (!this.selectedTopic) return;
|
||
|
||
this.loading = true;
|
||
try {
|
||
const response = await fetch(
|
||
`/api/logs?topic=${this.selectedTopic}&count=${this.queryCount}`
|
||
);
|
||
const data = await response.json();
|
||
this.logs = data.records || [];
|
||
if (data.stats) {
|
||
this.stats = data.stats;
|
||
}
|
||
} catch (error) {
|
||
console.error('加载日志失败:', error);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async queryFirst() {
|
||
if (!this.selectedTopic) return;
|
||
|
||
this.loading = true;
|
||
try {
|
||
const response = await fetch(
|
||
`/api/logs/first?topic=${this.selectedTopic}&count=${this.queryCount}`
|
||
);
|
||
const data = await response.json();
|
||
this.logs = data.records || [];
|
||
} catch (error) {
|
||
console.error('查询失败:', error);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async queryLast() {
|
||
if (!this.selectedTopic) return;
|
||
|
||
this.loading = true;
|
||
try {
|
||
const response = await fetch(
|
||
`/api/logs/last?topic=${this.selectedTopic}&count=${this.queryCount}`
|
||
);
|
||
const data = await response.json();
|
||
this.logs = data.records || [];
|
||
} catch (error) {
|
||
console.error('查询失败:', error);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
getLogClass(status) {
|
||
const classes = {
|
||
'bg-gray-50 border-gray-300': status === 'pending',
|
||
'bg-yellow-50 border-yellow-400': status === 'processing',
|
||
'bg-green-50 border-green-400': status === 'processed'
|
||
};
|
||
return classes[status] || 'bg-gray-50 border-gray-300';
|
||
},
|
||
|
||
getStatusClass(status) {
|
||
const classes = {
|
||
'bg-gray-200 text-gray-700': status === 'pending',
|
||
'bg-yellow-200 text-yellow-800': status === 'processing',
|
||
'bg-green-200 text-green-800': status === 'processed'
|
||
};
|
||
return classes[status] || 'bg-gray-200 text-gray-700';
|
||
},
|
||
|
||
getStatusText(status) {
|
||
const texts = {
|
||
'pending': '待处理',
|
||
'processing': '处理中',
|
||
'processed': '已处理'
|
||
};
|
||
return texts[status] || '未知';
|
||
},
|
||
|
||
formatUUID(uuid) {
|
||
// 只显示前 8 位
|
||
return uuid ? uuid.substring(0, 8) : '';
|
||
},
|
||
|
||
formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
},
|
||
|
||
async showLogDetail(log) {
|
||
// 如果已经有完整数据,直接显示
|
||
if (log.data && !log.dataPreview) {
|
||
this.selectedLog = log;
|
||
this.$refs.logDialog.showModal();
|
||
return;
|
||
}
|
||
|
||
// 否则先显示弹窗,然后加载完整数据
|
||
this.selectedLog = { ...log, data: '加载中...' };
|
||
this.$refs.logDialog.showModal();
|
||
this.loadingDetail = true;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/logs/record?topic=${this.selectedTopic}&index=${log.index}`
|
||
);
|
||
const fullRecord = await response.json();
|
||
// 更新 selectedLog 为完整数据
|
||
this.selectedLog = fullRecord;
|
||
} catch (error) {
|
||
console.error('加载日志详情失败:', error);
|
||
this.selectedLog.data = '加载失败';
|
||
} finally {
|
||
this.loadingDetail = false;
|
||
}
|
||
},
|
||
|
||
closeModal() {
|
||
this.$refs.logDialog.close();
|
||
this.selectedLog = null;
|
||
},
|
||
|
||
// 处理滚动事件
|
||
handleScroll(event) {
|
||
const container = event.target;
|
||
const scrollTop = container.scrollTop;
|
||
const scrollHeight = container.scrollHeight;
|
||
const clientHeight = container.clientHeight;
|
||
|
||
// 触顶:加载更早的记录
|
||
if (scrollTop < this.scrollThreshold && !this.loadingOlder && this.logs.length > 0) {
|
||
this.loadOlder();
|
||
}
|
||
|
||
// 触底:加载更新的记录
|
||
if (scrollHeight - scrollTop - clientHeight < this.scrollThreshold && !this.loadingNewer && this.logs.length > 0) {
|
||
this.loadNewer();
|
||
}
|
||
},
|
||
|
||
// 加载更早的记录(向前)
|
||
async loadOlder() {
|
||
if (!this.selectedTopic || this.logs.length === 0 || this.loadingOlder) return;
|
||
|
||
// 获取当前最小索引
|
||
const minIndex = Math.min(...this.logs.map(log => log.index));
|
||
if (minIndex === 0) return; // 已经到达最早
|
||
|
||
this.loadingOlder = true;
|
||
const container = this.$refs.logContainer;
|
||
const oldScrollHeight = container.scrollHeight;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/logs/older?topic=${this.selectedTopic}&refIndex=${minIndex}&count=${this.queryCount}`
|
||
);
|
||
const data = await response.json();
|
||
const newRecords = data.records || [];
|
||
|
||
if (newRecords.length > 0) {
|
||
// 将新记录添加到数组开头
|
||
this.logs = [...newRecords, ...this.logs];
|
||
|
||
// 保持滚动位置
|
||
this.$nextTick(() => {
|
||
const newScrollHeight = container.scrollHeight;
|
||
container.scrollTop = newScrollHeight - oldScrollHeight;
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('加载更早记录失败:', error);
|
||
} finally {
|
||
this.loadingOlder = false;
|
||
}
|
||
},
|
||
|
||
// 加载更新的记录(向后)
|
||
async loadNewer() {
|
||
if (!this.selectedTopic || this.logs.length === 0 || this.loadingNewer) return;
|
||
|
||
// 获取当前最大索引
|
||
const maxIndex = Math.max(...this.logs.map(log => log.index));
|
||
|
||
this.loadingNewer = true;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/logs/newer?topic=${this.selectedTopic}&refIndex=${maxIndex}&count=${this.queryCount}`
|
||
);
|
||
const data = await response.json();
|
||
const newRecords = data.records || [];
|
||
|
||
if (newRecords.length > 0) {
|
||
// 将新记录添加到数组末尾
|
||
this.logs = [...this.logs, ...newRecords];
|
||
}
|
||
} catch (error) {
|
||
console.error('加载更新记录失败:', error);
|
||
} finally {
|
||
this.loadingNewer = false;
|
||
}
|
||
}
|
||
},
|
||
mounted() {
|
||
this.loadTopics();
|
||
|
||
// 每 3 秒自动刷新统计信息
|
||
setInterval(() => {
|
||
if (this.selectedTopic) {
|
||
this.loadStats();
|
||
}
|
||
}, 3000);
|
||
|
||
// 监听 dialog 关闭事件(ESC 键)
|
||
this.$refs.logDialog.addEventListener('close', () => {
|
||
this.selectedLog = null;
|
||
});
|
||
|
||
// 点击弹窗外部关闭弹窗
|
||
this.$refs.logDialog.addEventListener('click', (event) => {
|
||
const rect = this.$refs.logDialog.getBoundingClientRect();
|
||
const isInDialog = (
|
||
rect.top <= event.clientY &&
|
||
event.clientY <= rect.top + rect.height &&
|
||
rect.left <= event.clientX &&
|
||
event.clientX <= rect.left + rect.width
|
||
);
|
||
if (!isInDialog) {
|
||
this.closeModal();
|
||
}
|
||
});
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|