mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
Merge branch 'codex/log-database-decoupling'
# Conflicts: # docs/changelog/index.md # frontend/app/(main)/error-pages/page.tsx # internal/infra/persistence/migrator/migrator_test.go
This commit is contained in:
@@ -18,8 +18,6 @@ export type OpenFlareOpsFields = {
|
||||
uptime_kuma_retry: string;
|
||||
uptime_kuma_retry_interval: string;
|
||||
uptime_kuma_timeout: string;
|
||||
database_auto_cleanup_enabled: boolean;
|
||||
database_auto_cleanup_retention_days: string;
|
||||
pages_max_package_size_mb: string;
|
||||
pages_max_history_count: string;
|
||||
};
|
||||
@@ -42,8 +40,6 @@ export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
|
||||
uptime_kuma_retry: '0',
|
||||
uptime_kuma_retry_interval: '60',
|
||||
uptime_kuma_timeout: '48',
|
||||
database_auto_cleanup_enabled: false,
|
||||
database_auto_cleanup_retention_days: '30',
|
||||
pages_max_package_size_mb: '100',
|
||||
pages_max_history_count: '20',
|
||||
};
|
||||
@@ -88,12 +84,6 @@ export function mapOptionsToOpsFields(
|
||||
uptime_kuma_retry: optionMap.uptime_kuma_retry ?? '0',
|
||||
uptime_kuma_retry_interval: optionMap.uptime_kuma_retry_interval ?? '60',
|
||||
uptime_kuma_timeout: optionMap.uptime_kuma_timeout ?? '48',
|
||||
database_auto_cleanup_enabled: toBoolean(
|
||||
optionMap.database_auto_cleanup_enabled,
|
||||
false,
|
||||
),
|
||||
database_auto_cleanup_retention_days:
|
||||
optionMap.database_auto_cleanup_retention_days ?? '30',
|
||||
pages_max_package_size_mb: optionMap.pages_max_package_size_mb ?? '100',
|
||||
pages_max_history_count: optionMap.pages_max_history_count ?? '20',
|
||||
};
|
||||
@@ -165,16 +155,6 @@ export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
|
||||
throw new Error('请求超时必须为正整数。');
|
||||
}
|
||||
|
||||
export function validateDatabaseAutoCleanup(fields: OpenFlareOpsFields) {
|
||||
const retentionDays = Number.parseInt(
|
||||
fields.database_auto_cleanup_retention_days,
|
||||
10,
|
||||
);
|
||||
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||
throw new Error('自动清理保留天数至少为 1 天。');
|
||||
}
|
||||
}
|
||||
|
||||
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateAgentFields(fields);
|
||||
return [
|
||||
@@ -220,22 +200,6 @@ export function uptimeKumaOptionEntries(
|
||||
];
|
||||
}
|
||||
|
||||
export function databaseAutoCleanupEntries(
|
||||
fields: OpenFlareOpsFields,
|
||||
): OptionItem[] {
|
||||
validateDatabaseAutoCleanup(fields);
|
||||
return [
|
||||
{
|
||||
key: 'database_auto_cleanup_enabled',
|
||||
value: String(fields.database_auto_cleanup_enabled),
|
||||
},
|
||||
{
|
||||
key: 'database_auto_cleanup_retention_days',
|
||||
value: fields.database_auto_cleanup_retention_days,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function validatePagesFields(fields: OpenFlareOpsFields) {
|
||||
const packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10);
|
||||
const historyCount = Number.parseInt(fields.pages_max_history_count, 10);
|
||||
|
||||
@@ -11,20 +11,8 @@ import {
|
||||
RotateCw,
|
||||
Save,
|
||||
Server,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -46,7 +34,6 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type { DatabaseCleanupTarget } from '@/lib/services/openflare';
|
||||
import {
|
||||
NodeService,
|
||||
OptionService,
|
||||
@@ -57,7 +44,6 @@ import {
|
||||
import {
|
||||
agentOptionEntries,
|
||||
buildDiscoveryCommand,
|
||||
databaseAutoCleanupEntries,
|
||||
defaultOpenFlareOpsFields,
|
||||
formatDurationLabel,
|
||||
getBrowserOrigin,
|
||||
@@ -72,31 +58,6 @@ import { UptimeKumaSiteSelectModal } from './uptimekuma-site-modal';
|
||||
const optionsQueryKey = ['openflare', 'options'] as const;
|
||||
const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const;
|
||||
|
||||
const cleanupTargets: Array<{
|
||||
target: DatabaseCleanupTarget;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
target: 'node_access_logs',
|
||||
label: '访问日志',
|
||||
description:
|
||||
'清理 node_access_logs,影响访问明细与 IP 汇总;表 TTL 为 90 天。',
|
||||
},
|
||||
{
|
||||
target: 'node_metric_snapshots',
|
||||
label: '性能快照',
|
||||
description:
|
||||
'清理 node_metric_snapshots,影响节点资源趋势;表 TTL 为 30 天。',
|
||||
},
|
||||
{
|
||||
target: 'node_edge_health',
|
||||
label: 'OpenResty 健康',
|
||||
description:
|
||||
'清理 node_edge_health(OpenResty 连接/健康快照);表 TTL 为 30 天。业务流量请清理访问日志。',
|
||||
},
|
||||
];
|
||||
|
||||
async function copyText(value: string) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
}
|
||||
@@ -109,11 +70,6 @@ export function OpenFlareOpsSettings() {
|
||||
const [savingSection, setSavingSection] = useState<string | null>(null);
|
||||
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
|
||||
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false);
|
||||
const [cleanupTarget, setCleanupTarget] = useState<{
|
||||
target: DatabaseCleanupTarget;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: optionsQueryKey,
|
||||
@@ -194,25 +150,6 @@ export function OpenFlareOpsSettings() {
|
||||
toast.error(error instanceof Error ? error.message : '同步失败'),
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (payload: {
|
||||
target: DatabaseCleanupTarget;
|
||||
retention_days?: number;
|
||||
}) => OptionService.cleanupDatabase(payload),
|
||||
onSuccess: (result) => {
|
||||
setCleanupTarget(null);
|
||||
setCleanupRetentionDays('');
|
||||
toast.success(
|
||||
result.delete_all
|
||||
? `已清空${result.target_label},共删除 ${result.deleted_count} 条。`
|
||||
: `已清理${result.target_label},共删除 ${result.deleted_count} 条。`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '清理失败');
|
||||
},
|
||||
});
|
||||
|
||||
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
||||
const discoveryCommand = useMemo(() => {
|
||||
if (!fields.server_address || !discoveryToken) return '';
|
||||
@@ -248,17 +185,6 @@ export function OpenFlareOpsSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveDatabaseAutoCleanup = () => {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: 'database-auto',
|
||||
entries: databaseAutoCleanupEntries(fields),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '参数校验失败');
|
||||
}
|
||||
};
|
||||
|
||||
const savePagesSettings = () => {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
@@ -690,86 +616,6 @@ export function OpenFlareOpsSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>数据库自动清理</CardTitle>
|
||||
<CardDescription>
|
||||
每天凌晨 3 点物化 ClickHouse 表 TTL;访问日志至少保留 90
|
||||
天,其它观测数据至少保留 30 天。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={savingSection === 'database-auto'}
|
||||
onClick={saveDatabaseAutoCleanup}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<ToggleRow
|
||||
label='启用每日自动清理'
|
||||
checked={fields.database_auto_cleanup_enabled}
|
||||
onChange={(value) =>
|
||||
updateField('database_auto_cleanup_enabled', value)
|
||||
}
|
||||
/>
|
||||
<div className='space-y-1.5'>
|
||||
<FieldInput
|
||||
label='期望保留天数'
|
||||
value={fields.database_auto_cleanup_retention_days}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
updateField('database_auto_cleanup_retention_days', value)
|
||||
}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
小于表 TTL 时自动按下限执行:访问日志 90 天,其它观测数据 30
|
||||
天。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>手动数据清理</CardTitle>
|
||||
<CardDescription>
|
||||
按表 TTL 清理;输入天数不能小于对应表
|
||||
TTL,留空时将删除该类数据的全部历史记录。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{cleanupTargets.map((item) => (
|
||||
<div
|
||||
key={item.target}
|
||||
className='flex items-start justify-between gap-3 rounded-lg border border-dashed p-3'
|
||||
>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{item.label}</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setCleanupTarget({ target: item.target, label: item.label })
|
||||
}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<UptimeKumaSiteSelectModal
|
||||
open={uptimeKumaModalOpen}
|
||||
selectedSites={
|
||||
@@ -782,56 +628,6 @@ export function OpenFlareOpsSettings() {
|
||||
updateField('uptime_kuma_selected_sites', sites.join(','))
|
||||
}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={cleanupTarget !== null}
|
||||
onOpenChange={(open) => !open && setCleanupTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理{cleanupTarget?.label}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
输入保留天数后会按该表 TTL 物化过期数据;小于表 TTL
|
||||
的天数会被拒绝。留空则删除全部历史记录,操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<FieldInput
|
||||
label='保留天数'
|
||||
value={cleanupRetentionDays}
|
||||
type='number'
|
||||
onChange={setCleanupRetentionDays}
|
||||
placeholder='留空则全部删除'
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (!cleanupTarget) return;
|
||||
const trimmed = cleanupRetentionDays.trim();
|
||||
if (trimmed !== '') {
|
||||
const retentionDays = Number.parseInt(trimmed, 10);
|
||||
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||
toast.error('保留天数至少为 1 天');
|
||||
return;
|
||||
}
|
||||
cleanupMutation.mutate({
|
||||
target: cleanupTarget.target,
|
||||
retention_days: retentionDays,
|
||||
});
|
||||
return;
|
||||
}
|
||||
cleanupMutation.mutate({ target: cleanupTarget.target });
|
||||
}}
|
||||
>
|
||||
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -8,6 +8,13 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,12 +26,17 @@ import {
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
Database,
|
||||
Info,
|
||||
Layers,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
|
||||
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';
|
||||
@@ -68,6 +80,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',
|
||||
},
|
||||
of_log_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 = {
|
||||
@@ -192,9 +216,38 @@ export function TaskManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [logDbStatus, setLogDbStatus] = useState<LogDatabaseStatus | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 日志库状态用于「切换日志数据库」卡片与 target 下拉;获取失败不阻塞任务列表。
|
||||
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) {
|
||||
@@ -344,6 +397,45 @@ export function TaskManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.type === 'of_log_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'
|
||||
@@ -436,70 +528,104 @@ export function TaskManager() {
|
||||
}
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{targetTask.params.map((param) => (
|
||||
<div key={param.name} className='grid gap-2'>
|
||||
<Label
|
||||
htmlFor={`param-${param.name}`}
|
||||
className='flex items-center gap-1'
|
||||
>
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className='text-destructive font-bold'>*</span>
|
||||
)}
|
||||
</Label>
|
||||
{param.type === 'text' ? (
|
||||
<Textarea
|
||||
id={`param-${param.name}`}
|
||||
placeholder={param.placeholder}
|
||||
className='text-xs min-h-[80px]'
|
||||
value={paramValues[param.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<div className='flex items-center gap-2 pt-1 h-9'>
|
||||
<Switch
|
||||
{targetTask.params.map((param) => {
|
||||
const isSwitchTarget =
|
||||
param.name === 'target' &&
|
||||
getSelectedTaskMeta()?.type === 'of_log_db_switch';
|
||||
return (
|
||||
<div key={param.name} className='grid gap-2'>
|
||||
<Label
|
||||
htmlFor={`param-${param.name}`}
|
||||
className='flex items-center gap-1'
|
||||
>
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className='text-destructive font-bold'>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{param.type === 'text' ? (
|
||||
<Textarea
|
||||
id={`param-${param.name}`}
|
||||
checked={paramValues[param.name] === 'true'}
|
||||
onCheckedChange={(checked) =>
|
||||
placeholder={param.placeholder}
|
||||
className='text-xs min-h-[80px]'
|
||||
value={paramValues[param.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: checked ? 'true' : 'false',
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{paramValues[param.name] === 'true'
|
||||
? '开启'
|
||||
: '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={param.placeholder}
|
||||
className='text-xs'
|
||||
value={paramValues[param.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
) : param.type === 'boolean' ? (
|
||||
<div className='flex items-center gap-2 pt-1 h-9'>
|
||||
<Switch
|
||||
id={`param-${param.name}`}
|
||||
checked={paramValues[param.name] === 'true'}
|
||||
onCheckedChange={(checked) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: checked ? 'true' : 'false',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{paramValues[param.name] === 'true'
|
||||
? '开启'
|
||||
: '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : isSwitchTarget &&
|
||||
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}`}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={param.placeholder}
|
||||
className='text-xs'
|
||||
value={paramValues[param.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { KeyRound, ShieldAlert, X } from 'lucide-react';
|
||||
import { Database, KeyRound, Save, ShieldAlert, X } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -28,6 +32,24 @@ import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { TemplatesManager } from './templates';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const LOG_RETENTION_FIELDS = [
|
||||
{
|
||||
key: 'log_retention_days_postgres',
|
||||
label: 'PostgreSQL',
|
||||
description: '访问日志与可观测指标统一保留天数',
|
||||
},
|
||||
{
|
||||
key: 'log_retention_days_sqlite',
|
||||
label: 'SQLite',
|
||||
description: 'SQLite 日志保留天数',
|
||||
},
|
||||
{
|
||||
key: 'log_retention_days_clickhouse',
|
||||
label: 'ClickHouse',
|
||||
description: 'ClickHouse 日志保留天数',
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface OperationTabProps {
|
||||
configs: Record<string, SystemConfig>;
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
|
||||
@@ -44,6 +66,65 @@ export function OperationTab({
|
||||
queryFn: () => services.adminSystemConfig.listUploadTypes(),
|
||||
});
|
||||
|
||||
const businessConfigsQuery = useQuery({
|
||||
queryKey: ['admin', 'system-configs', 'business'],
|
||||
queryFn: () => services.adminSystemConfig.listSystemConfigs('business'),
|
||||
});
|
||||
|
||||
const businessConfigs = useMemo(() => {
|
||||
return (businessConfigsQuery.data ?? []).reduce<
|
||||
Record<string, SystemConfig>
|
||||
>((acc, config) => {
|
||||
acc[config.key] = config;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [businessConfigsQuery.data]);
|
||||
|
||||
const [retentionValues, setRetentionValues] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!businessConfigsQuery.data) return;
|
||||
setRetentionValues((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
LOG_RETENTION_FIELDS.forEach((field) => {
|
||||
const config = businessConfigs[field.key];
|
||||
next[field.key] = config?.value || prev[field.key] || '90';
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [businessConfigsQuery.data, businessConfigs]);
|
||||
|
||||
const updateRetentionMutation = useMutation({
|
||||
mutationFn: async (values: Record<string, string>) => {
|
||||
for (const field of LOG_RETENTION_FIELDS) {
|
||||
const raw = (values[field.key] ?? '').trim();
|
||||
const num = Number(raw);
|
||||
if (!raw || !Number.isInteger(num) || num < 1) {
|
||||
throw new Error(`${field.label}必须为大于等于 1 的整数`);
|
||||
}
|
||||
const config = businessConfigs[field.key];
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${field.key}`);
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(field.key, {
|
||||
value: String(num),
|
||||
description: config.description,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('日志保留时间已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '更新日志保留时间失败');
|
||||
},
|
||||
});
|
||||
|
||||
const updateWhitelistMutation = useMutation({
|
||||
mutationFn: async (newValue: string) => {
|
||||
const config = configs['file_access_whitelist'];
|
||||
@@ -208,6 +289,77 @@ export function OperationTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 日志保留时间设置 */}
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<Database className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
日志保留时间
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置各日志数据库的日志保留天数,切换日志数据库后自动按对应配置清理过期日志。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-3'>
|
||||
{LOG_RETENTION_FIELDS.map((field) => (
|
||||
<div key={field.key} className='grid gap-2'>
|
||||
<Label htmlFor={field.key}>{field.label}</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
id={field.key}
|
||||
type='number'
|
||||
min={1}
|
||||
className='text-xs'
|
||||
value={retentionValues[field.key] ?? ''}
|
||||
disabled={
|
||||
updateRetentionMutation.isPending ||
|
||||
businessConfigsQuery.isPending
|
||||
}
|
||||
onChange={(e) =>
|
||||
setRetentionValues((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className='text-xs text-muted-foreground whitespace-nowrap'>
|
||||
天
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-4 flex justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={() => updateRetentionMutation.mutate(retentionValues)}
|
||||
disabled={
|
||||
updateRetentionMutation.isPending ||
|
||||
businessConfigsQuery.isPending
|
||||
}
|
||||
>
|
||||
{updateRetentionMutation.isPending ? (
|
||||
<Spinner className='size-3' />
|
||||
) : (
|
||||
<Save className='size-3' />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 通知模板管理 */}
|
||||
<TemplatesManager />
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -405,6 +405,22 @@ 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[];
|
||||
/** ClickHouse 运行指标(仅主库为 ClickHouse 时) */
|
||||
clickhouse?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统状态信息
|
||||
*/
|
||||
|
||||
@@ -158,6 +158,7 @@ export type {
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
SystemStatus,
|
||||
LogDatabaseStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
CreateScheduleRequest,
|
||||
@@ -260,6 +261,5 @@ export type {
|
||||
AccessLogOverview,
|
||||
OptionItem,
|
||||
GeoIPLookupResult,
|
||||
DatabaseCleanupResult,
|
||||
OpenFlarePublicStatus,
|
||||
} from './openflare';
|
||||
|
||||
@@ -104,9 +104,6 @@ export type {
|
||||
FoldedAccessLogList,
|
||||
OptionItem,
|
||||
GeoIPLookupResult,
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
DatabaseCleanupTarget,
|
||||
OpenFlarePublicStatus,
|
||||
OriginDetail,
|
||||
OriginItem,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { OpenFlareBaseService } from './base.service';
|
||||
import type {
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
GeoIPLookupResult,
|
||||
OptionBatchPayload,
|
||||
OptionItem,
|
||||
@@ -26,10 +24,4 @@ export class OptionService extends OpenFlareBaseService {
|
||||
static lookupGeoIP(provider: string, ip: string): Promise<GeoIPLookupResult> {
|
||||
return this.post<GeoIPLookupResult>('/geoip/lookup', { provider, ip });
|
||||
}
|
||||
|
||||
static cleanupDatabase(
|
||||
payload: DatabaseCleanupPayload,
|
||||
): Promise<DatabaseCleanupResult> {
|
||||
return this.post<DatabaseCleanupResult>('/database/cleanup', payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,27 +866,6 @@ export interface GeoIPLookupResult {
|
||||
longitude?: number | null;
|
||||
}
|
||||
|
||||
export type DatabaseCleanupTarget =
|
||||
| 'node_access_logs'
|
||||
| 'node_metric_snapshots'
|
||||
| 'node_edge_health'
|
||||
| 'node_obs_frps'
|
||||
| 'node_obs_frpc';
|
||||
|
||||
export interface DatabaseCleanupPayload {
|
||||
target: DatabaseCleanupTarget;
|
||||
retention_days?: number;
|
||||
}
|
||||
|
||||
export interface DatabaseCleanupResult {
|
||||
target: DatabaseCleanupTarget;
|
||||
target_label: string;
|
||||
deleted_count: number;
|
||||
delete_all: boolean;
|
||||
retention_days?: number;
|
||||
cutoff?: string;
|
||||
}
|
||||
|
||||
export interface OpenFlarePublicStatus {
|
||||
version: string;
|
||||
start_time: number;
|
||||
|
||||
Reference in New Issue
Block a user