feat(log): 解耦用户访问日志存储,支持切换日志主库

用户访问日志可在 ClickHouse、PostgreSQL、SQLite 之间切换。
关闭 ClickHouse 时由主库承接写入与查询;切换任务会冻结写入、复制数据后翻转主库。
启动时校验日志主库与运行配置一致,定期清理按各库保留天数删除过期记录。
This commit is contained in:
ryan
2026-08-16 11:17:55 +08:00
parent 6a53619dd2
commit e52592b16d
34 changed files with 2325 additions and 145 deletions
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -20,12 +20,24 @@ import {
import {
Calendar as CalendarIcon,
Clock,
Database,
Info,
Layers,
Play,
} from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { DispatchTaskRequest, TaskMeta } from '@/lib/services/admin';
import type {
DispatchTaskRequest,
LogDatabaseStatus,
TaskMeta,
} from '@/lib/services/admin';
import services from '@/lib/services';
import { buildTaskPayload } from '@/lib/task-param-utils';
import { ErrorInline } from '@/components/layout/error';
@@ -69,6 +81,18 @@ const TASK_CONFIGS: Record<
gradient:
'from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500',
},
logs_db_switch: {
icon: Database,
color: 'text-teal-600 dark:text-teal-400',
gradient:
'from-teal-500/10 via-teal-500/5 to-transparent border-teal-200/50 dark:border-teal-800/50 hover:border-teal-400 dark:hover:border-teal-500',
},
};
const LOG_DATABASE_LABELS: Record<string, string> = {
postgres: 'PostgreSQL(主库)',
sqlite: 'SQLite(主库)',
clickhouse: 'ClickHouse',
};
const DEFAULT_TASK_CONFIG = {
@@ -196,9 +220,37 @@ export function TaskManager() {
}
}, [t]);
const [logDbStatus, setLogDbStatus] = useState<LogDatabaseStatus | null>(
null,
);
const fetchLogDbStatus = useCallback(async () => {
try {
const data = await services.adminStatus.getLogDatabaseStatus();
setLogDbStatus(data);
} catch {
setLogDbStatus(null);
}
}, []);
useEffect(() => {
fetchTaskTypes();
}, [fetchTaskTypes]);
fetchLogDbStatus();
}, [fetchTaskTypes, fetchLogDbStatus]);
const availableLogDbTargets = useMemo(
() => logDbStatus?.available_targets ?? [],
[logDbStatus],
);
const retentionSummary = useMemo(() => {
const days = logDbStatus?.retention_days ?? {};
const parts: string[] = [];
if (days.postgres != null) parts.push(`PG ${days.postgres}`);
if (days.sqlite != null) parts.push(`SQLite ${days.sqlite}`);
if (days.clickhouse != null) parts.push(`CH ${days.clickhouse}`);
return parts.join(' / ');
}, [logDbStatus]);
useEffect(() => {
if (selectedTaskType) {
@@ -353,6 +405,45 @@ export function TaskManager() {
</div>
</div>
{task.type === 'logs_db_switch' && logDbStatus && (
<div className='pt-3 mt-3 border-t border-border/50 space-y-1.5'>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
日志主库
</span>
<span className='text-[10px] font-mono text-foreground truncate'>
{LOG_DATABASE_LABELS[logDbStatus.active_database] ||
logDbStatus.active_database}
</span>
</div>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
保留天数
</span>
<span className='text-[10px] font-mono text-muted-foreground truncate'>
{retentionSummary || '-'}
</span>
</div>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
迁移状态
</span>
<Badge
variant={
logDbStatus.migration === 'migrating'
? 'default'
: 'outline'
}
className='text-[10px] h-5 px-1.5'
>
{logDbStatus.migration === 'migrating'
? '迁移中'
: '空闲'}
</Badge>
</div>
</div>
)}
<div className='pt-4 mt-1'>
<Button
className='w-full h-7 text-xs'
@@ -485,6 +576,34 @@ export function TaskManager() {
: t('paramOff')}
</span>
</div>
) : param.name === 'target' &&
getSelectedTaskMeta()?.type === 'logs_db_switch' &&
availableLogDbTargets.length > 0 ? (
<Select
value={paramValues[param.name] || ''}
onValueChange={(value) =>
setParamValues((prev) => ({
...prev,
[param.name]: value,
}))
}
disabled={dispatching}
>
<SelectTrigger
id={`param-${param.name}`}
className='w-full text-xs'
size='sm'
>
<SelectValue placeholder='选择目标日志库...' />
</SelectTrigger>
<SelectContent>
{availableLogDbTargets.map((target) => (
<SelectItem key={target} value={target}>
{LOG_DATABASE_LABELS[target] || target}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`param-${param.name}`}
+1
View File
@@ -29,6 +29,7 @@ export type {
UpdateUserStatusRequest,
UpdateUserRequest,
SystemStatus,
LogDatabaseStatus,
AppUpdateStatus,
Schedule,
CreateScheduleRequest,
@@ -1,5 +1,5 @@
import { BaseService } from '@/lib/services/core';
import type { AppUpdateStatus, SystemStatus } from './types';
import type { AppUpdateStatus, LogDatabaseStatus, SystemStatus } from './types';
export class AdminStatusService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
@@ -8,6 +8,10 @@ export class AdminStatusService extends BaseService {
return this.get<SystemStatus>('/status');
}
static async getLogDatabaseStatus(): Promise<LogDatabaseStatus> {
return this.get<LogDatabaseStatus>('/status/log-database');
}
static async getUpdateStatus(): Promise<AppUpdateStatus> {
return this.get<AppUpdateStatus>('/update');
}
+14
View File
@@ -401,6 +401,20 @@ export interface ToggleAuthSourceRequest {
is_active: boolean;
}
/**
* 日志数据库状态
*/
export interface LogDatabaseStatus {
/** 当前日志主库:postgres | sqlite | clickhouse */
active_database: string;
/** 迁移状态:idle | migrating */
migration: string;
/** 各日志库保留天数 */
retention_days: Record<string, number>;
/** 当前主库的合法迁移目标 */
available_targets: string[];
}
/**
* 系统状态信息
*/
+1
View File
@@ -124,6 +124,7 @@ export type {
CreateUserRequest,
UpdateUserRequest,
SystemStatus,
LogDatabaseStatus,
AppUpdateStatus,
Schedule,
CreateScheduleRequest,