This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
+247 -173
View File
@@ -1,12 +1,20 @@
"use client"
'use client';
import {useCallback, useEffect, useMemo, useState} from "react"
import {Eye, GitCompare, History, Loader2, Play, RefreshCw, Trash2,} from "lucide-react"
import {toast} from "sonner"
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Eye,
GitCompare,
History,
Loader2,
Play,
RefreshCw,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
AlertDialog,
AlertDialogCancel,
@@ -15,26 +23,33 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
type ConfigDiffResult,
type ConfigPreviewResult,
ConfigVersionService,
type ConfigVersionSummary,
} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import {CleanupDialog} from "./components/cleanup-dialog"
import {DiffDialog} from "./components/diff-dialog"
import {PreviewSheet} from "./components/preview-sheet"
import {VersionSnapshotSheet} from "./components/version-snapshot-sheet"
import { CleanupDialog } from './components/cleanup-dialog';
import { DiffDialog } from './components/diff-dialog';
import { PreviewSheet } from './components/preview-sheet';
import { VersionSnapshotSheet } from './components/version-snapshot-sheet';
function truncateChecksum(checksum: string) {
if (!checksum) return "—"
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum
if (!checksum) return '—';
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
}
function hasConfigDiff(diff: ConfigDiffResult) {
@@ -49,285 +64,323 @@ function hasConfigDiff(diff: ConfigDiffResult) {
diff.waf_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
)
);
}
export default function ConfigVersionsPage() {
const [versions, setVersions] = useState<ConfigVersionSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [versions, setVersions] = useState<ConfigVersionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false)
const [preview, setPreview] = useState<ConfigPreviewResult | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false);
const [preview, setPreview] = useState<ConfigPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [diffOpen, setDiffOpen] = useState(false)
const [diff, setDiff] = useState<ConfigDiffResult | null>(null)
const [diffLoading, setDiffLoading] = useState(false)
const [diffError, setDiffError] = useState<string | null>(null)
const [diffOpen, setDiffOpen] = useState(false);
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
const [diffLoading, setDiffLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [snapshotVersion, setSnapshotVersion] = useState<ConfigVersionSummary | null>(null)
const [snapshotOpen, setSnapshotOpen] = useState(false)
const [snapshotVersion, setSnapshotVersion] =
useState<ConfigVersionSummary | null>(null);
const [snapshotOpen, setSnapshotOpen] = useState(false);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false)
const [activateTarget, setActivateTarget] = useState<ConfigVersionSummary | null>(null)
const [cleanupOpen, setCleanupOpen] = useState(false)
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false);
const [activateTarget, setActivateTarget] =
useState<ConfigVersionSummary | null>(null);
const [cleanupOpen, setCleanupOpen] = useState(false);
const [publishing, setPublishing] = useState(false)
const [activating, setActivating] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [publishing, setPublishing] = useState(false);
const [activating, setActivating] = useState(false);
const [cleaning, setCleaning] = useState(false);
const canPublish = useMemo(
() => Boolean(preview && preview.route_count > 0 && diff && hasConfigDiff(diff)),
() =>
Boolean(
preview && preview.route_count > 0 && diff && hasConfigDiff(diff),
),
[preview, diff],
)
);
const sortedVersions = useMemo(
() =>
[...versions].sort(
(left, right) =>
new Date(right.created_at).getTime() - new Date(left.created_at).getTime(),
new Date(right.created_at).getTime() -
new Date(left.created_at).getTime(),
),
[versions],
)
);
const fetchVersions = useCallback(async () => {
setLoading(true)
setError(null)
setLoading(true);
setError(null);
try {
const data = await ConfigVersionService.list()
setVersions(data)
const data = await ConfigVersionService.list();
setVersions(data);
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败")
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
void fetchVersions()
}, [fetchVersions])
void fetchVersions();
}, [fetchVersions]);
const loadPreviewData = async () => {
setPreviewLoading(true)
setPreviewError(null)
setDiffLoading(true)
setDiffError(null)
setPreviewLoading(true);
setPreviewError(null);
setDiffLoading(true);
setDiffError(null);
try {
const [previewData, diffData] = await Promise.all([
ConfigVersionService.preview(),
ConfigVersionService.diff(),
])
setPreview(previewData)
setDiff(diffData)
]);
setPreview(previewData);
setDiff(diffData);
} catch (err) {
const message = err instanceof Error ? err.message : "加载预览失败"
setPreviewError(message)
setDiffError(message)
const message = err instanceof Error ? err.message : '加载预览失败';
setPreviewError(message);
setDiffError(message);
} finally {
setPreviewLoading(false)
setDiffLoading(false)
setPreviewLoading(false);
setDiffLoading(false);
}
}
};
const handleOpenPreview = async () => {
setPreviewOpen(true)
setPreviewOpen(true);
if (!preview) {
await loadPreviewData()
await loadPreviewData();
}
}
};
const handleOpenDiff = async () => {
setDiffOpen(true)
setDiffOpen(true);
if (!diff) {
setDiffLoading(true)
setDiffError(null)
setDiffLoading(true);
setDiffError(null);
try {
const diffData = await ConfigVersionService.diff()
setDiff(diffData)
const diffData = await ConfigVersionService.diff();
setDiff(diffData);
} catch (err) {
setDiffError(err instanceof Error ? err.message : "加载差异失败")
setDiffError(err instanceof Error ? err.message : '加载差异失败');
} finally {
setDiffLoading(false)
setDiffLoading(false);
}
}
}
};
const handlePublish = async (force = false) => {
setPublishing(true)
setPublishing(true);
try {
const version = await ConfigVersionService.publish(force)
toast.success("发布成功", { description: `版本 ${version.version}` })
setPreviewOpen(false)
setPublishConfirmOpen(false)
setForcePublishConfirmOpen(false)
setPreview(null)
setDiff(null)
await fetchVersions()
const version = await ConfigVersionService.publish(force);
toast.success('发布成功', { description: `版本 ${version.version}` });
setPreviewOpen(false);
setPublishConfirmOpen(false);
setForcePublishConfirmOpen(false);
setPreview(null);
setDiff(null);
await fetchVersions();
} catch (err) {
toast.error("发布失败", {
description: err instanceof Error ? err.message : "未知错误",
})
toast.error('发布失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setPublishing(false)
setPublishing(false);
}
}
};
const handleActivate = async () => {
if (!activateTarget) return
if (!activateTarget) return;
setActivating(true)
setActivating(true);
try {
const version = await ConfigVersionService.activate(activateTarget.id)
toast.success("激活成功", { description: `版本 ${version.version}` })
setActivateTarget(null)
await fetchVersions()
const version = await ConfigVersionService.activate(activateTarget.id);
toast.success('激活成功', { description: `版本 ${version.version}` });
setActivateTarget(null);
await fetchVersions();
} catch (err) {
toast.error("激活失败", {
description: err instanceof Error ? err.message : "未知错误",
})
toast.error('激活失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setActivating(false)
setActivating(false);
}
}
};
const handleCleanup = async (keepCount: number) => {
setCleaning(true)
setCleaning(true);
try {
const result = await ConfigVersionService.cleanup({ keep_count: keepCount })
toast.success("清理完成", {
const result = await ConfigVersionService.cleanup({
keep_count: keepCount,
});
toast.success('清理完成', {
description: `已删除 ${result.deleted_count} 个历史快照`,
})
setCleanupOpen(false)
await fetchVersions()
});
setCleanupOpen(false);
await fetchVersions();
} catch (err) {
toast.error("清理失败", {
description: err instanceof Error ? err.message : "未知错误",
})
toast.error('清理失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setCleaning(false)
setCleaning(false);
}
}
};
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<History className="size-5 text-primary" />
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<History className='size-5 text-primary' />
<div>
<h1 className="text-2xl font-semibold tracking-tight">配置版本</h1>
<p className="text-sm text-muted-foreground">
<h1 className='text-2xl font-semibold tracking-tight'>配置版本</h1>
<p className='text-sm text-muted-foreground'>
查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => void fetchVersions()} disabled={loading}>
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void fetchVersions()}
disabled={loading}
>
<RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/>
刷新
</Button>
<Button variant="outline" size="sm" onClick={() => setCleanupOpen(true)}>
<Trash2 className="size-3.5 mr-1" />
<Button
variant='outline'
size='sm'
onClick={() => setCleanupOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
清理旧版本
</Button>
<Button variant="outline" size="sm" onClick={() => void handleOpenDiff()}>
<GitCompare className="size-3.5 mr-1" />
<Button
variant='outline'
size='sm'
onClick={() => void handleOpenDiff()}
>
<GitCompare className='size-3.5 mr-1' />
查看差异
</Button>
<Button variant="outline" size="sm" onClick={() => setForcePublishConfirmOpen(true)}>
<Button
variant='outline'
size='sm'
onClick={() => setForcePublishConfirmOpen(true)}
>
强制发布
</Button>
<Button size="sm" onClick={() => void handleOpenPreview()}>
<Eye className="size-3.5 mr-1" />
<Button size='sm' onClick={() => void handleOpenPreview()}>
<Eye className='size-3.5 mr-1' />
预览并发布
</Button>
</div>
</div>
{error ? <ErrorInline message={error} onRetry={() => void fetchVersions()} /> : null}
{error ? (
<ErrorInline message={error} onRetry={() => void fetchVersions()} />
) : null}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<div className='border border-dashed shadow-none rounded-lg overflow-hidden bg-background'>
{loading ? (
<LoadingStateWithBorder />
) : sortedVersions.length === 0 ? (
<EmptyStateWithBorder
title="暂无历史版本"
description="当前还没有可查看的发布记录,请先触发一次配置发布。"
title='暂无历史版本'
description='当前还没有可查看的发布记录,请先触发一次配置发布。'
/>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">版本号</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
<TableHead className="text-xs font-semibold">创建人</TableHead>
<TableHead className="text-xs font-semibold">Checksum</TableHead>
<TableHead className="text-xs font-semibold">创建时间</TableHead>
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>版本号</TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead>
<TableHead className='text-xs font-semibold'>创建人</TableHead>
<TableHead className='text-xs font-semibold'>
Checksum
</TableHead>
<TableHead className='text-xs font-semibold'>
创建时间
</TableHead>
<TableHead className='text-xs font-semibold text-right'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedVersions.map((version) => (
<TableRow
key={version.id}
className="border-dashed hover:bg-muted/10 transition-colors"
className='border-dashed hover:bg-muted/10 transition-colors'
>
<TableCell className="font-mono text-xs font-semibold">
<TableCell className='font-mono text-xs font-semibold'>
{version.version}
</TableCell>
<TableCell>
{version.is_active ? (
<Badge
variant="outline"
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
variant='outline'
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
>
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
当前激活
</Badge>
) : (
<Badge variant="outline" className="text-[10px] rounded-full py-0 px-2">
<Badge
variant='outline'
className='text-[10px] rounded-full py-0 px-2'
>
历史版本
</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{version.created_by || "系统"}
<TableCell className='text-xs text-muted-foreground'>
{version.created_by || '系统'}
</TableCell>
<TableCell
className="text-xs font-mono text-muted-foreground"
className='text-xs font-mono text-muted-foreground'
title={version.checksum}
>
{truncateChecksum(version.checksum)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<TableCell className='text-xs text-muted-foreground'>
{formatDateTime(version.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1.5'>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={() => {
setSnapshotVersion(version)
setSnapshotOpen(true)
setSnapshotVersion(version);
setSnapshotOpen(true);
}}
>
<Eye className="size-3 mr-1" />
<Eye className='size-3 mr-1' />
快照
</Button>
{!version.is_active ? (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={() => setActivateTarget(version)}
>
<Play className="size-3 mr-1" />
<Play className='size-3 mr-1' />
激活
</Button>
) : null}
@@ -372,7 +425,10 @@ export default function ConfigVersionsPage() {
loading={cleaning}
/>
<AlertDialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<AlertDialog
open={publishConfirmOpen}
onOpenChange={setPublishConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
@@ -382,14 +438,24 @@ export default function ConfigVersionsPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button onClick={() => void handlePublish(false)} disabled={publishing}>
{publishing ? <Loader2 className="size-4 animate-spin" /> : "确认发布"}
<Button
onClick={() => void handlePublish(false)}
disabled={publishing}
>
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
'确认发布'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={forcePublishConfirmOpen} onOpenChange={setForcePublishConfirmOpen}>
<AlertDialog
open={forcePublishConfirmOpen}
onOpenChange={setForcePublishConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认强制发布</AlertDialogTitle>
@@ -400,11 +466,15 @@ export default function ConfigVersionsPage() {
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button
variant="destructive"
variant='destructive'
onClick={() => void handlePublish(true)}
disabled={publishing}
>
{publishing ? <Loader2 className="size-4 animate-spin" /> : "强制发布"}
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
'强制发布'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
@@ -413,7 +483,7 @@ export default function ConfigVersionsPage() {
<AlertDialog
open={activateTarget !== null}
onOpenChange={(open) => {
if (!open) setActivateTarget(null)
if (!open) setActivateTarget(null);
}}
>
<AlertDialogContent>
@@ -422,17 +492,21 @@ export default function ConfigVersionsPage() {
<AlertDialogDescription>
{activateTarget
? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?`
: ""}
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={activating}>取消</AlertDialogCancel>
<Button onClick={() => void handleActivate()} disabled={activating}>
{activating ? <Loader2 className="size-4 animate-spin" /> : "确认激活"}
{activating ? (
<Loader2 className='size-4 animate-spin' />
) : (
'确认激活'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
);
}