feat: add log cleanup controls

This commit is contained in:
JetSprow
2026-04-30 17:10:53 +10:00
parent 042c5b34ab
commit 1e194aabdb
18 changed files with 598 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ import {
formatAuditTargetLabel,
formatAuditTargetType,
} from "@/lib/audit-display";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import { formatDate } from "@/lib/utils";
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
@@ -33,6 +34,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
@@ -61,6 +63,17 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
{formatAuditMessage(log.message)}
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<LogDeleteButton
id={log.id}
target="AUDIT_LOGS"
title="删除这条审计日志?"
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。"
successMessage="审计日志已删除"
/>
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>

View File

@@ -37,6 +37,9 @@ export default async function AdminSettingsPage() {
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
trafficSyncEnabled: config.trafficSyncEnabled,
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
logCleanupEnabled: config.logCleanupEnabled,
logRetentionDays: config.logRetentionDays,
logCleanupLastRunAt: config.logCleanupLastRunAt,
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
networkInsightsEnabled: config.networkInsightsEnabled,
subscriptionRiskEnabled: config.subscriptionRiskEnabled,

View File

@@ -2,7 +2,9 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -16,10 +18,12 @@ import {
} from "@/actions/admin/settings";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
import { formatDate } from "@/lib/utils";
import {
booleanAppSettingLabels,
type BooleanAppSettingField,
} from "@/lib/domain-labels";
import type { LogCleanupTarget } from "@/services/log-cleanup";
interface AppConfig {
siteName: string;
@@ -36,6 +40,9 @@ interface AppConfig {
reminderDispatchIntervalMinutes: number;
trafficSyncEnabled: boolean;
trafficSyncIntervalSeconds: number;
logCleanupEnabled: boolean;
logRetentionDays: number;
logCleanupLastRunAt: Date | string | null;
networkRecommendationsEnabled: boolean;
networkInsightsEnabled: boolean;
subscriptionRiskEnabled: boolean;
@@ -76,6 +83,16 @@ interface CouponOption {
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
const logCleanupTargetOptions = [
{ value: "ALL", label: "全部日志" },
{ value: "AUDIT_LOGS", label: "审计日志" },
{ value: "TASK_RUNS", label: "任务记录" },
{ value: "TRAFFIC_LOGS", label: "流量日志" },
{ value: "NODE_LATENCY_LOGS", label: "节点延迟日志" },
{ value: "SUBSCRIPTION_ACCESS_LOGS", label: "风控访问日志" },
{ value: "SUBSCRIPTION_RISK_EVENTS", label: "风控事件" },
] satisfies Array<{ value: LogCleanupTarget; label: string }>;
type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels = booleanAppSettingLabels;
@@ -87,6 +104,7 @@ function initialToggleValues(config: AppConfig): ToggleValues {
requireInviteCode: config.requireInviteCode,
autoReminderDispatchEnabled: config.autoReminderDispatchEnabled,
trafficSyncEnabled: config.trafficSyncEnabled,
logCleanupEnabled: config.logCleanupEnabled,
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
networkInsightsEnabled: config.networkInsightsEnabled,
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
@@ -102,6 +120,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [cleaningLogs, setCleaningLogs] = useState(false);
const [cleanupTarget, setCleanupTarget] = useState<LogCleanupTarget>("ALL");
const [manualCleanupDays, setManualCleanupDays] = useState(config.logRetentionDays);
const [riskSettingsOpen, setRiskSettingsOpen] = useState(false);
const [toggleValues, setToggleValues] = useState<ToggleValues>(() => initialToggleValues(config));
const [pendingToggles, setPendingToggles] = useState<Partial<Record<BooleanSettingField, boolean>>>({});
@@ -225,6 +246,24 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
}
}
async function handleCleanupExpiredLogs() {
if (cleaningLogs) return;
const cutoffDays = Math.min(3650, Math.max(1, Math.trunc(Number(manualCleanupDays) || config.logRetentionDays || 30)));
setCleaningLogs(true);
try {
const result = await cleanupExpiredAdminLogs({ target: cleanupTarget, cutoffDays });
if (!result.ok) {
throw new Error(result.error);
}
setManualCleanupDays(cutoffDays);
router.refresh();
toast.success(`日志清理完成:${result.message}`);
} finally {
setCleaningLogs(false);
}
}
function clearPasswordField(form: HTMLFormElement) {
const password = form.elements.namedItem("smtpPassword");
if (password instanceof HTMLInputElement) {
@@ -331,6 +370,84 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Trash2 className="size-4 text-primary" />
</div>
<p className="text-xs leading-5 text-muted-foreground">
30
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="logCleanupEnabled"></Label>
{renderImmediateToggle("logCleanupEnabled", { id: "logCleanupEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="logRetentionDays"></Label>
<Input
id="logRetentionDays"
name="logRetentionDays"
type="number"
min={1}
max={3650}
step={1}
defaultValue={config.logRetentionDays}
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex min-h-10 items-center rounded-lg border border-border bg-background px-3 text-sm text-muted-foreground">
{config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : "尚未执行"}
</div>
</div>
</div>
<div className="border-t border-border/60 pt-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_auto] lg:items-end">
<div className="space-y-2">
<Label htmlFor="manualCleanupTarget"></Label>
<select
id="manualCleanupTarget"
value={cleanupTarget}
onChange={(event) => setCleanupTarget(event.target.value as LogCleanupTarget)}
className={selectClassName}
>
{logCleanupTargetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="manualCleanupDays"></Label>
<Input
id="manualCleanupDays"
type="number"
min={1}
max={3650}
step={1}
value={manualCleanupDays}
onChange={(event) => setManualCleanupDays(Number(event.target.value))}
/>
</div>
<ConfirmActionButton
title="清理过期日志?"
description={`将删除 ${manualCleanupDays || config.logRetentionDays || 30} 天前的${logCleanupTargetOptions.find((option) => option.value === cleanupTarget)?.label ?? "日志"}。删除后无法恢复。`}
confirmLabel="开始清理"
errorMessage="清理日志失败"
disabled={saving || hasPendingToggle || cleaningLogs}
onConfirm={handleCleanupExpiredLogs}
>
<Trash2 className="size-4" />
{cleaningLogs ? "清理中..." : "清理过期日志"}
</ConfirmActionButton>
</div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">
</p>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<RadioTower className="size-4 text-primary" /> 线

View File

@@ -1,4 +1,5 @@
import { ChevronDown, Globe2, MapPin, ScrollText } from "lucide-react";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import { StatusBadge } from "@/components/shared/status-badge";
import { WORLD_COUNTRY_PATHS } from "@/components/shared/world-map-paths";
import { formatDate } from "@/lib/utils";
@@ -132,6 +133,13 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<StatusBadge tone={log.source === "节点 Xray 日志" ? "info" : "neutral"}>{log.source}</StatusBadge>
<StatusBadge tone={log.allowed ? "success" : "warning"}>{log.allowed ? "放行" : "拦截"}</StatusBadge>
<LogDeleteButton
id={log.id}
target="SUBSCRIPTION_ACCESS_LOGS"
title="删除这条风控访问日志?"
description="删除后无法恢复,只会移除这条访问或节点连接证据,不会删除用户、订阅或风控事件。"
successMessage="风控访问日志已删除"
/>
</div>
</div>
<p className="mt-2 break-words text-xs leading-5 text-muted-foreground">{log.location}</p>

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client";
import { ChevronDown } from "lucide-react";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
@@ -251,6 +252,22 @@ function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
finalAction={event.finalAction}
/>
</div>
<div className="border-t border-border/60 pt-4">
<LogDeleteButton
id={event.id}
target="SUBSCRIPTION_RISK_EVENTS"
label="删除事件"
title="删除这条风控事件?"
description={
event.userRestrictionActive
? "删除后无法恢复。此事件当前仍有用户端限制标记,请先确认是否需要在处理动作里解除限制。"
: "删除后无法恢复,只会移除这条风控事件,不会删除用户、订阅或访问日志。"
}
successMessage="风控事件已删除"
className="w-full justify-center text-destructive hover:text-destructive"
size="sm"
/>
</div>
</aside>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { batchRetryTaskRuns, retryTaskRun } from "@/actions/admin/tasks";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { DataTableShell } from "@/components/admin/data-table-shell";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import {
DataTable,
DataTableBody,
@@ -77,7 +78,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
{task.errorMessage || "—"}
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<div className="flex justify-end gap-2">
{task.retryable && task.status === "FAILED" && (
<form
action={async () => {
@@ -88,6 +89,13 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
<PendingSubmitButton size="sm" variant="outline" pendingLabel="重试中..."></PendingSubmitButton>
</form>
)}
<LogDeleteButton
id={task.id}
target="TASK_RUNS"
title="删除这条任务记录?"
description="删除后无法恢复,只会移除任务执行记录,不会撤销任务已经产生的业务结果。"
successMessage="任务记录已删除"
/>
</div>
</DataTableCell>
</DataTableRow>