Commit fff1d548 authored by yangjianbo's avatar yangjianbo
Browse files

feat(log): 落地统一日志底座与系统日志运维能力

parent a5f29019
......@@ -6,6 +6,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
)
......@@ -193,6 +194,13 @@ func ProvideOpsCleanupService(
return svc
}
func ProvideOpsSystemLogSink(opsRepo OpsRepository) *OpsSystemLogSink {
sink := NewOpsSystemLogSink(opsRepo)
sink.Start()
logger.SetSink(sink)
return sink
}
// ProvideSoraMediaStorage 初始化 Sora 媒体存储
func ProvideSoraMediaStorage(cfg *config.Config) *SoraMediaStorage {
return NewSoraMediaStorage(cfg)
......@@ -268,6 +276,7 @@ var ProviderSet = wire.NewSet(
NewAccountUsageService,
NewAccountTestService,
NewSettingService,
ProvideOpsSystemLogSink,
NewOpsService,
ProvideOpsMetricsCollector,
ProvideOpsAggregationService,
......
-- 054_ops_system_logs.sql
-- 统一日志索引表与清理审计表
CREATE TABLE IF NOT EXISTS ops_system_logs (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level VARCHAR(16) NOT NULL,
component VARCHAR(128) NOT NULL DEFAULT '',
message TEXT NOT NULL,
request_id VARCHAR(128),
client_request_id VARCHAR(128),
user_id BIGINT,
account_id BIGINT,
platform VARCHAR(32),
model VARCHAR(128),
extra JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_created_at_id
ON ops_system_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_level_created_at
ON ops_system_logs (level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_component_created_at
ON ops_system_logs (component, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_request_id
ON ops_system_logs (request_id);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_client_request_id
ON ops_system_logs (client_request_id);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_user_id_created_at
ON ops_system_logs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_account_id_created_at
ON ops_system_logs (account_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_platform_model_created_at
ON ops_system_logs (platform, model, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ops_system_logs_message_search
ON ops_system_logs USING GIN (to_tsvector('simple', COALESCE(message, '')));
CREATE TABLE IF NOT EXISTS ops_system_log_cleanup_audits (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
operator_id BIGINT NOT NULL,
conditions JSONB NOT NULL DEFAULT '{}'::jsonb,
deleted_rows BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ops_system_log_cleanup_audits_created_at
ON ops_system_log_cleanup_audits (created_at DESC, id DESC);
......@@ -20,6 +20,52 @@ SERVER_PORT=8080
# Server mode: release or debug
SERVER_MODE=release
# -----------------------------------------------------------------------------
# Logging Configuration
# 日志配置
# -----------------------------------------------------------------------------
# 日志级别:debug/info/warn/error
LOG_LEVEL=info
# 日志格式:json/console
LOG_FORMAT=json
# 每条日志附带的 service 字段
LOG_SERVICE_NAME=sub2api
# 每条日志附带的 env 字段
LOG_ENV=production
# 是否输出调用方位置信息
LOG_CALLER=true
# 堆栈输出阈值:none/error/fatal
LOG_STACKTRACE_LEVEL=error
# 输出开关(建议容器内保持双输出)
# 是否输出到 stdout/stderr
LOG_OUTPUT_TO_STDOUT=true
# 是否输出到文件
LOG_OUTPUT_TO_FILE=true
# 日志文件路径(留空自动推导):
# - 设置 DATA_DIR:${DATA_DIR}/logs/sub2api.log
# - 未设置 DATA_DIR:/app/data/logs/sub2api.log
LOG_OUTPUT_FILE_PATH=
# 滚动配置
# 单文件最大体积(MB)
LOG_ROTATION_MAX_SIZE_MB=100
# 保留历史文件数量(0 表示不限制)
LOG_ROTATION_MAX_BACKUPS=10
# 历史日志保留天数(0 表示不限制)
LOG_ROTATION_MAX_AGE_DAYS=7
# 是否压缩历史日志
LOG_ROTATION_COMPRESS=true
# 滚动文件时间戳是否使用本地时间
LOG_ROTATION_LOCAL_TIME=true
# 采样配置(高频重复日志降噪)
LOG_SAMPLING_ENABLED=false
# 每秒前 N 条日志不采样
LOG_SAMPLING_INITIAL=100
# 之后每 N 条保留 1 条
LOG_SAMPLING_THEREAFTER=100
# Global max request body size in bytes (default: 100MB)
# 全局最大请求体大小(字节,默认 100MB)
# Applies to all requests, especially important for h2c first request memory protection
......
......@@ -286,6 +286,70 @@ gateway:
# profile_2:
# name: "Custom Profile 2"
# =============================================================================
# Logging Configuration
# 日志配置
# =============================================================================
log:
# Log level: debug/info/warn/error
# 日志级别:debug/info/warn/error
level: "info"
# Log format: json/console
# 日志格式:json/console
format: "json"
# Service name field written into each log line
# 每条日志都会附带 service 字段
service_name: "sub2api"
# Environment field written into each log line
# 每条日志都会附带 env 字段
env: "production"
# Include caller information
# 是否输出调用方位置信息
caller: true
# Stacktrace threshold: none/error/fatal
# 堆栈输出阈值:none/error/fatal
stacktrace_level: "error"
output:
# Keep stdout/stderr output for container log collection
# 保持标准输出用于容器日志采集
to_stdout: true
# Enable file output (default path auto-derived)
# 启用文件输出(默认路径自动推导)
to_file: true
# Empty means:
# - DATA_DIR set: {{DATA_DIR}}/logs/sub2api.log
# - otherwise: /app/data/logs/sub2api.log
# 留空时:
# - 设置 DATA_DIR:{{DATA_DIR}}/logs/sub2api.log
# - 否则:/app/data/logs/sub2api.log
file_path: ""
rotation:
# Max file size before rotation (MB)
# 单文件滚动阈值(MB)
max_size_mb: 100
# Number of rotated files to keep (0 means unlimited)
# 保留历史文件数量(0 表示不限制)
max_backups: 10
# Number of days to keep old log files (0 means unlimited)
# 历史日志保留天数(0 表示不限制)
max_age_days: 7
# Compress rotated files
# 是否压缩历史日志
compress: true
# Use local time for timestamp in rotated filename
# 滚动文件名时间戳使用本地时区
local_time: true
sampling:
# Enable zap sampler (reduce high-frequency repetitive logs)
# 启用 zap 采样(减少高频重复日志)
enabled: false
# Number of first entries per second to always log
# 每秒无采样保留的前 N 条日志
initial: 100
# Thereafter keep 1 out of N entries per second
# 之后每 N 条保留 1 条
thereafter: 100
# =============================================================================
# Sora Direct Client Configuration
# Sora 直连配置
......
......@@ -850,6 +850,77 @@ export interface OpsAggregationSettings {
aggregation_enabled: boolean
}
export interface OpsRuntimeLogConfig {
level: 'debug' | 'info' | 'warn' | 'error'
enable_sampling: boolean
sampling_initial: number
sampling_thereafter: number
caller: boolean
stacktrace_level: 'none' | 'error' | 'fatal'
retention_days: number
source?: string
updated_at?: string
updated_by_user_id?: number
}
export interface OpsSystemLog {
id: number
created_at: string
level: string
component: string
message: string
request_id?: string
client_request_id?: string
user_id?: number | null
account_id?: number | null
platform?: string
model?: string
extra?: Record<string, any>
}
export type OpsSystemLogListResponse = PaginatedResponse<OpsSystemLog>
export interface OpsSystemLogQuery {
page?: number
page_size?: number
time_range?: '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d'
start_time?: string
end_time?: string
level?: string
component?: string
request_id?: string
client_request_id?: string
user_id?: number | null
account_id?: number | null
platform?: string
model?: string
q?: string
}
export interface OpsSystemLogCleanupRequest {
start_time?: string
end_time?: string
level?: string
component?: string
request_id?: string
client_request_id?: string
user_id?: number | null
account_id?: number | null
platform?: string
model?: string
q?: string
}
export interface OpsSystemLogSinkHealth {
queue_depth: number
queue_capacity: number
dropped_count: number
write_failed_count: number
written_count: number
avg_write_delay_ms: number
last_error?: string
}
export interface OpsErrorLog {
id: number
created_at: string
......@@ -1205,6 +1276,36 @@ export async function updateAlertRuntimeSettings(config: OpsAlertRuntimeSettings
return data
}
export async function getRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
const { data } = await apiClient.get<OpsRuntimeLogConfig>('/admin/ops/runtime/logging')
return data
}
export async function updateRuntimeLogConfig(config: OpsRuntimeLogConfig): Promise<OpsRuntimeLogConfig> {
const { data } = await apiClient.put<OpsRuntimeLogConfig>('/admin/ops/runtime/logging', config)
return data
}
export async function resetRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
const { data } = await apiClient.post<OpsRuntimeLogConfig>('/admin/ops/runtime/logging/reset')
return data
}
export async function listSystemLogs(params: OpsSystemLogQuery): Promise<OpsSystemLogListResponse> {
const { data } = await apiClient.get<OpsSystemLogListResponse>('/admin/ops/system-logs', { params })
return data
}
export async function cleanupSystemLogs(payload: OpsSystemLogCleanupRequest): Promise<{ deleted: number }> {
const { data } = await apiClient.post<{ deleted: number }>('/admin/ops/system-logs/cleanup', payload)
return data
}
export async function getSystemLogSinkHealth(): Promise<OpsSystemLogSinkHealth> {
const { data } = await apiClient.get<OpsSystemLogSinkHealth>('/admin/ops/system-logs/health')
return data
}
// Advanced settings (DB-backed)
export async function getAdvancedSettings(): Promise<OpsAdvancedSettings> {
const { data } = await apiClient.get<OpsAdvancedSettings>('/admin/ops/advanced-settings')
......@@ -1272,10 +1373,16 @@ export const opsAPI = {
updateEmailNotificationConfig,
getAlertRuntimeSettings,
updateAlertRuntimeSettings,
getRuntimeLogConfig,
updateRuntimeLogConfig,
resetRuntimeLogConfig,
getAdvancedSettings,
updateAdvancedSettings,
getMetricThresholds,
updateMetricThresholds
updateMetricThresholds,
listSystemLogs,
cleanupSystemLogs,
getSystemLogSinkHealth
}
export default opsAPI
......@@ -96,6 +96,13 @@
<!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
<!-- System Logs -->
<OpsSystemLogTable
v-if="opsEnabled && !(loading && !hasLoadedOnce)"
:platform-filter="platform"
:refresh-token="dashboardRefreshToken"
/>
<!-- Settings Dialog (hidden in fullscreen mode) -->
<template v-if="!isFullscreen">
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
......@@ -158,6 +165,7 @@ import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
import OpsOpenAITokenStatsCard from './components/OpsOpenAITokenStatsCard.vue'
import OpsSystemLogTable from './components/OpsSystemLogTable.vue'
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'
......
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops'
import Pagination from '@/components/common/Pagination.vue'
import { useAppStore } from '@/stores'
const appStore = useAppStore()
const props = withDefaults(defineProps<{
platformFilter?: string
refreshToken?: number
}>(), {
platformFilter: '',
refreshToken: 0
})
const loading = ref(false)
const logs = ref<OpsSystemLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const health = ref<OpsSystemLogSinkHealth>({
queue_depth: 0,
queue_capacity: 0,
dropped_count: 0,
write_failed_count: 0,
written_count: 0,
avg_write_delay_ms: 0
})
const runtimeLoading = ref(false)
const runtimeSaving = ref(false)
const runtimeConfig = reactive<OpsRuntimeLogConfig>({
level: 'info',
enable_sampling: false,
sampling_initial: 100,
sampling_thereafter: 100,
caller: true,
stacktrace_level: 'error',
retention_days: 30
})
const filters = reactive({
time_range: '1h' as '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d',
start_time: '',
end_time: '',
level: '',
component: '',
request_id: '',
client_request_id: '',
user_id: '',
account_id: '',
platform: '',
model: '',
q: ''
})
const levelBadgeClass = (level: string) => {
const v = String(level || '').toLowerCase()
if (v === 'error' || v === 'fatal') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
if (v === 'warn' || v === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
if (v === 'debug') return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
const formatTime = (value: string) => {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleString()
}
const toRFC3339 = (value: string) => {
if (!value) return undefined
const d = new Date(value)
if (Number.isNaN(d.getTime())) return undefined
return d.toISOString()
}
const buildQuery = () => {
const query: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: filters.time_range
}
if (filters.time_range === '30d') {
query.time_range = '30d'
}
if (filters.start_time) query.start_time = toRFC3339(filters.start_time)
if (filters.end_time) query.end_time = toRFC3339(filters.end_time)
if (filters.level.trim()) query.level = filters.level.trim()
if (filters.component.trim()) query.component = filters.component.trim()
if (filters.request_id.trim()) query.request_id = filters.request_id.trim()
if (filters.client_request_id.trim()) query.client_request_id = filters.client_request_id.trim()
if (filters.user_id.trim()) {
const v = Number.parseInt(filters.user_id.trim(), 10)
if (Number.isFinite(v) && v > 0) query.user_id = v
}
if (filters.account_id.trim()) {
const v = Number.parseInt(filters.account_id.trim(), 10)
if (Number.isFinite(v) && v > 0) query.account_id = v
}
if (filters.platform.trim()) query.platform = filters.platform.trim()
if (filters.model.trim()) query.model = filters.model.trim()
if (filters.q.trim()) query.q = filters.q.trim()
return query
}
const fetchLogs = async () => {
loading.value = true
try {
const res = await opsAPI.listSystemLogs(buildQuery())
logs.value = res.items || []
total.value = res.total || 0
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to fetch logs', err)
appStore.showError(err?.response?.data?.detail || '系统日志加载失败')
} finally {
loading.value = false
}
}
const fetchHealth = async () => {
try {
health.value = await opsAPI.getSystemLogSinkHealth()
} catch {
// 忽略健康数据读取失败,不影响主流程。
}
}
const loadRuntimeConfig = async () => {
runtimeLoading.value = true
try {
const cfg = await opsAPI.getRuntimeLogConfig()
runtimeConfig.level = cfg.level
runtimeConfig.enable_sampling = cfg.enable_sampling
runtimeConfig.sampling_initial = cfg.sampling_initial
runtimeConfig.sampling_thereafter = cfg.sampling_thereafter
runtimeConfig.caller = cfg.caller
runtimeConfig.stacktrace_level = cfg.stacktrace_level
runtimeConfig.retention_days = cfg.retention_days
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to load runtime log config', err)
} finally {
runtimeLoading.value = false
}
}
const saveRuntimeConfig = async () => {
runtimeSaving.value = true
try {
const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig })
runtimeConfig.level = saved.level
runtimeConfig.enable_sampling = saved.enable_sampling
runtimeConfig.sampling_initial = saved.sampling_initial
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('日志运行时配置已生效')
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to save runtime log config', err)
appStore.showError(err?.response?.data?.detail || '保存日志配置失败')
} finally {
runtimeSaving.value = false
}
}
const resetRuntimeConfig = async () => {
const ok = window.confirm('确认回滚为启动配置(env/yaml)并立即生效?')
if (!ok) return
runtimeSaving.value = true
try {
const saved = await opsAPI.resetRuntimeLogConfig()
runtimeConfig.level = saved.level
runtimeConfig.enable_sampling = saved.enable_sampling
runtimeConfig.sampling_initial = saved.sampling_initial
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('已回滚到启动日志配置')
await fetchHealth()
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to reset runtime log config', err)
appStore.showError(err?.response?.data?.detail || '回滚日志配置失败')
} finally {
runtimeSaving.value = false
}
}
const cleanupCurrentFilter = async () => {
const ok = window.confirm('确认按当前筛选条件清理系统日志?该操作不可撤销。')
if (!ok) return
try {
const payload = {
start_time: toRFC3339(filters.start_time),
end_time: toRFC3339(filters.end_time),
level: filters.level.trim() || undefined,
component: filters.component.trim() || undefined,
request_id: filters.request_id.trim() || undefined,
client_request_id: filters.client_request_id.trim() || undefined,
user_id: filters.user_id.trim() ? Number.parseInt(filters.user_id.trim(), 10) : undefined,
account_id: filters.account_id.trim() ? Number.parseInt(filters.account_id.trim(), 10) : undefined,
platform: filters.platform.trim() || undefined,
model: filters.model.trim() || undefined,
q: filters.q.trim() || undefined
}
const res = await opsAPI.cleanupSystemLogs(payload)
appStore.showSuccess(`清理完成,删除 ${res.deleted || 0} 条日志`)
page.value = 1
await Promise.all([fetchLogs(), fetchHealth()])
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to cleanup logs', err)
appStore.showError(err?.response?.data?.detail || '清理系统日志失败')
}
}
const resetFilters = () => {
filters.time_range = '1h'
filters.start_time = ''
filters.end_time = ''
filters.level = ''
filters.component = ''
filters.request_id = ''
filters.client_request_id = ''
filters.user_id = ''
filters.account_id = ''
filters.platform = props.platformFilter || ''
filters.model = ''
filters.q = ''
page.value = 1
fetchLogs()
}
watch(() => props.platformFilter, (v) => {
if (v && !filters.platform) {
filters.platform = v
page.value = 1
fetchLogs()
}
})
watch(() => props.refreshToken, () => {
fetchLogs()
fetchHealth()
})
const onPageChange = (next: number) => {
page.value = next
fetchLogs()
}
const onPageSizeChange = (next: number) => {
pageSize.value = next
page.value = 1
fetchLogs()
}
const applyFilters = () => {
page.value = 1
fetchLogs()
}
const hasData = computed(() => logs.value.length > 0)
onMounted(async () => {
if (props.platformFilter) {
filters.platform = props.platformFilter
}
await Promise.all([fetchLogs(), fetchHealth(), loadRuntimeConfig()])
})
</script>
<template>
<section class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/60">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">系统日志</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">默认按最新时间倒序,支持筛选搜索与按条件清理。</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">队列 {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">写入 {{ health.written_count }}</span>
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">丢弃 {{ health.dropped_count }}</span>
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">失败 {{ health.write_failed_count }}</span>
</div>
</div>
<div class="mb-4 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800/70">
<div class="mb-2 flex items-center justify-between">
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">运行时日志配置(实时生效)</div>
<span v-if="runtimeLoading" class="text-xs text-gray-500">加载中...</span>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-6">
<label class="text-xs text-gray-600 dark:text-gray-300">
级别
<select v-model="runtimeConfig.level" class="input mt-1">
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
堆栈阈值
<select v-model="runtimeConfig.stacktrace_level" class="input mt-1">
<option value="none">none</option>
<option value="error">error</option>
<option value="fatal">fatal</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样初始
<input v-model.number="runtimeConfig.sampling_initial" type="number" min="1" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样后续
<input v-model.number="runtimeConfig.sampling_thereafter" type="number" min="1" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
保留天数
<input v-model.number="runtimeConfig.retention_days" type="number" min="1" max="3650" class="input mt-1" />
</label>
<div class="flex items-end gap-2">
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.caller" type="checkbox" />
caller
</label>
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
sampling
</label>
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
{{ runtimeSaving ? '保存中...' : '保存并生效' }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
回滚默认值
</button>
</div>
</div>
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">最近写入错误:{{ health.last_error }}</p>
</div>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-5">
<label class="text-xs text-gray-600 dark:text-gray-300">
时间范围
<select v-model="filters.time_range" class="input mt-1">
<option value="5m">5m</option>
<option value="30m">30m</option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
开始时间(可选)
<input v-model="filters.start_time" type="datetime-local" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
结束时间(可选)
<input v-model="filters.end_time" type="datetime-local" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
级别
<select v-model="filters.level" class="input mt-1">
<option value="">全部</option>
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
组件
<input v-model="filters.component" type="text" class="input mt-1" placeholder="如 http.access" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
request_id
<input v-model="filters.request_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
client_request_id
<input v-model="filters.client_request_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
user_id
<input v-model="filters.user_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
account_id
<input v-model="filters.account_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
平台
<input v-model="filters.platform" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
模型
<input v-model="filters.model" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
关键词
<input v-model="filters.q" type="text" class="input mt-1" placeholder="消息/request_id" />
</label>
</div>
<div class="mb-3 flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">查询</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">按当前筛选清理</button>
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">刷新健康指标</button>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">加载中...</div>
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">暂无系统日志</div>
<div v-else class="overflow-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900">
<tr>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">时间</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">级别</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">组件</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">消息</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">关联</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
<tr v-for="row in logs" :key="row.id" class="align-top">
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{{ formatTime(row.created_at) }}</td>
<td class="px-3 py-2 text-xs">
<span class="inline-flex rounded-full px-2 py-0.5 font-semibold" :class="levelBadgeClass(row.level)">
{{ row.level }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{{ row.component || '-' }}</td>
<td class="max-w-[680px] px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{{ row.message }}</td>
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-400">
<div>req: {{ row.request_id || '-' }}</div>
<div>client: {{ row.client_request_id || '-' }}</div>
<div>user: {{ row.user_id || '-' }} / acc: {{ row.account_id || '-' }}</div>
<div>{{ row.platform || '-' }} / {{ row.model || '-' }}</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="total"
:page="page"
:page-size="pageSize"
:page-size-options="[10, 20, 50, 100, 200]"
@update:page="onPageChange"
@update:page-size="onPageSizeChange"
/>
</div>
</section>
</template>
......@@ -17,5 +17,8 @@ export type {
OpsMetricThresholds,
OpsAdvancedSettings,
OpsDataRetentionSettings,
OpsAggregationSettings
OpsAggregationSettings,
OpsRuntimeLogConfig,
OpsSystemLog,
OpsSystemLogSinkHealth
} from '@/api/admin/ops'
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment