Files
seqlog/ui/index.html
bourdon 810664eb12 重构:优化记录格式并修复核心功能
- 修改记录存储格式为 [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>
2025-10-04 17:54:49 +08:00

569 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>