Initial commit: LogHive centralized log management system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
v6ole
2026-05-09 14:55:14 +08:00
commit abfd07331e
54 changed files with 3816 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# ── Build Stage ──
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# ── Production Stage (nginx) ──
FROM nginx:stable-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LogHive — 日志管理系统</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Vue SPA — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "loghive-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.7.0",
"echarts": "^5.5.0",
"vue-echarts": "^6.7.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.2.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" fill="#f0c040"/>
<text x="32" y="42" text-anchor="middle" font-size="36" font-family="Arial" fill="#333">H</text>
</svg>

After

Width:  |  Height:  |  Size: 217 B

+69
View File
@@ -0,0 +1,69 @@
<template>
<el-container style="height: 100vh">
<el-aside width="220px" style="background: #1d1e1f; color: #fff">
<div class="sidebar-header">
<h2 style="margin: 0; padding: 20px; font-size: 20px">🐝 LogHive</h2>
</div>
<el-menu
:default-active="route.path"
router
background-color="#1d1e1f"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item index="/">
<el-icon><DataAnalysis /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Search /></el-icon>
<span>日志检索</span>
</el-menu-item>
<el-menu-item index="/projects">
<el-icon><Setting /></el-icon>
<span>项目管理</span>
</el-menu-item>
<el-menu-item index="/alerts">
<el-icon><WarningFilled /></el-icon>
<span>告警中心</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="background: #fff; border-bottom: 1px solid #e4e7ed; display: flex; align-items: center; justify-content: space-between; padding: 0 20px">
<el-breadcrumb>
<el-breadcrumb-item :to="{ path: '/' }">LogHive</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.path === '/logs'">日志检索</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.path === '/projects'">项目管理</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.path === '/alerts'">告警中心</el-breadcrumb-item>
</el-breadcrumb>
<div style="font-size: 14px; color: #909399">
LogHive v0.1.0
</div>
</el-header>
<el-main style="background: #f5f7fa">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.el-menu {
border-right: none !important;
}
.sidebar-header {
border-bottom: 1px solid #333;
}
</style>
+92
View File
@@ -0,0 +1,92 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 10000,
})
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
const msg = error.response?.data?.detail || error.message
console.error('API Error:', msg)
return Promise.reject(error)
}
)
// ── Dashboard ─────────────────────────────────────────────────
export function fetchDashboardStats(params) {
return api.get('/dashboard/stats', { params })
}
export function fetchTimeSeries(params) {
return api.get('/dashboard/timeseries', { params })
}
// ── Logs ──────────────────────────────────────────────────────
export function searchLogs(params) {
return api.get('/logs/search', { params })
}
export function fetchLogStats(params) {
return api.get('/logs/stats', { params })
}
// ── Projects ──────────────────────────────────────────────────
export function listProjects() {
return api.get('/projects')
}
export function getProject(id) {
return api.get(`/projects/${id}`)
}
export function createProject(data) {
return api.post('/projects', data)
}
export function updateProject(id, data) {
return api.patch(`/projects/${id}`, data)
}
export function deleteProject(id) {
return api.delete(`/projects/${id}`)
}
export function rotateApiKey(id) {
return api.post(`/projects/${id}/rotate-key`)
}
// ── Alerts ────────────────────────────────────────────────────
export function listAlertRules(params) {
return api.get('/alerts/rules', { params })
}
export function createAlertRule(data) {
return api.post('/alerts/rules', data)
}
export function updateAlertRule(id, data) {
return api.patch(`/alerts/rules/${id}`, data)
}
export function deleteAlertRule(id) {
return api.delete(`/alerts/rules/${id}`)
}
export function listAlertHistory(params) {
return api.get('/alerts/history', { params })
}
export function acknowledgeAlert(id) {
return api.post(`/alerts/history/${id}/acknowledge`)
}
export function healthCheck() {
return api.get('/health')
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// Register all Element Plus icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+19
View File
@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '../views/DashboardView.vue'
import LogSearchView from '../views/LogSearchView.vue'
import ProjectManageView from '../views/ProjectManageView.vue'
import AlertManageView from '../views/AlertManageView.vue'
const routes = [
{ path: '/', name: 'dashboard', component: DashboardView },
{ path: '/logs', name: 'logs', component: LogSearchView },
{ path: '/projects', name: 'projects', component: ProjectManageView },
{ path: '/alerts', name: 'alerts', component: AlertManageView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
+272
View File
@@ -0,0 +1,272 @@
<template>
<div class="alert-manage">
<h2 style="margin-bottom: 20px">🔔 告警中心</h2>
<!-- Tabs: Rules / History -->
<el-tabs v-model="activeTab">
<!-- Rules -->
<el-tab-pane label="告警规则" name="rules">
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px">
<el-button type="primary" @click="showCreateRuleDialog" :icon="Plus">新建规则</el-button>
</div>
<el-card shadow="hover">
<el-table :data="rules" stripe style="width: 100%" v-loading="rulesLoading">
<el-table-column prop="name" label="规则名称" min-width="150" />
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="row.level === 'critical' ? 'danger' : row.level === 'warning' ? 'warning' : 'info'" size="small">
{{ row.level?.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="field" label="字段" width="100" />
<el-table-column label="条件" width="150">
<template #default="{ row }">
{{ row.field }} {{ operatorLabel(row.operator) }} {{ row.threshold }}
</template>
</el-table-column>
<el-table-column label="时间窗口" width="120">
<template #default="{ row }">{{ row.window_minutes }} 分钟</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-switch :model-value="row.is_enabled" @change="(v) => toggleRule(row, v)" />
</template>
</el-table-column>
<el-table-column prop="project_id" label="项目 ID" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="showEditRuleDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDeleteRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- History -->
<el-tab-pane label="告警历史" name="history">
<el-card shadow="hover">
<el-table :data="history" stripe style="width: 100%" v-loading="historyLoading">
<el-table-column label="时间" width="170">
<template #default="{ row }">
{{ row.triggered_at?.substring(0, 19)?.replace('T', ' ') }}
</template>
</el-table-column>
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="row.level === 'critical' ? 'danger' : 'warning'" size="small">
{{ row.level?.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="消息" min-width="400" prop="message" show-overflow-tooltip />
<el-table-column label="触发值" width="100" prop="triggered_value" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_acknowledged ? 'success' : 'danger'" size="small">
{{ row.is_acknowledged ? '已确认' : '未确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button v-if="!row.is_acknowledged" size="small" type="primary" @click="handleAcknowledge(row)">确认</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- Rule Dialog -->
<el-dialog v-model="ruleDialogVisible" :title="isEditingRule ? '编辑规则' : '新建规则'" width="550px">
<el-form :model="ruleForm" :rules="ruleRules" ref="ruleFormRef" label-width="120px">
<el-form-item label="规则名称" prop="name">
<el-input v-model="ruleForm.name" />
</el-form-item>
<el-form-item label="项目 ID" prop="project_id">
<el-input v-model="ruleForm.project_id" placeholder="输入项目 ID" />
</el-form-item>
<el-form-item label="告警级别" prop="level">
<el-select v-model="ruleForm.level" style="width: 100%">
<el-option label="INFO" value="info" />
<el-option label="WARNING" value="warning" />
<el-option label="CRITICAL" value="critical" />
</el-select>
</el-form-item>
<el-form-item label="字段" prop="field">
<el-input v-model="ruleForm.field" placeholder="如: level" />
</el-form-item>
<el-form-item label="条件" prop="operator">
<el-select v-model="ruleForm.operator" style="width: 120px">
<el-option label=">" value="gt" />
<el-option label=">=" value="gte" />
<el-option label="<" value="lt" />
<el-option label="<=" value="lte" />
<el-option label="=" value="eq" />
</el-select>
<el-input-number v-model="ruleForm.threshold" :min="0" style="margin-left: 10px" />
</el-form-item>
<el-form-item label="时间窗口(分)">
<el-input-number v-model="ruleForm.window_minutes" :min="1" :max="1440" />
</el-form-item>
<el-form-item label="通知渠道">
<el-checkbox-group v-model="ruleForm.notify_channels">
<el-checkbox label="feishu">飞书</el-checkbox>
<el-checkbox label="dingtalk">钉钉</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="ruleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitRule" :loading="ruleSubmitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, listAlertHistory, acknowledgeAlert } from '../api'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const activeTab = ref('rules')
// Rules
const rules = ref([])
const rulesLoading = ref(false)
const ruleDialogVisible = ref(false)
const isEditingRule = ref(false)
const editRuleId = ref(null)
const ruleSubmitting = ref(false)
const ruleFormRef = ref(null)
const ruleForm = ref({
name: '',
project_id: '',
level: 'warning',
field: 'level',
operator: 'gte',
threshold: 10,
window_minutes: 5,
notify_channels: [],
})
const ruleRules = {
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
project_id: [{ required: true, message: '请输入项目 ID', trigger: 'blur' }],
}
// History
const history = ref([])
const historyLoading = ref(false)
function operatorLabel(op) {
const map = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '=' }
return map[op] || op
}
async function loadRules() {
rulesLoading.value = true
try {
const res = await listAlertRules()
rules.value = res.data
} catch (e) {
console.error(e)
} finally {
rulesLoading.value = false
}
}
async function loadHistory() {
historyLoading.value = true
try {
const res = await listAlertHistory()
history.value = res.data
} catch (e) {
console.error(e)
} finally {
historyLoading.value = false
}
}
function showCreateRuleDialog() {
isEditingRule.value = false
editRuleId.value = null
ruleForm.value = { name: '', project_id: '', level: 'warning', field: 'level', operator: 'gte', threshold: 10, window_minutes: 5, notify_channels: [] }
ruleDialogVisible.value = true
}
function showEditRuleDialog(row) {
isEditingRule.value = true
editRuleId.value = row.id
ruleForm.value = {
name: row.name,
project_id: row.project_id,
level: row.level,
field: row.field,
operator: row.operator,
threshold: row.threshold,
window_minutes: row.window_minutes,
notify_channels: row.notify_channels || [],
}
ruleDialogVisible.value = true
}
async function handleSubmitRule() {
const valid = await ruleFormRef.value.validate().catch(() => false)
if (!valid) return
ruleSubmitting.value = true
try {
if (isEditingRule.value) {
await updateAlertRule(editRuleId.value, ruleForm.value)
ElMessage.success('规则已更新')
} else {
await createAlertRule(ruleForm.value)
ElMessage.success('规则已创建')
}
ruleDialogVisible.value = false
await loadRules()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '操作失败')
} finally {
ruleSubmitting.value = false
}
}
async function handleDeleteRule(row) {
try {
await ElMessageBox.confirm(`确定删除规则"${row.name}"`, '确认删除', { type: 'warning' })
await deleteAlertRule(row.id)
ElMessage.success('规则已删除')
await loadRules()
} catch (e) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
async function toggleRule(row, enabled) {
try {
await updateAlertRule(row.id, { is_enabled: enabled })
ElMessage.success(enabled ? '规则已启用' : '规则已停用')
} catch (e) {
ElMessage.error('操作失败')
}
}
async function handleAcknowledge(row) {
try {
await acknowledgeAlert(row.id)
ElMessage.success('已确认')
await loadHistory()
} catch (e) {
ElMessage.error('确认失败')
}
}
onMounted(() => {
loadRules()
loadHistory()
})
</script>
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="dashboard">
<h2 style="margin-bottom: 20px">📊 仪表盘</h2>
<!-- Stats Cards -->
<el-row :gutter="20" style="margin-bottom: 20px">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">总日志量</div>
<div class="stat-value">{{ stats.total_logs.toLocaleString() }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">错误数</div>
<div class="stat-value" style="color: #f56c6c">{{ stats.error_count.toLocaleString() }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">错误率</div>
<div class="stat-value" :style="{ color: stats.error_rate > 5 ? '#f56c6c' : '#67c23a' }">
{{ stats.error_rate }}%
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">项目数</div>
<div class="stat-value" style="color: #409eff">{{ projects.length }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Charts Row -->
<el-row :gutter="20" style="margin-bottom: 20px">
<el-col :span="16">
<el-card shadow="hover">
<template #header>
<span>📈 日志趋势近24h</span>
</template>
<div ref="trendChart" style="height: 300px"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<template #header>
<span>🥧 日志级别分布</span>
</template>
<div ref="pieChart" style="height: 300px"></div>
</el-card>
</el-col>
</el-row>
<!-- Top Errors -->
<el-card shadow="hover">
<template #header>
<span> 高频错误 TOP 10</span>
</template>
<el-table :data="stats.top_errors" stripe style="width: 100%" v-if="stats.top_errors.length">
<el-table-column prop="message" label="错误消息" min-width="500">
<template #default="{ row }">
<el-tooltip :content="row.message" placement="top">
<span class="error-msg">{{ row.message }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="count" label="出现次数" width="120" align="right" sortable />
</el-table>
<el-empty v-else description="暂无错误数据" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, watch } from 'vue'
import { fetchDashboardStats, fetchTimeSeries, listProjects } from '../api'
import * as echarts from 'echarts'
const stats = ref({
total_logs: 0,
error_count: 0,
error_rate: 0,
level_breakdown: {},
top_errors: [],
})
const projects = ref([])
const trendChart = ref(null)
const pieChart = ref(null)
async function loadData() {
try {
const [statsRes, tsRes, projRes] = await Promise.all([
fetchDashboardStats(),
fetchTimeSeries(),
listProjects(),
])
stats.value = statsRes.data
projects.value = projRes.data
renderCharts(tsRes.data)
} catch (e) {
console.error('Failed to load dashboard data', e)
}
}
function renderCharts(timeSeries) {
nextTick(() => {
// Trend chart
if (trendChart.value) {
const chart = echarts.init(trendChart.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['总计', 'error', 'warning'] },
grid: { left: 40, right: 20, bottom: 30 },
xAxis: {
type: 'category',
data: timeSeries.map((t) => t.timestamp?.substring(11, 16) || ''),
axisLabel: { rotate: 45 },
},
yAxis: { type: 'value' },
series: [
{
name: '总计',
type: 'line',
data: timeSeries.map((t) => t.total || 0),
smooth: true,
lineStyle: { color: '#409eff' },
},
{
name: 'error',
type: 'line',
data: timeSeries.map((t) => t.levels?.error || 0),
smooth: true,
lineStyle: { color: '#f56c6c' },
},
{
name: 'warning',
type: 'line',
data: timeSeries.map((t) => t.levels?.warning || 0),
smooth: true,
lineStyle: { color: '#e6a23c' },
},
],
})
}
// Pie chart
if (pieChart.value) {
const chart = echarts.init(pieChart.value)
const levels = stats.value.level_breakdown || {}
const colors = { debug: '#909399', info: '#409eff', warning: '#e6a23c', error: '#f56c6c', critical: '#f56c6c' }
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
data: Object.entries(levels).map(([name, value]) => ({
name,
value,
itemStyle: { color: colors[name] || '#909399' },
})),
label: { formatter: '{b}\n{d}%' },
},
],
})
}
})
}
onMounted(loadData)
</script>
<style scoped>
.stat-card {
text-align: center;
padding: 10px 0;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
}
.error-msg {
display: inline-block;
max-width: 480px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<template>
<div class="log-search">
<h2 style="margin-bottom: 20px">🔍 日志检索</h2>
<!-- Search Filters -->
<el-card shadow="hover" style="margin-bottom: 20px">
<el-form :model="filters" inline>
<el-form-item label="项目">
<el-select v-model="filters.project" placeholder="全部项目" clearable style="width: 160px">
<el-option v-for="p in projects" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
</el-form-item>
<el-form-item label="级别">
<el-select v-model="filters.level" placeholder="全部级别" clearable style="width: 120px">
<el-option label="DEBUG" value="debug" />
<el-option label="INFO" value="info" />
<el-option label="WARNING" value="warning" />
<el-option label="ERROR" value="error" />
<el-option label="CRITICAL" value="critical" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="filters.query" placeholder="搜索消息内容..." clearable style="width: 250px" />
</el-form-item>
<el-form-item label="Trace ID">
<el-input v-model="filters.trace_id" placeholder="按 trace ID 过滤" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch" :icon="Search">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Stats Summary -->
<el-card shadow="hover" style="margin-bottom: 20px">
<el-row :gutter="20">
<el-col :span="8">
<div class="stat-item">
<span class="stat-label">匹配总数</span>
<span class="stat-value">{{ result.total }}</span>
</div>
</el-col>
<el-col :span="8">
<div class="stat-item">
<span class="stat-label">当前页</span>
<span class="stat-value">{{ result.page }} / {{ totalPages }}</span>
</div>
</el-col>
<el-col :span="8">
<div class="stat-item">
<span class="stat-label">每页</span>
<el-select v-model="filters.page_size" style="width: 80px" @change="doSearch">
<el-option label="20" :value="20" />
<el-option label="50" :value="50" />
<el-option label="100" :value="100" />
</el-select>
</div>
</el-col>
</el-row>
</el-card>
<!-- Log Results -->
<el-card shadow="hover">
<el-table :data="result.hits" stripe style="width: 100%" v-loading="loading" @row-click="showDetail">
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ row['timestamp']?.substring(0, 19)?.replace('T', ' ') || '-' }}
</template>
</el-table-column>
<el-table-column label="级别" width="90">
<template #default="{ row }">
<el-tag :type="levelTag(row.level)" size="small" effect="dark">
{{ row.level?.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="项目" width="130" prop="project_name" />
<el-table-column label="Logger" width="150" prop="logger" />
<el-table-column label="消息" min-width="300">
<template #default="{ row }">
<span class="log-message">{{ row.message }}</span>
</template>
</el-table-column>
<el-table-column label="Trace ID" width="160" prop="trace_id" />
</el-table>
<!-- Pagination -->
<div style="margin-top: 16px; display: flex; justify-content: center">
<el-pagination
v-model:current-page="filters.page"
:page-size="filters.page_size"
:total="result.total"
layout="prev, pager, next"
@current-change="doSearch"
/>
</div>
</el-card>
<!-- Log Detail Dialog -->
<el-dialog v-model="detailVisible" title="日志详情" width="700px" top="5vh">
<pre class="log-detail">{{ JSON.stringify(selectedLog, null, 2) }}</pre>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { searchLogs, listProjects } from '../api'
import { Search } from '@element-plus/icons-vue'
const filters = ref({
project: null,
level: null,
query: '',
trace_id: '',
page: 1,
page_size: 50,
sort_by: 'timestamp',
sort_order: 'desc',
})
const timeRange = ref(null)
const result = ref({ total: 0, page: 1, page_size: 50, hits: [] })
const projects = ref([])
const loading = ref(false)
const detailVisible = ref(false)
const selectedLog = ref(null)
const totalPages = computed(() => Math.max(1, Math.ceil(result.value.total / filters.value.page_size)))
function levelTag(level) {
const map = { debug: 'info', info: 'primary', warning: 'warning', error: 'danger', critical: 'danger' }
return map[level] || 'info'
}
async function doSearch() {
loading.value = true
try {
const params = { ...filters.value }
if (timeRange.value) {
params.start_time = timeRange.value[0]
params.end_time = timeRange.value[1]
}
// Remove null/empty values
Object.keys(params).forEach((k) => {
if (params[k] === null || params[k] === '' || params[k] === undefined) delete params[k]
})
const res = await searchLogs(params)
result.value = res.data
} catch (e) {
console.error('Search failed', e)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.value = { project: null, level: null, query: '', trace_id: '', page: 1, page_size: 50, sort_by: 'timestamp', sort_order: 'desc' }
timeRange.value = null
doSearch()
}
function showDetail(row) {
selectedLog.value = row
detailVisible.value = true
}
onMounted(async () => {
try {
const res = await listProjects()
projects.value = res.data
} catch (e) {
// ignore
}
doSearch()
})
</script>
<style scoped>
.stat-item {
display: flex;
align-items: center;
gap: 8px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.stat-value {
font-size: 16px;
font-weight: bold;
}
.log-message {
display: inline-block;
max-width: 500px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-detail {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
font-size: 13px;
overflow: auto;
max-height: 60vh;
white-space: pre-wrap;
word-break: break-all;
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div class="project-manage">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
<h2 style="margin: 0"> 项目管理</h2>
<el-button type="primary" @click="showCreateDialog" :icon="Plus">新建项目</el-button>
</div>
<el-card shadow="hover">
<el-table :data="projects" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="项目名称" min-width="150" />
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
{{ row.is_active ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ row.created_at?.substring(0, 19)?.replace('T', ' ') }}
</template>
</el-table-column>
<el-table-column label="API Key" width="200">
<template #default="{ row }">
<el-input :model-value="row.api_key || ''" readonly size="small" style="width: 140px">
<template #suffix>
<el-icon style="cursor: pointer" @click="copyKey(row)"><CopyDocument /></el-icon>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button size="small" type="warning" @click="handleRotateKey(row)">重置密钥</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑项目' : '新建项目'" width="500px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="项目名称" prop="name">
<el-input v-model="form.name" placeholder="输入项目名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="项目描述(可选)" />
</el-form-item>
<el-form-item label="启用" v-if="isEditing">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listProjects, createProject, updateProject, deleteProject, rotateApiKey } from '../api'
import { Plus, CopyDocument } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const projects = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEditing = ref(false)
const submitting = ref(false)
const editId = ref(null)
const formRef = ref(null)
const form = ref({ name: '', description: '', is_active: true })
const rules = {
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
}
async function loadProjects() {
loading.value = true
try {
const res = await listProjects()
projects.value = res.data
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function showCreateDialog() {
isEditing.value = false
editId.value = null
form.value = { name: '', description: '', is_active: true }
dialogVisible.value = true
}
function showEditDialog(row) {
isEditing.value = true
editId.value = row.id
form.value = { name: row.name, description: row.description || '', is_active: row.is_active }
dialogVisible.value = true
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (isEditing.value) {
await updateProject(editId.value, form.value)
ElMessage.success('项目已更新')
} else {
const res = await createProject(form.value)
ElMessage.success(`项目已创建!API Key: ${res.data.api_key}`)
}
dialogVisible.value = false
await loadProjects()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '操作失败')
} finally {
submitting.value = false
}
}
async function handleRotateKey(row) {
try {
await ElMessageBox.confirm('确定要重置该项目的 API Key 吗?旧密钥将立即失效。', '确认重置', { type: 'warning' })
const res = await rotateApiKey(row.id)
ElMessage.success(`新 API Key: ${res.data.api_key}`)
await loadProjects()
} catch (e) {
if (e !== 'cancel') ElMessage.error('重置失败')
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定要删除项目"${row.name}"吗?相关日志不会被删除。`, '确认删除', { type: 'warning' })
await deleteProject(row.id)
ElMessage.success('项目已删除')
await loadProjects()
} catch (e) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
function copyKey(row) {
if (!row.api_key) return
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(row.api_key).then(() => {
ElMessage.success('API Key 已复制')
}).catch(() => {
ElMessage.error('复制失败')
})
} else {
const ta = document.createElement('textarea')
ta.value = row.api_key
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
ElMessage.success('API Key 已复制')
} catch (e) {
ElMessage.error('复制失败,请手动复制')
} finally {
document.body.removeChild(ta)
}
}
}
onMounted(loadProjects)
</script>
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})