mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add log cleanup controls
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" /> 商城线路展示
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user