Files
seqlog/ui/index.html

569 lines
26 KiB
HTML
Raw Normal View History

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