Initial commit: LogHive centralized log management system
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user