2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 10:17:17 +08:00

feat: 添加操作日志记录功能和前端日志查看页面 (#1227)

* Initial plan

* feat: 添加操作日志记录功能和前端日志查看页面

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 修复 HttpLog 中 IP 字符串处理的类型安全问题

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* feat: 完善面板内日志记录,添加网站/数据库/项目/计划任务/备份CRUD日志

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 完善更多模块的日志记录(证书/SSH/Webhook/用户/设置/安全)

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 改进cert_dns删除方法,在删除前获取实体信息用于日志记录

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* feat: 从session获取操作员ID并在前端显示用户名

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-01-12 23:31:22 +08:00
committed by GitHub
parent 1012e5a246
commit f2e41a3364
64 changed files with 1305 additions and 248 deletions

View File

@@ -0,0 +1,7 @@
import { http } from '@/utils'
export default {
// 获取日志列表
list: (type: 'app' | 'db' | 'http', limit: number = 100): any =>
http.Get('/log/list', { params: { type, limit } })
}

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
defineOptions({
name: 'database-log'
})
import { NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
const { $gettext } = useGettext()
// 日志条目类型定义
interface LogEntry {
time: string
level: string
msg: string
extra?: Record<string, any>
}
// 数据加载
const limit = ref(200)
const { loading, data, send: refresh } = useRequest(
() => log.list('db', limit.value),
{ initialData: [] }
)
// 表格列配置
const columns = [
{
title: $gettext('Time'),
key: 'time',
width: 180,
render: (row: LogEntry) => {
const date = new Date(row.time)
return date.toLocaleString()
}
},
{
title: $gettext('Level'),
key: 'level',
width: 80,
render: (row: LogEntry) => {
const typeMap: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
INFO: 'success',
WARN: 'warning',
ERROR: 'error',
DEBUG: 'info'
}
return h(NTag, { type: typeMap[row.level] || 'default', size: 'small' }, () => row.level)
}
},
{
title: $gettext('Query'),
key: 'query',
ellipsis: {
tooltip: true
},
render: (row: LogEntry) => {
return row.extra?.query || row.msg || '-'
}
},
{
title: $gettext('Duration'),
key: 'duration',
width: 120,
render: (row: LogEntry) => {
if (row.extra?.duration) {
// 纳秒转毫秒
const ms = Number(row.extra.duration) / 1000000
return `${ms.toFixed(2)} ms`
}
return '-'
}
},
{
title: $gettext('Rows'),
key: 'rows',
width: 80,
render: (row: LogEntry) => {
return row.extra?.rows !== undefined ? row.extra.rows : '-'
}
}
]
// 刷新
const handleRefresh = () => {
refresh()
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="mb-4 flex gap-4 items-center">
<span>{{ $gettext('Show entries') }}:</span>
<n-select
v-model:value="limit"
:options="[
{ label: '100', value: 100 },
{ label: '200', value: 200 },
{ label: '500', value: 500 },
{ label: '1000', value: 1000 }
]"
class="w-100px"
@update:value="handleRefresh"
/>
<n-button type="primary" @click="handleRefresh">
{{ $gettext('Refresh') }}
</n-button>
</div>
<n-data-table
:columns="columns"
:data="data"
:loading="loading"
:bordered="false"
:max-height="600"
:scroll-x="800"
virtual-scroll
/>
</div>
</template>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
defineOptions({
name: 'http-log'
})
import { NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
const { $gettext } = useGettext()
// 日志条目类型定义
interface LogEntry {
time: string
level: string
msg: string
extra?: Record<string, any>
}
// 数据加载
const limit = ref(200)
const { loading, data, send: refresh } = useRequest(
() => log.list('http', limit.value),
{ initialData: [] }
)
// 获取状态码颜色
const getStatusType = (code: number): 'success' | 'warning' | 'error' | 'info' => {
if (code >= 200 && code < 300) return 'success'
if (code >= 300 && code < 400) return 'info'
if (code >= 400 && code < 500) return 'warning'
return 'error'
}
// 表格列配置
const columns = [
{
title: $gettext('Time'),
key: 'time',
width: 180,
render: (row: LogEntry) => {
const date = new Date(row.time)
return date.toLocaleString()
}
},
{
title: $gettext('Method'),
key: 'method',
width: 80,
render: (row: LogEntry) => {
const method = row.extra?.['http.request.method'] || '-'
const colorMap: Record<string, string> = {
GET: '#52c41a',
POST: '#1890ff',
PUT: '#faad14',
DELETE: '#ff4d4f',
PATCH: '#722ed1'
}
return h('span', { style: { color: colorMap[method] || 'inherit', fontWeight: 'bold' } }, method)
}
},
{
title: $gettext('Path'),
key: 'path',
ellipsis: {
tooltip: true
},
render: (row: LogEntry) => {
return row.extra?.['url.path'] || '-'
}
},
{
title: $gettext('Status'),
key: 'status',
width: 80,
render: (row: LogEntry) => {
const status = row.extra?.['http.response.status_code']
if (status) {
return h(NTag, { type: getStatusType(status), size: 'small' }, () => status)
}
return '-'
}
},
{
title: $gettext('Duration'),
key: 'duration',
width: 120,
render: (row: LogEntry) => {
const duration = row.extra?.['event.duration']
if (duration) {
// 纳秒转毫秒
const ms = Number(duration) / 1000000
return `${ms.toFixed(2)} ms`
}
return '-'
}
},
{
title: $gettext('Client IP'),
key: 'client_ip',
width: 150,
render: (row: LogEntry) => {
const ip = row.extra?.['client.ip'] || '-'
// 移除端口号
return typeof ip === 'string' && ip.includes(':') ? ip.split(':')[0] : ip
}
}
]
// 刷新
const handleRefresh = () => {
refresh()
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="mb-4 flex gap-4 items-center">
<span>{{ $gettext('Show entries') }}:</span>
<n-select
v-model:value="limit"
:options="[
{ label: '100', value: 100 },
{ label: '200', value: 200 },
{ label: '500', value: 500 },
{ label: '1000', value: 1000 }
]"
class="w-100px"
@update:value="handleRefresh"
/>
<n-button type="primary" @click="handleRefresh">
{{ $gettext('Refresh') }}
</n-button>
</div>
<n-data-table
:columns="columns"
:data="data"
:loading="loading"
:bordered="false"
:max-height="600"
:scroll-x="800"
virtual-scroll
/>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
defineOptions({
name: 'log-index'
})
import { useGettext } from 'vue3-gettext'
import DatabaseLog from './DatabaseLog.vue'
import HttpLog from './HttpLog.vue'
import OperationLog from './OperationLog.vue'
const { $gettext } = useGettext()
// 当前激活的标签
const activeTab = ref('operation')
</script>
<template>
<common-page show-header show-footer>
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="operation" :tab="$gettext('Operation Log')">
<OperationLog />
</n-tab-pane>
<n-tab-pane name="database" :tab="$gettext('Database Log')">
<DatabaseLog />
</n-tab-pane>
<n-tab-pane name="http" :tab="$gettext('HTTP Log')">
<HttpLog />
</n-tab-pane>
</n-tabs>
</common-page>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
defineOptions({
name: 'operation-log'
})
import { NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
const { $gettext } = useGettext()
// 日志条目类型定义
interface LogEntry {
time: string
level: string
msg: string
type?: string
operator_id?: number
operator_name?: string
extra?: Record<string, any>
}
// 数据加载
const limit = ref(200)
const { loading, data, send: refresh } = useRequest(
() => log.list('app', limit.value),
{ initialData: [] }
)
// 表格列配置
const columns = [
{
title: $gettext('Time'),
key: 'time',
width: 180,
render: (row: LogEntry) => {
const date = new Date(row.time)
return date.toLocaleString()
}
},
{
title: $gettext('Level'),
key: 'level',
width: 80,
render: (row: LogEntry) => {
const typeMap: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
INFO: 'success',
WARN: 'warning',
ERROR: 'error',
DEBUG: 'info'
}
return h(NTag, { type: typeMap[row.level] || 'default', size: 'small' }, () => row.level)
}
},
{
title: $gettext('Type'),
key: 'type',
width: 120,
render: (row: LogEntry) => {
return row.type || '-'
}
},
{
title: $gettext('Operator'),
key: 'operator_name',
width: 120,
render: (row: LogEntry) => {
if (row.operator_id === 0 || row.operator_id === undefined) {
return $gettext('System')
}
return row.operator_name || `#${row.operator_id}`
}
},
{
title: $gettext('Message'),
key: 'msg',
ellipsis: {
tooltip: true
}
}
]
// 刷新
const handleRefresh = () => {
refresh()
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="mb-4 flex gap-4 items-center">
<span>{{ $gettext('Show entries') }}:</span>
<n-select
v-model:value="limit"
:options="[
{ label: '100', value: 100 },
{ label: '200', value: 200 },
{ label: '500', value: 500 },
{ label: '1000', value: 1000 }
]"
class="w-100px"
@update:value="handleRefresh"
/>
<n-button type="primary" @click="handleRefresh">
{{ $gettext('Refresh') }}
</n-button>
</div>
<n-data-table
:columns="columns"
:data="data"
:loading="loading"
:bordered="false"
:max-height="600"
:scroll-x="800"
virtual-scroll
/>
</div>
</template>

View File

@@ -0,0 +1,25 @@
import type { RouteType } from '~/types/router'
const Layout = () => import('@/layout/IndexView.vue')
export default {
name: 'log',
path: '/log',
component: Layout,
meta: {
order: 35
},
children: [
{
name: 'log-index',
path: '',
component: () => import('./IndexView.vue'),
meta: {
title: 'Logs',
icon: 'mdi:file-document-outline',
role: ['admin'],
requireAuth: true
}
}
]
} as RouteType