migrate frontend

This commit is contained in:
ryan
2026-06-18 17:43:11 +08:00
parent a9fd0bd128
commit 31b114b0cc
104 changed files with 16735 additions and 874 deletions
@@ -0,0 +1,183 @@
import type {OptionItem} from "@/lib/services/openflare"
export type OpenFlareOpsFields = {
AgentHeartbeatInterval: string
AgentWebsocketUpgradeEnabled: boolean
NodeOfflineThreshold: string
AgentUpdateRepo: string
GeoIPProvider: string
ServerAddress: string
UptimeKumaEnabled: boolean
UptimeKumaUrl: string
UptimeKumaUsername: string
UptimeKumaPassword: string
UptimeKumaMonitorScope: string
UptimeKumaSelectedSites: string
UptimeKumaSyncInterval: string
UptimeKumaInterval: string
UptimeKumaRetry: string
UptimeKumaRetryInterval: string
UptimeKumaTimeout: string
DatabaseAutoCleanupEnabled: boolean
DatabaseAutoCleanupRetentionDays: string
}
export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
AgentHeartbeatInterval: "10000",
AgentWebsocketUpgradeEnabled: true,
NodeOfflineThreshold: "120000",
AgentUpdateRepo: "Rain-kl/OpenFlare",
GeoIPProvider: "ipinfo",
ServerAddress: "",
UptimeKumaEnabled: false,
UptimeKumaUrl: "",
UptimeKumaUsername: "",
UptimeKumaPassword: "",
UptimeKumaMonitorScope: "all",
UptimeKumaSelectedSites: "",
UptimeKumaSyncInterval: "5",
UptimeKumaInterval: "60",
UptimeKumaRetry: "0",
UptimeKumaRetryInterval: "60",
UptimeKumaTimeout: "48",
DatabaseAutoCleanupEnabled: false,
DatabaseAutoCleanupRetentionDays: "30",
}
export const INSTALLER_SCRIPT_URL =
"https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh"
export function optionsToMap(options: OptionItem[]) {
return options.reduce<Record<string, string>>((accumulator, option) => {
accumulator[option.key] = option.value
return accumulator
}, {})
}
function toBoolean(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback
return value === "true"
}
export function mapOptionsToOpsFields(
optionMap: Record<string, string>,
serverAddress = "",
): OpenFlareOpsFields {
return {
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? "10000",
AgentWebsocketUpgradeEnabled: toBoolean(optionMap.AgentWebsocketUpgradeEnabled, true),
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? "120000",
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? "Rain-kl/OpenFlare",
GeoIPProvider: optionMap.GeoIPProvider ?? "ipinfo",
ServerAddress: optionMap.ServerAddress || serverAddress,
UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false),
UptimeKumaUrl: optionMap.UptimeKumaUrl ?? "",
UptimeKumaUsername: optionMap.UptimeKumaUsername ?? "",
UptimeKumaPassword: "",
UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? "all",
UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? "",
UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? "5",
UptimeKumaInterval: optionMap.UptimeKumaInterval ?? "60",
UptimeKumaRetry: optionMap.UptimeKumaRetry ?? "0",
UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? "60",
UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? "48",
DatabaseAutoCleanupEnabled: toBoolean(optionMap.DatabaseAutoCleanupEnabled, false),
DatabaseAutoCleanupRetentionDays: optionMap.DatabaseAutoCleanupRetentionDays ?? "30",
}
}
export function formatDurationLabel(value: string) {
const milliseconds = Number.parseInt(value, 10)
if (Number.isNaN(milliseconds)) return value
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`
return `${milliseconds / 1000} 秒`
}
export function normalizeServerUrl(value: string) {
return value.trim().replace(/\/+$/, "")
}
export function getBrowserOrigin() {
if (typeof window === "undefined") return ""
return normalizeServerUrl(window.location.origin)
}
export function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) {
return [
`curl -fsSL ${INSTALLER_SCRIPT_URL} | bash -s -- \\`,
` --server-url ${normalizeServerUrl(serverUrl)} \\`,
` --discovery-token ${discoveryToken}`,
].join("\n")
}
export function validateAgentFields(fields: OpenFlareOpsFields) {
const heartbeat = Number.parseInt(fields.AgentHeartbeatInterval, 10)
const offline = Number.parseInt(fields.NodeOfflineThreshold, 10)
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
throw new Error("心跳间隔不能小于 5000 毫秒。")
}
if (Number.isNaN(offline) || offline < 10000) {
throw new Error("离线阈值不能小于 10000 毫秒。")
}
}
export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
const syncInt = Number.parseInt(fields.UptimeKumaSyncInterval, 10)
const interval = Number.parseInt(fields.UptimeKumaInterval, 10)
const retry = Number.parseInt(fields.UptimeKumaRetry, 10)
const retryInt = Number.parseInt(fields.UptimeKumaRetryInterval, 10)
const timeout = Number.parseInt(fields.UptimeKumaTimeout, 10)
if (fields.UptimeKumaEnabled) {
if (!fields.UptimeKumaUrl.trim()) throw new Error("请输入 Uptime Kuma 地址。")
if (!fields.UptimeKumaUsername.trim()) throw new Error("请输入 Uptime Kuma 用户名。")
}
if (Number.isNaN(syncInt) || syncInt <= 0) throw new Error("同步间隔必须为正整数。")
if (Number.isNaN(interval) || interval <= 0) throw new Error("心跳间隔必须为正整数。")
if (Number.isNaN(retry) || retry < 0) throw new Error("重试次数必须为非负整数。")
if (Number.isNaN(retryInt) || retryInt <= 0) throw new Error("心跳重试间隔必须为正整数。")
if (Number.isNaN(timeout) || timeout <= 0) throw new Error("请求超时必须为正整数。")
}
export function validateDatabaseAutoCleanup(fields: OpenFlareOpsFields) {
const retentionDays = Number.parseInt(fields.DatabaseAutoCleanupRetentionDays, 10)
if (Number.isNaN(retentionDays) || retentionDays < 1) {
throw new Error("自动清理保留天数至少为 1 天。")
}
}
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
validateAgentFields(fields)
return [
{ key: "AgentHeartbeatInterval", value: fields.AgentHeartbeatInterval },
{ key: "AgentWebsocketUpgradeEnabled", value: String(fields.AgentWebsocketUpgradeEnabled) },
{ key: "NodeOfflineThreshold", value: fields.NodeOfflineThreshold },
{ key: "AgentUpdateRepo", value: fields.AgentUpdateRepo.trim() },
{ key: "GeoIPProvider", value: fields.GeoIPProvider },
]
}
export function uptimeKumaOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
validateUptimeKumaFields(fields)
return [
{ key: "UptimeKumaEnabled", value: String(fields.UptimeKumaEnabled) },
{ key: "UptimeKumaUrl", value: fields.UptimeKumaUrl.trim() },
{ key: "UptimeKumaUsername", value: fields.UptimeKumaUsername.trim() },
{ key: "UptimeKumaPassword", value: fields.UptimeKumaPassword },
{ key: "UptimeKumaMonitorScope", value: fields.UptimeKumaMonitorScope },
{ key: "UptimeKumaSelectedSites", value: fields.UptimeKumaSelectedSites },
{ key: "UptimeKumaSyncInterval", value: fields.UptimeKumaSyncInterval },
{ key: "UptimeKumaInterval", value: fields.UptimeKumaInterval },
{ key: "UptimeKumaRetry", value: fields.UptimeKumaRetry },
{ key: "UptimeKumaRetryInterval", value: fields.UptimeKumaRetryInterval },
{ key: "UptimeKumaTimeout", value: fields.UptimeKumaTimeout },
]
}
export function databaseAutoCleanupEntries(fields: OpenFlareOpsFields): OptionItem[] {
validateDatabaseAutoCleanup(fields)
return [
{ key: "DatabaseAutoCleanupEnabled", value: String(fields.DatabaseAutoCleanupEnabled) },
{ key: "DatabaseAutoCleanupRetentionDays", value: fields.DatabaseAutoCleanupRetentionDays },
]
}
@@ -0,0 +1,757 @@
"use client"
import Link from "next/link"
import {useEffect, useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Copy, ExternalLink, Loader2, RefreshCw, 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 {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
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, StatusService, UpdateService, UptimeKumaService,} from "@/lib/services/openflare"
import {
agentOptionEntries,
buildDiscoveryCommand,
databaseAutoCleanupEntries,
defaultOpenFlareOpsFields,
formatDurationLabel,
getBrowserOrigin,
mapOptionsToOpsFields,
type OpenFlareOpsFields,
optionsToMap,
uptimeKumaOptionEntries,
} from "./openflare-ops-utils"
import {UptimeKumaSiteSelectModal} from "./uptimekuma-site-modal"
const optionsQueryKey = ["openflare", "options"] as const
const cleanupTargets: Array<{
target: DatabaseCleanupTarget
label: string
description: string
}> = [
{
target: "node_access_logs",
label: "访问日志",
description: "清理 node_access_logs,影响访问明细与 IP 汇总。",
},
{
target: "node_metric_snapshots",
label: "性能快照",
description: "清理 node_metric_snapshots,影响节点资源趋势。",
},
{
target: "node_request_reports",
label: "请求聚合",
description: "清理 node_request_reports,影响请求量与错误量统计。",
},
]
async function copyText(value: string) {
await navigator.clipboard.writeText(value)
}
export function OpenFlareOpsSettings() {
const queryClient = useQueryClient()
const [fields, setFields] = useState<OpenFlareOpsFields>(defaultOpenFlareOpsFields)
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,
queryFn: () => OptionService.list(),
})
const statusQuery = useQuery({
queryKey: ["openflare", "public-status"],
queryFn: () => StatusService.getPublicStatus(),
})
const bootstrapQuery = useQuery({
queryKey: ["openflare", "bootstrap-token"],
queryFn: () => NodeService.getBootstrapToken(),
})
const releaseQuery = useQuery({
queryKey: ["openflare", "latest-release"],
queryFn: () => UpdateService.getLatestRelease("stable"),
})
useEffect(() => {
if (!optionsQuery.data) return
const optionMap = optionsToMap(optionsQuery.data)
const serverAddress =
optionMap.ServerAddress ||
statusQuery.data?.server_address ||
getBrowserOrigin()
setFields(mapOptionsToOpsFields(optionMap, serverAddress))
}, [optionsQuery.data, statusQuery.data?.server_address])
const geoIPMutation = useMutation({
mutationFn: () => OptionService.lookupGeoIP(fields.GeoIPProvider, geoIPTestIP.trim()),
})
const saveMutation = useMutation({
mutationFn: async ({
section,
entries,
}: {
section: string
entries: Array<{ key: string; value: string }>
}) => {
setSavingSection(section)
await OptionService.updateBatch(entries)
},
onSuccess: async () => {
toast.success("OpenFlare 运维设置已保存")
await queryClient.invalidateQueries({ queryKey: optionsQueryKey })
setSavingSection(null)
},
onError: (error) => {
setSavingSection(null)
toast.error(error instanceof Error ? error.message : "保存失败")
},
})
const rotateTokenMutation = useMutation({
mutationFn: () => NodeService.rotateBootstrapToken(),
onSuccess: async (data) => {
toast.success("Discovery Token 已重新生成")
await queryClient.invalidateQueries({ queryKey: ["openflare", "bootstrap-token"] })
if (data.discovery_token) {
try {
await copyText(data.discovery_token)
} catch {
// ignore clipboard errors
}
}
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Token 轮换失败")
},
})
const syncUptimeKumaMutation = useMutation({
mutationFn: () => UptimeKumaService.sync(),
onSuccess: () => toast.success("Uptime Kuma 同步任务已执行"),
onError: (error) => 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.ServerAddress || !discoveryToken) return ""
return buildDiscoveryCommand(fields.ServerAddress, discoveryToken)
}, [discoveryToken, fields.ServerAddress])
const updateField = <K extends keyof OpenFlareOpsFields>(
key: K,
value: OpenFlareOpsFields[K],
) => {
setFields((previous) => ({ ...previous, [key]: value }))
}
const saveAgentSettings = () => {
try {
saveMutation.mutate({
section: "agent",
entries: agentOptionEntries(fields),
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "参数校验失败")
}
}
const saveUptimeKumaSettings = () => {
try {
saveMutation.mutate({
section: "uptimekuma",
entries: uptimeKumaOptionEntries(fields),
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "参数校验失败")
}
}
const saveDatabaseAutoCleanup = () => {
try {
saveMutation.mutate({
section: "database-auto",
entries: databaseAutoCleanupEntries(fields),
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "参数校验失败")
}
}
if (optionsQuery.isLoading) {
return <LoadingStateWithBorder icon={Server} description="加载 OpenFlare 运维设置..." />
}
if (optionsQuery.isError) {
return (
<ErrorInline
message={
optionsQuery.error instanceof Error ? optionsQuery.error.message : "加载失败"
}
onRetry={() => void optionsQuery.refetch()}
/>
)
}
return (
<div className="space-y-6">
<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">Agent 运行参数</CardTitle>
<CardDescription>心跳间隔与离线阈值会在下个心跳周期同步到节点。</CardDescription>
</div>
<Button size="sm" disabled={savingSection === "agent"} onClick={saveAgentSettings}>
{savingSection === "agent" ? (
<Loader2 className="size-4 animate-spin mr-1" />
) : (
<Save className="size-3.5 mr-1" />
)}
保存
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<FieldInput
label={`心跳间隔 (${formatDurationLabel(fields.AgentHeartbeatInterval)})`}
value={fields.AgentHeartbeatInterval}
type="number"
onChange={(value) => updateField("AgentHeartbeatInterval", value)}
/>
<FieldInput
label={`离线阈值 (${formatDurationLabel(fields.NodeOfflineThreshold)})`}
value={fields.NodeOfflineThreshold}
type="number"
onChange={(value) => updateField("NodeOfflineThreshold", value)}
/>
</div>
<ToggleRow
label="开启 WS 连接升级"
description="HTTP 心跳成功后尝试升级为 WebSocket,配置发布可即时通知。"
checked={fields.AgentWebsocketUpgradeEnabled}
onChange={(value) => updateField("AgentWebsocketUpgradeEnabled", value)}
/>
<FieldInput
label="Agent 更新仓库"
value={fields.AgentUpdateRepo}
placeholder="Rain-kl/OpenFlare"
onChange={(value) => updateField("AgentUpdateRepo", value)}
/>
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">IP 归属解析</CardTitle>
<CardDescription>
控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用 MaxMind mmdb。
</CardDescription>
</div>
<Button size="sm" disabled={savingSection === "agent"} onClick={saveAgentSettings}>
{savingSection === "agent" ? (
<Loader2 className="size-4 animate-spin mr-1" />
) : (
<Save className="size-3.5 mr-1" />
)}
保存
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label>归属方式</Label>
<Select
value={fields.GeoIPProvider}
onValueChange={(value) => updateField("GeoIPProvider", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">关闭</SelectItem>
<SelectItem value="mmdb">MaxMind mmdb</SelectItem>
<SelectItem value="ip-api">ip-api.com</SelectItem>
<SelectItem value="geojs">geojs.io</SelectItem>
<SelectItem value="ipinfo">ipinfo.io</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end">
<FieldInput
label="测试 IP"
value={geoIPTestIP}
onChange={setGeoIPTestIP}
placeholder="8.8.8.8"
/>
<Button
type="button"
variant="outline"
disabled={geoIPMutation.isPending}
onClick={() => geoIPMutation.mutate()}
>
{geoIPMutation.isPending ? "查询中..." : "查询归属"}
</Button>
</div>
{geoIPMutation.data ? (
<div className="grid gap-2 text-sm sm:grid-cols-2">
<InfoCell label="国家/地区" value={geoIPMutation.data.name || "—"} />
<InfoCell label="ISO Code" value={geoIPMutation.data.iso_code || "—"} />
</div>
) : null}
</CardContent>
</Card>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">Discovery Token 与部署</CardTitle>
<CardDescription>
新节点首次接入使用 Discovery Token;轮换请前往节点管理或使用下方操作。
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/openflare/nodes">
<ExternalLink className="size-3.5 mr-1" />
节点管理
</Link>
</Button>
<Button
variant="outline"
size="sm"
disabled={rotateTokenMutation.isPending}
onClick={() => rotateTokenMutation.mutate()}
>
<RotateCw className="size-3.5 mr-1" />
轮换 Token
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<FieldInput
label="Server URL"
value={fields.ServerAddress}
onChange={(value) => updateField("ServerAddress", value)}
placeholder="https://yourdomain.com"
/>
<div className="rounded-lg border border-dashed p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Discovery Token(只读)
</p>
<p className="mt-2 break-all text-sm font-mono">
{bootstrapQuery.isLoading ? "加载中..." : discoveryToken || "未生成"}
</p>
</div>
<div className="space-y-1.5">
<Label>一键部署命令</Label>
<Textarea
readOnly
value={discoveryCommand || "请先填写可访问的 Server URL 并获取 Token。"}
className="min-h-24 font-mono text-xs"
/>
{discoveryCommand ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void copyText(discoveryCommand).then(() => toast.success("命令已复制"))}
>
<Copy className="size-3.5 mr-1" />
复制命令
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">Uptime Kuma 集成</CardTitle>
<CardDescription>将反代站点差分同步至 Uptime Kuma 监控实例。</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!fields.UptimeKumaEnabled || syncUptimeKumaMutation.isPending}
onClick={() => syncUptimeKumaMutation.mutate()}
>
<RefreshCw className="size-3.5 mr-1" />
立即同步
</Button>
<Button
size="sm"
disabled={savingSection === "uptimekuma"}
onClick={saveUptimeKumaSettings}
>
保存
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ToggleRow
label="开启 Uptime Kuma"
checked={fields.UptimeKumaEnabled}
onChange={(value) => updateField("UptimeKumaEnabled", value)}
/>
{fields.UptimeKumaEnabled ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<FieldInput
label="Uptime Kuma 地址"
value={fields.UptimeKumaUrl}
onChange={(value) => updateField("UptimeKumaUrl", value)}
placeholder="http://localhost:3001"
/>
<FieldInput
label="用户名"
value={fields.UptimeKumaUsername}
onChange={(value) => updateField("UptimeKumaUsername", value)}
/>
<FieldInput
label="密码"
value={fields.UptimeKumaPassword}
type="password"
onChange={(value) => updateField("UptimeKumaPassword", value)}
placeholder="留空表示不更新"
/>
<FieldInput
label="同步间隔 (分钟)"
value={fields.UptimeKumaSyncInterval}
type="number"
onChange={(value) => updateField("UptimeKumaSyncInterval", value)}
/>
<div className="space-y-1.5">
<Label>监控范围</Label>
<Select
value={fields.UptimeKumaMonitorScope}
onValueChange={(value) => updateField("UptimeKumaMonitorScope", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部站点</SelectItem>
<SelectItem value="selected">选择站点</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{fields.UptimeKumaMonitorScope === "selected" ? (
<div className="rounded-lg border border-dashed p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">已选站点</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setUptimeKumaModalOpen(true)}
>
选择监控站点
</Button>
</div>
<p className="mt-2 break-all text-xs text-muted-foreground">
{fields.UptimeKumaSelectedSites
? fields.UptimeKumaSelectedSites.split(",").join(", ")
: "未选择任何站点"}
</p>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<FieldInput
label="检测频率 (秒)"
value={fields.UptimeKumaInterval}
type="number"
onChange={(value) => updateField("UptimeKumaInterval", value)}
/>
<FieldInput
label="重试次数"
value={fields.UptimeKumaRetry}
type="number"
onChange={(value) => updateField("UptimeKumaRetry", value)}
/>
<FieldInput
label="重试间隔 (秒)"
value={fields.UptimeKumaRetryInterval}
type="number"
onChange={(value) => updateField("UptimeKumaRetryInterval", value)}
/>
<FieldInput
label="请求超时 (秒)"
value={fields.UptimeKumaTimeout}
type="number"
onChange={(value) => updateField("UptimeKumaTimeout", value)}
/>
</div>
</>
) : null}
</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 点清理超出保留期的观测数据。</CardDescription>
</div>
<Button
size="sm"
disabled={savingSection === "database-auto"}
onClick={saveDatabaseAutoCleanup}
>
保存
</Button>
</CardHeader>
<CardContent className="space-y-4">
<ToggleRow
label="启用每日自动清理"
checked={fields.DatabaseAutoCleanupEnabled}
onChange={(value) => updateField("DatabaseAutoCleanupEnabled", value)}
/>
<FieldInput
label="保留天数"
value={fields.DatabaseAutoCleanupRetentionDays}
type="number"
onChange={(value) => updateField("DatabaseAutoCleanupRetentionDays", value)}
/>
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base">手动数据清理</CardTitle>
<CardDescription>保留天数留空时将删除该类数据的全部历史记录。</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>
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base">版本信息</CardTitle>
<CardDescription>
可通过 UpdateService 对接顶栏升级入口;完整升级流程可复用 Wavelet admin/updater。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
<InfoCell
label="最新 Release"
value={releaseQuery.data?.tag_name ?? "—"}
/>
<div className="rounded-lg border border-dashed px-3 py-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新状态</p>
<div className="mt-2">
{releaseQuery.data?.has_update ? (
<Badge variant="secondary">有新版本</Badge>
) : (
<Badge variant="outline">已是最新</Badge>
)}
</div>
</div>
<InfoCell
label="启动时间"
value={
statusQuery.data?.start_time
? new Date(statusQuery.data.start_time * 1000).toLocaleString()
: "—"
}
/>
</CardContent>
</Card>
<UptimeKumaSiteSelectModal
open={uptimeKumaModalOpen}
selectedSites={
fields.UptimeKumaSelectedSites
? fields.UptimeKumaSelectedSites.split(",")
: []
}
onOpenChange={setUptimeKumaModalOpen}
onSave={(sites) => updateField("UptimeKumaSelectedSites", sites.join(","))}
/>
<AlertDialog
open={cleanupTarget !== null}
onOpenChange={(open) => !open && setCleanupTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理{cleanupTarget?.label}</AlertDialogTitle>
<AlertDialogDescription>
输入保留天数后仅删除超出范围的数据;留空则删除全部历史记录,操作不可恢复。
</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>
)
}
function FieldInput({
label,
value,
onChange,
type = "text",
placeholder,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
placeholder?: string
}) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
<Input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-9 text-xs"
/>
</div>
)
}
function ToggleRow({
label,
description,
checked,
onChange,
}: {
label: string
description?: string
checked: boolean
onChange: (value: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-dashed px-3 py-2">
<div>
<Label className="text-xs">{label}</Label>
{description ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">{description}</p>
) : null}
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
)
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-dashed px-3 py-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</p>
<p className="mt-2 text-sm font-medium break-all">{value}</p>
</div>
)
}
@@ -0,0 +1,181 @@
"use client"
import {useEffect, useMemo, useState} from "react"
import {useQuery} from "@tanstack/react-query"
import {Button} from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {ProxyRouteService} from "@/lib/services/openflare"
type UptimeKumaSiteSelectModalProps = {
open: boolean
selectedSites: string[]
onOpenChange: (open: boolean) => void
onSave: (sites: string[]) => void
}
export function UptimeKumaSiteSelectModal({
open,
selectedSites,
onOpenChange,
onSave,
}: UptimeKumaSiteSelectModalProps) {
const [searchTerm, setSearchTerm] = useState("")
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set())
const routesQuery = useQuery({
queryKey: ["openflare", "proxy-routes"],
queryFn: () => ProxyRouteService.list(),
enabled: open,
})
useEffect(() => {
if (!open) return
setTempSelected(new Set(selectedSites.map((site) => site.trim()).filter(Boolean)))
setSearchTerm("")
}, [open, selectedSites])
const filteredRoutes = useMemo(() => {
const routes = routesQuery.data ?? []
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) return routes
return routes.filter(
(route) =>
route.site_name.toLowerCase().includes(keyword) ||
route.primary_domain.toLowerCase().includes(keyword),
)
}, [routesQuery.data, searchTerm])
const toggleSite = (siteName: string) => {
setTempSelected((previous) => {
const next = new Set(previous)
if (next.has(siteName)) next.delete(siteName)
else next.add(siteName)
return next
})
}
const handleSelectAll = () => {
setTempSelected((previous) => {
const next = new Set(previous)
filteredRoutes.forEach((route) => next.add(route.site_name))
return next
})
}
const handleDeselectAll = () => {
setTempSelected((previous) => {
const next = new Set(previous)
filteredRoutes.forEach((route) => next.delete(route.site_name))
return next
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>选择监控站点</DialogTitle>
<DialogDescription>
选择要同步到 Uptime Kuma 的反代站点,支持按站点名称和域名搜索。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>搜索站点</Label>
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按名称或域名搜索..."
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
全选过滤项
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleDeselectAll}>
清空过滤项
</Button>
</div>
<div className="max-h-72 overflow-y-auto rounded-lg border">
{routesQuery.isLoading ? (
<p className="p-4 text-sm text-muted-foreground">加载站点列表...</p>
) : routesQuery.isError ? (
<p className="p-4 text-sm text-destructive">
{routesQuery.error instanceof Error
? routesQuery.error.message
: "加载站点失败"}
</p>
) : filteredRoutes.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">无匹配的站点</p>
) : (
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground">
<tr>
<th className="w-12 px-3 py-2">选择</th>
<th className="px-3 py-2">站点名称</th>
<th className="px-3 py-2">主域名</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredRoutes.map((route) => {
const checked = tempSelected.has(route.site_name)
return (
<tr
key={route.id}
className="cursor-pointer hover:bg-muted/30"
onClick={() => toggleSite(route.site_name)}
>
<td className="px-3 py-2" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSite(route.site_name)}
className="size-4 rounded border-input"
/>
</td>
<td className="px-3 py-2 font-medium">{route.site_name}</td>
<td className="px-3 py-2 text-muted-foreground">{route.primary_domain}</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
<p className="text-right text-xs text-muted-foreground">
已选择 {tempSelected.size} 个监控站点
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
onClick={() => {
onSave(Array.from(tempSelected))
onOpenChange(false)
}}
>
保存选择
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -49,6 +49,11 @@ const SystemStatusManager = dynamic(
{ loading: () => tabFallback },
)
const OpenFlareOpsSettings = dynamic(
() => import("./components/openflare-ops").then((mod) => mod.OpenFlareOpsSettings),
{ loading: () => tabFallback },
)
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config
@@ -118,6 +123,9 @@ export function AdminSettingsPageClient() {
<TabsTrigger value="info" className="px-0 pb-2 text-xs font-semibold">
系统信息
</TabsTrigger>
<TabsTrigger value="openflare-ops" className="px-0 pb-2 text-xs font-semibold">
OpenFlare 运维
</TabsTrigger>
</TabsList>
<TabsContent value="security" className="focus-visible:outline-none">
@@ -138,6 +146,9 @@ export function AdminSettingsPageClient() {
<TabsContent value="info" className="focus-visible:outline-none">
<InfoTab />
</TabsContent>
<TabsContent value="openflare-ops" className="focus-visible:outline-none">
<OpenFlareOpsSettings />
</TabsContent>
</Tabs>
</motion.div>
)
@@ -0,0 +1,124 @@
"use client"
import {Search} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import type {AccessLogTab, SearchDraft} from "./access-log-utils"
import {PAGE_SIZE_OPTIONS} from "./access-log-utils"
interface AccessLogFiltersProps {
tab: AccessLogTab
draft: SearchDraft
pageSize: number
onDraftChange: (draft: SearchDraft) => void
onPageSizeChange: (pageSize: number) => void
onSearch: () => void
onReset: () => void
}
export function AccessLogFilters({
tab,
draft,
pageSize,
onDraftChange,
onPageSizeChange,
onSearch,
onReset,
}: AccessLogFiltersProps) {
const showPath = tab === "list" || tab === "folds"
return (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">节点 ID</p>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input
value={draft.nodeId}
onChange={(e) =>
onDraftChange({ ...draft, nodeId: e.target.value })
}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按 node_id 搜索"
className="pl-8 h-9 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">来源 IP</p>
<Input
value={draft.remoteAddr}
onChange={(e) =>
onDraftChange({ ...draft, remoteAddr: e.target.value })
}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按 IP 搜索"
className="h-9 text-xs"
/>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">访问域名</p>
<Input
value={draft.host}
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按域名搜索"
className="h-9 text-xs"
/>
</div>
{showPath ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">请求路径</p>
<Input
value={draft.path}
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按路径搜索"
className="h-9 text-xs"
/>
</div>
) : null}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5 w-full sm:max-w-[180px]">
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number.parseInt(value, 10))}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={onSearch}>
筛选
</Button>
<Button variant="outline" size="sm" onClick={onReset}>
清空
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
export type AccessLogTab = "list" | "folds" | "ip-summary" | "ip-trend"
export type SearchDraft = {
nodeId: string
remoteAddr: string
host: string
path: string
}
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]
export const DETAIL_SORT_OPTIONS = [
{ value: "logged_at:desc", label: "时间从新到旧" },
{ value: "logged_at:asc", label: "时间从旧到新" },
{ value: "status_code:desc", label: "状态码从高到低" },
{ value: "status_code:asc", label: "状态码从低到高" },
{ value: "remote_addr:asc", label: "IP 正序" },
{ value: "remote_addr:desc", label: "IP 倒序" },
]
export const FOLD_SORT_OPTIONS = [
{ value: "bucket_started_at:desc", label: "时间桶从新到旧" },
{ value: "bucket_started_at:asc", label: "时间桶从旧到新" },
{ value: "request_count:desc", label: "访问次数从高到低" },
{ value: "request_count:asc", label: "访问次数从低到高" },
]
export const IP_SORT_OPTIONS = [
{ value: "total_requests:desc", label: "总访问次数从高到低" },
{ value: "total_requests:asc", label: "总访问次数从低到高" },
{ value: "recent_requests:desc", label: "3 小时访问次数从高到低" },
{ value: "last_seen_at:desc", label: "最后访问时间从新到旧" },
]
export function parseSortValue(value: string) {
const [sortBy = "logged_at", sortOrder = "desc"] = value.split(":")
return {
sortBy,
sortOrder: sortOrder === "asc" ? ("asc" as const) : ("desc" as const),
}
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat("zh-CN", {
notation: value >= 10000 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value)
}
@@ -0,0 +1,115 @@
"use client"
import {useState} from "react"
import {Loader2} from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
const CLEANUP_PRESETS = [3, 7, 30]
interface CleanupDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (retentionDays: number) => void
loading: boolean
}
export function CleanupDialog({
open,
onOpenChange,
onConfirm,
loading,
}: CleanupDialogProps) {
const [mode, setMode] = useState<string>("7")
const [customDays, setCustomDays] = useState("14")
const [error, setError] = useState<string | null>(null)
const handleConfirm = () => {
const retentionDays =
mode === "custom"
? Number.parseInt(customDays, 10)
: Number.parseInt(mode, 10)
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
setError("保留天数必须大于 0")
return
}
setError(null)
onConfirm(retentionDays)
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理访问日志</AlertDialogTitle>
<AlertDialogDescription>
删除早于指定保留天数的访问日志记录,操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>保留策略</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLEANUP_PRESETS.map((days) => (
<SelectItem key={days} value={String(days)}>
保留最近 {days} 天
</SelectItem>
))}
<SelectItem value="custom">自定义天数</SelectItem>
</SelectContent>
</Select>
</div>
{mode === "custom" ? (
<div className="space-y-1.5">
<Label htmlFor="customDays">自定义保留天数</Label>
<Input
id="customDays"
type="number"
min={1}
value={customDays}
onChange={(e) => setCustomDays(e.target.value)}
disabled={loading}
/>
</div>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
{loading ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
清理中...
</>
) : (
"确认清理"
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,7 +1,566 @@
import {ScrollText} from 'lucide-react';
"use client"
import {PlaceholderPage} from '../components/placeholder-page';
import {useCallback, useEffect, useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
import {RefreshCw, ScrollText, 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 {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent,} from "@/components/ui/chart"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {AccessLogService} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
import {AccessLogFilters} from "./components/access-log-filters"
import {
type AccessLogTab,
DETAIL_SORT_OPTIONS,
FOLD_SORT_OPTIONS,
formatCompactNumber,
IP_SORT_OPTIONS,
parseSortValue,
type SearchDraft,
} from "./components/access-log-utils"
import {CleanupDialog} from "./components/cleanup-dialog"
const trendChartConfig = {
requests: { label: "请求数", color: "hsl(var(--primary))" },
} satisfies ChartConfig
const emptyDraft: SearchDraft = {
nodeId: "",
remoteAddr: "",
host: "",
path: "",
}
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number
hasMore: boolean
loading: boolean
onPrev: () => void
onNext: () => void
}) {
return (
<div className="flex items-center justify-between px-4 py-3 border-t border-dashed">
<p className="text-xs text-muted-foreground">当前第 {page + 1} 页</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={loading || page <= 0} onClick={onPrev}>
上一页
</Button>
<Button variant="outline" size="sm" disabled={loading || !hasMore} onClick={onNext}>
下一页
</Button>
</div>
</div>
)
}
export default function AccessLogsPage() {
return <PlaceholderPage title="访问日志" icon={ScrollText} />;
}
const queryClient = useQueryClient()
const [tab, setTab] = useState<AccessLogTab>("list")
const [draft, setDraft] = useState<SearchDraft>(emptyDraft)
const [filters, setFilters] = useState<SearchDraft>(emptyDraft)
const [pageSize, setPageSize] = useState(20)
const [page, setPage] = useState(0)
const [detailSort, setDetailSort] = useState("logged_at:desc")
const [foldSort, setFoldSort] = useState("bucket_started_at:desc")
const [ipSort, setIpSort] = useState("total_requests:desc")
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3)
const [trendIp, setTrendIp] = useState("")
const [appliedTrendIp, setAppliedTrendIp] = useState("")
const [cleanupOpen, setCleanupOpen] = useState(false)
const detailSortState = parseSortValue(detailSort)
const foldSortState = parseSortValue(foldSort)
const ipSortState = parseSortValue(ipSort)
const listQuery = useQuery({
queryKey: ["openflare", "access-logs", "list", filters, page, pageSize, detailSort],
queryFn: () =>
AccessLogService.list({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: page,
page_size: pageSize,
sort_by: detailSortState.sortBy,
sort_order: detailSortState.sortOrder,
}),
enabled: tab === "list",
})
const foldsQuery = useQuery({
queryKey: ["openflare", "access-logs", "folds", filters, page, pageSize, foldSort, foldMinutes],
queryFn: () =>
AccessLogService.listFolds({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: page,
page_size: pageSize,
sort_by: foldSortState.sortBy,
sort_order: foldSortState.sortOrder,
fold_minutes: foldMinutes,
}),
enabled: tab === "folds",
})
const ipSummaryQuery = useQuery({
queryKey: ["openflare", "access-logs", "ip-summary", filters, page, pageSize, ipSort],
queryFn: () =>
AccessLogService.listIPSummaries({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
p: page,
page_size: pageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
enabled: tab === "ip-summary",
})
const ipTrendQuery = useQuery({
queryKey: ["openflare", "access-logs", "ip-trend", filters, appliedTrendIp],
queryFn: () =>
AccessLogService.getIPTrend({
node_id: filters.nodeId || undefined,
remote_addr: appliedTrendIp,
host: filters.host || undefined,
hours: 24,
bucket_minutes: 30,
}),
enabled: tab === "ip-trend" && appliedTrendIp !== "",
})
const cleanupMutation = useMutation({
mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }),
onSuccess: async (result) => {
toast.success(`已清理 ${result.deleted_count} 条日志`)
setCleanupOpen(false)
await queryClient.invalidateQueries({ queryKey: ["openflare", "access-logs"] })
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "清理失败")
},
})
const activeSummary = useMemo(() => {
if (tab === "list" && listQuery.data) {
return {
totalRecord: listQuery.data.total_record,
totalIp: listQuery.data.total_ip,
}
}
if (tab === "folds" && foldsQuery.data) {
return {
totalRecord: foldsQuery.data.total_record,
totalIp: foldsQuery.data.total_ip,
}
}
if (tab === "ip-summary" && ipSummaryQuery.data) {
return {
totalRecord: 0,
totalIp: ipSummaryQuery.data.total_ip,
}
}
return { totalRecord: 0, totalIp: 0 }
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data])
const trendChartData = useMemo(() => {
return (ipTrendQuery.data?.points ?? []).map((point) => ({
label: formatDateTime(point.bucket_started_at).slice(5),
requests: point.request_count,
}))
}, [ipTrendQuery.data?.points])
const handleSearch = useCallback(() => {
setFilters({
nodeId: draft.nodeId.trim(),
remoteAddr: draft.remoteAddr.trim(),
host: draft.host.trim(),
path: draft.path.trim(),
})
setPage(0)
}, [draft])
const handleReset = () => {
setDraft(emptyDraft)
setFilters(emptyDraft)
setPage(0)
setTrendIp("")
setAppliedTrendIp("")
}
useEffect(() => {
setPage(0)
}, [tab, pageSize])
const refreshActive = () => {
if (tab === "list") void listQuery.refetch()
if (tab === "folds") void foldsQuery.refetch()
if (tab === "ip-summary") void ipSummaryQuery.refetch()
if (tab === "ip-trend") void ipTrendQuery.refetch()
}
const isFetching =
listQuery.isFetching ||
foldsQuery.isFetching ||
ipSummaryQuery.isFetching ||
ipTrendQuery.isFetching
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">
<ScrollText className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">访问日志</h1>
<p className="text-sm text-muted-foreground">
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={refreshActive} disabled={isFetching}>
<RefreshCw className={`size-3.5 mr-1 ${isFetching ? "animate-spin" : ""}`} />
刷新
</Button>
<Button variant="destructive" size="sm" onClick={() => setCleanupOpen(true)}>
<Trash2 className="size-3.5 mr-1" />
清理日志
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{[
{ label: "访问记录", value: formatCompactNumber(activeSummary.totalRecord) },
{ label: "来源 IP", value: formatCompactNumber(activeSummary.totalIp) },
{
label: "当前视图",
value:
tab === "list"
? "明细日志"
: tab === "folds"
? "时间折叠"
: tab === "ip-summary"
? "IP 汇总"
: "IP 趋势",
},
].map((item) => (
<div key={item.label} className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-1 text-lg font-semibold">{item.value}</p>
</div>
))}
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as AccessLogTab)}>
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4">
<TabsTrigger value="list">明细列表</TabsTrigger>
<TabsTrigger value="folds">时间折叠</TabsTrigger>
<TabsTrigger value="ip-summary">IP 汇总</TabsTrigger>
<TabsTrigger value="ip-trend">IP 趋势</TabsTrigger>
</TabsList>
<div className="mt-4 rounded-lg border border-dashed bg-background p-4">
<AccessLogFilters
tab={tab}
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
/>
</div>
<TabsContent value="list" className="mt-4">
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
<div className="flex items-center justify-between px-4 py-3 border-b border-dashed">
<p className="text-sm font-medium">明细日志</p>
<Select value={detailSort} onValueChange={setDetailSort}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{listQuery.isError ? (
<div className="p-4">
<ErrorInline
message={listQuery.error instanceof Error ? listQuery.error.message : "加载失败"}
onRetry={() => void listQuery.refetch()}
/>
</div>
) : listQuery.isLoading ? (
<LoadingStateWithBorder />
) : (listQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title="暂无访问日志" />
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs">时间</TableHead>
<TableHead className="text-xs">节点</TableHead>
<TableHead className="text-xs">IP</TableHead>
<TableHead className="text-xs">域名</TableHead>
<TableHead className="text-xs">路径</TableHead>
<TableHead className="text-xs">状态码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id} className="border-dashed">
<TableCell className="text-xs">{formatDateTime(item.logged_at)}</TableCell>
<TableCell className="text-xs">{item.node_name || item.node_id}</TableCell>
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
<TableCell className="text-xs">{item.host}</TableCell>
<TableCell className="text-xs max-w-48 truncate">{item.path}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{item.status_code}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={listQuery.data?.has_more ?? false}
loading={listQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value="folds" className="mt-4">
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">时间折叠</p>
<Select
value={String(foldMinutes)}
onValueChange={(value) => setFoldMinutes(Number(value) as 3 | 5)}
>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3 分钟桶</SelectItem>
<SelectItem value="5">5 分钟桶</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={foldSort} onValueChange={setFoldSort}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FOLD_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{foldsQuery.isLoading ? (
<LoadingStateWithBorder />
) : (foldsQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title="暂无折叠数据" />
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs">时间桶</TableHead>
<TableHead className="text-xs">请求数</TableHead>
<TableHead className="text-xs">独立 IP</TableHead>
<TableHead className="text-xs">独立域名</TableHead>
<TableHead className="text-xs">2xx</TableHead>
<TableHead className="text-xs">4xx</TableHead>
<TableHead className="text-xs">5xx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(foldsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.bucket_started_at} className="border-dashed">
<TableCell className="text-xs">
{formatDateTime(item.bucket_started_at)}
</TableCell>
<TableCell className="text-xs">{item.request_count}</TableCell>
<TableCell className="text-xs">{item.unique_ip_count}</TableCell>
<TableCell className="text-xs">{item.unique_host_count}</TableCell>
<TableCell className="text-xs">{item.success_count}</TableCell>
<TableCell className="text-xs">{item.client_error_count}</TableCell>
<TableCell className="text-xs">{item.server_error_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={foldsQuery.data?.has_more ?? false}
loading={foldsQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value="ip-summary" className="mt-4">
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
<div className="flex items-center justify-between px-4 py-3 border-b border-dashed">
<p className="text-sm font-medium">IP 汇总</p>
<Select value={ipSort} onValueChange={setIpSort}>
<SelectTrigger className="h-8 w-52 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{ipSummaryQuery.isLoading ? (
<LoadingStateWithBorder />
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs">IP</TableHead>
<TableHead className="text-xs">总请求数</TableHead>
<TableHead className="text-xs">近 3 小时</TableHead>
<TableHead className="text-xs">最后访问</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(ipSummaryQuery.data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className="border-dashed">
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
<TableCell className="text-xs">{item.total_requests}</TableCell>
<TableCell className="text-xs">{item.recent_requests}</TableCell>
<TableCell className="text-xs">
{formatDateTime(item.last_seen_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={ipSummaryQuery.data?.has_more ?? false}
loading={ipSummaryQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value="ip-trend" className="mt-4 space-y-4">
<div className="rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="space-y-1.5 flex-1">
<p className="text-xs font-medium text-muted-foreground">趋势 IP</p>
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs"
value={trendIp}
onChange={(e) => setTrendIp(e.target.value)}
placeholder="输入要分析趋势的 IP 地址"
/>
</div>
<Button
size="sm"
onClick={() => setAppliedTrendIp(trendIp.trim())}
disabled={!trendIp.trim()}
>
查看趋势
</Button>
</div>
<div className="rounded-lg border border-dashed overflow-hidden bg-background p-4">
{!appliedTrendIp ? (
<EmptyStateWithBorder description="请输入 IP 地址后查看 24 小时访问趋势。" />
) : ipTrendQuery.isLoading ? (
<LoadingStateWithBorder />
) : ipTrendQuery.isError ? (
<ErrorInline
message={
ipTrendQuery.error instanceof Error
? ipTrendQuery.error.message
: "加载失败"
}
onRetry={() => void ipTrendQuery.refetch()}
/>
) : trendChartData.length === 0 ? (
<EmptyStateWithBorder description="该 IP 在选定时间范围内没有访问记录。" />
) : (
<div className="space-y-3">
<p className="text-sm font-medium">
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
</p>
<ChartContainer config={trendChartConfig} className="h-64 w-full">
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} fontSize={10} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={40} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="requests"
stroke="var(--color-requests)"
fill="var(--color-requests)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
</div>
)}
</div>
</TabsContent>
</Tabs>
<CleanupDialog
open={cleanupOpen}
onOpenChange={setCleanupOpen}
onConfirm={(days) => cleanupMutation.mutate(days)}
loading={cleanupMutation.isPending}
/>
</div>
)
}
@@ -0,0 +1,324 @@
"use client"
import {useCallback, useEffect, useMemo, useState} from "react"
import Link from "next/link"
import {useSearchParams} from "next/navigation"
import {ClipboardList, Eye, RefreshCw, Search} from "lucide-react"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {type ApplyLogItem, ApplyLogService,} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
import {LogDetailSheet} from "./components/log-detail-sheet"
const PAGE_SIZE_OPTIONS = [20, 50, 100]
function truncateHash(value: string) {
if (!value) return "—"
return value.length > 12 ? `${value.slice(0, 12)}...` : value
}
function getResultBadge(result: string) {
if (result === "success") {
return (
<Badge
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" />
成功
</Badge>
)
}
if (result === "warning") {
return (
<Badge
variant="outline"
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
警告
</Badge>
)
}
return (
<Badge
variant="outline"
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
>
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
失败
</Badge>
)
}
export function ApplyLogsPageClient() {
const searchParams = useSearchParams()
const initialNodeId = searchParams.get("node_id")?.trim() ?? ""
const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId)
const [nodeFilter, setNodeFilter] = useState(initialNodeId)
const [pageNo, setPageNo] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [rows, setRows] = useState<ApplyLogItem[]>([])
const [current, setCurrent] = useState(1)
const [total, setTotal] = useState(0)
const [totalPage, setTotalPage] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const summary = useMemo(() => {
const nodeIds = new Set(rows.map((item) => item.node_id))
return [
{ label: "总记录数", value: total },
{ label: "当前页", value: current },
{ label: "总页数", value: totalPage },
{ label: "当前页节点数", value: nodeIds.size },
]
}, [rows, total, current, totalPage])
const fetchLogs = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await ApplyLogService.list({
node_id: nodeFilter || undefined,
pageNo,
pageSize,
})
setRows(data.rows)
setCurrent(data.current)
setTotal(data.total)
setTotalPage(data.totalPage)
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败")
} finally {
setLoading(false)
}
}, [nodeFilter, pageNo, pageSize])
useEffect(() => {
void fetchLogs()
}, [fetchLogs])
useEffect(() => {
const nodeId = searchParams.get("node_id")?.trim()
if (!nodeId) return
setNodeFilterInput(nodeId)
setNodeFilter(nodeId)
setPageNo(1)
}, [searchParams])
const handleSearch = () => {
setPageNo(1)
setNodeFilter(nodeFilterInput.trim())
}
const handleReset = () => {
setNodeFilterInput("")
setNodeFilter("")
setPageNo(1)
}
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">
<ClipboardList className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">应用日志</h1>
<p className="text-sm text-muted-foreground">
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={loading}>
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
刷新
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/openflare/nodes">返回节点</Link>
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-lg border border-dashed px-4 py-3 bg-background"
>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-1 text-lg font-semibold">{item.value}</p>
</div>
))}
</div>
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Node ID</p>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input
value={nodeFilterInput}
onChange={(e) => setNodeFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch()
}}
placeholder="输入 node_id 过滤应用日志"
className="pl-8 h-9 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number.parseInt(value, 10))
setPageNo(1)
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={handleSearch}>
筛选
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
清空
</Button>
</div>
</div>
{error ? <ErrorInline message={error} onRetry={() => void fetchLogs()} /> : null}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
{loading ? (
<LoadingStateWithBorder />
) : rows.length === 0 ? (
<EmptyStateWithBorder
title="暂无应用日志"
description="当前筛选条件下没有可展示的应用记录。"
/>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">Node ID</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">消息</TableHead>
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((log) => (
<TableRow
key={log.id}
className="border-dashed hover:bg-muted/10 transition-colors align-top"
>
<TableCell className="text-xs font-medium">{log.node_id}</TableCell>
<TableCell className="text-xs text-muted-foreground">{log.version}</TableCell>
<TableCell>{getResultBadge(log.result)}</TableCell>
<TableCell
className="text-xs font-mono text-muted-foreground"
title={log.checksum}
>
{truncateHash(log.checksum)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(log.created_at)}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-56">
<div className="line-clamp-2 break-words">{log.message || "—"}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
setSelectedLog(log)
setDetailOpen(true)
}}
>
<Eye className="size-3 mr-1" />
详情
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{!loading && rows.length > 0 ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={current <= 1}
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((prev) =>
totalPage > 0 ? Math.min(totalPage, prev + 1) : prev,
)
}
>
下一页
</Button>
</div>
</div>
) : null}
<LogDetailSheet
log={selectedLog}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</div>
)
}
@@ -1,324 +1,28 @@
"use client"
import {Suspense} from 'react';
import {useCallback, useEffect, useMemo, useState} from "react"
import Link from "next/link"
import {useSearchParams} from "next/navigation"
import {ClipboardList, Eye, RefreshCw, Search} from "lucide-react"
import {Skeleton} from '@/components/ui/skeleton';
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {type ApplyLogItem, ApplyLogService,} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
import {ApplyLogsPageClient} from './page-client';
import {LogDetailSheet} from "./components/log-detail-sheet"
const PAGE_SIZE_OPTIONS = [20, 50, 100]
function truncateHash(value: string) {
if (!value) return "—"
return value.length > 12 ? `${value.slice(0, 12)}...` : value
}
function getResultBadge(result: string) {
if (result === "success") {
return (
<Badge
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" />
成功
</Badge>
)
}
if (result === "warning") {
return (
<Badge
variant="outline"
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
警告
</Badge>
)
}
function ApplyLogsPageFallback() {
return (
<Badge
variant="outline"
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
>
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
失败
</Badge>
)
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-40" />
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<Skeleton className="h-96 w-full" />
</div>
);
}
export default function ApplyLogsPage() {
const searchParams = useSearchParams()
const initialNodeId = searchParams.get("node_id")?.trim() ?? ""
const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId)
const [nodeFilter, setNodeFilter] = useState(initialNodeId)
const [pageNo, setPageNo] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [rows, setRows] = useState<ApplyLogItem[]>([])
const [current, setCurrent] = useState(1)
const [total, setTotal] = useState(0)
const [totalPage, setTotalPage] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const summary = useMemo(() => {
const nodeIds = new Set(rows.map((item) => item.node_id))
return [
{ label: "总记录数", value: total },
{ label: "当前页", value: current },
{ label: "总页数", value: totalPage },
{ label: "当前页节点数", value: nodeIds.size },
]
}, [rows, total, current, totalPage])
const fetchLogs = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await ApplyLogService.list({
node_id: nodeFilter || undefined,
pageNo,
pageSize,
})
setRows(data.rows)
setCurrent(data.current)
setTotal(data.total)
setTotalPage(data.totalPage)
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败")
} finally {
setLoading(false)
}
}, [nodeFilter, pageNo, pageSize])
useEffect(() => {
void fetchLogs()
}, [fetchLogs])
useEffect(() => {
const nodeId = searchParams.get("node_id")?.trim()
if (!nodeId) return
setNodeFilterInput(nodeId)
setNodeFilter(nodeId)
setPageNo(1)
}, [searchParams])
const handleSearch = () => {
setPageNo(1)
setNodeFilter(nodeFilterInput.trim())
}
const handleReset = () => {
setNodeFilterInput("")
setNodeFilter("")
setPageNo(1)
}
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">
<ClipboardList className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">应用日志</h1>
<p className="text-sm text-muted-foreground">
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={loading}>
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
刷新
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/openflare/nodes">返回节点</Link>
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-lg border border-dashed px-4 py-3 bg-background"
>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-1 text-lg font-semibold">{item.value}</p>
</div>
))}
</div>
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Node ID</p>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input
value={nodeFilterInput}
onChange={(e) => setNodeFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch()
}}
placeholder="输入 node_id 过滤应用日志"
className="pl-8 h-9 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number.parseInt(value, 10))
setPageNo(1)
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={handleSearch}>
筛选
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
清空
</Button>
</div>
</div>
{error ? <ErrorInline message={error} onRetry={() => void fetchLogs()} /> : null}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
{loading ? (
<LoadingStateWithBorder />
) : rows.length === 0 ? (
<EmptyStateWithBorder
title="暂无应用日志"
description="当前筛选条件下没有可展示的应用记录。"
/>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">Node ID</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">消息</TableHead>
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((log) => (
<TableRow
key={log.id}
className="border-dashed hover:bg-muted/10 transition-colors align-top"
>
<TableCell className="text-xs font-medium">{log.node_id}</TableCell>
<TableCell className="text-xs text-muted-foreground">{log.version}</TableCell>
<TableCell>{getResultBadge(log.result)}</TableCell>
<TableCell
className="text-xs font-mono text-muted-foreground"
title={log.checksum}
>
{truncateHash(log.checksum)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(log.created_at)}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-56">
<div className="line-clamp-2 break-words">{log.message || "—"}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
setSelectedLog(log)
setDetailOpen(true)
}}
>
<Eye className="size-3 mr-1" />
详情
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{!loading && rows.length > 0 ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={current <= 1}
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((prev) =>
totalPage > 0 ? Math.min(totalPage, prev + 1) : prev,
)
}
>
下一页
</Button>
</div>
</div>
) : null}
<LogDetailSheet
log={selectedLog}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</div>
)
}
<Suspense fallback={<ApplyLogsPageFallback />}>
<ApplyLogsPageClient />
</Suspense>
);
}
@@ -0,0 +1,129 @@
'use client';
import { Area, AreaChart, CartesianGrid, Line, XAxis, YAxis } from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import type { CapacityTrendPoint } from '@/lib/services/openflare';
import { formatPercent, formatTrendHour } from './dashboard-utils';
const chartConfig = {
cpu: {
label: '平均 CPU',
color: 'hsl(var(--chart-2))',
},
memory: {
label: '平均内存',
color: 'hsl(var(--chart-3))',
},
} satisfies ChartConfig;
export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] }) {
const data = points.map((point) => ({
hour: formatTrendHour(point.bucket_started_at),
cpu: point.average_cpu_usage_percent,
memory: point.average_memory_usage_percent,
}));
if (data.length === 0) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时容量趋势</CardTitle>
<CardDescription className="text-xs">
按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
暂无趋势数据
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时容量趋势</CardTitle>
<CardDescription className="text-xs">
按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。
</CardDescription>
</CardHeader>
<CardContent className="pl-2 pr-4">
<div className="h-[280px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="capacityCpuFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cpu)" stopOpacity={0.2} />
<stop offset="95%" stopColor="var(--color-cpu)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
domain={[0, 100]}
tickFormatter={(value) => formatPercent(Number(value))}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
formatter={(value, name) => (
<span className="font-mono tabular-nums">
{formatPercent(Number(value))}
<span className="ml-1 text-muted-foreground">
{name === 'cpu' ? 'CPU' : '内存'}
</span>
</span>
)}
/>
}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="var(--color-cpu)"
fill="url(#capacityCpuFill)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="memory"
stroke="var(--color-memory)"
strokeWidth={2}
dot={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,21 @@
export function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
export function formatPercent(value: number) {
if (!Number.isFinite(value)) {
return '—';
}
return `${value.toFixed(1)}%`;
}
export function formatCompactNumber(value: number) {
if (!Number.isFinite(value)) {
return '—';
}
return value.toLocaleString('zh-CN');
}
@@ -0,0 +1,54 @@
'use client';
import {Globe2} from 'lucide-react';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {Progress} from '@/components/ui/progress';
import type {DistributionItem} from '@/lib/services/openflare';
import {formatCompactNumber} from './dashboard-utils';
export function GeoDistributionList({ items }: { items: DistributionItem[] }) {
const sortedItems = [...items]
.sort((left, right) => right.value - left.value)
.slice(0, 8);
const maxValue = sortedItems[0]?.value ?? 0;
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Globe2 className="size-4 text-primary" />
来源国家分布
</CardTitle>
<CardDescription className="text-xs">
聚合最近 24 小时主要来源国家。
</CardDescription>
</CardHeader>
<CardContent>
{sortedItems.length === 0 ? (
<div className="flex min-h-[180px] items-center justify-center text-xs text-muted-foreground">
暂无来源分布数据
</div>
) : (
<div className="space-y-3">
{sortedItems.map((item) => {
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<div key={item.key} className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{item.key || '未知'}</span>
<span className="font-mono tabular-nums text-muted-foreground">
{formatCompactNumber(item.value)}
</span>
</div>
<Progress value={ratio} className="h-1.5" />
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,132 @@
'use client';
import Link from 'next/link';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {DashboardNodeHealth} from '@/lib/services/openflare';
import {NodeStatusBadge} from '../../nodes/components/node-status-badge';
import {
formatRelativeTime,
getNodeStatusLabel,
getNodeStatusTone,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
isWSConnectedLastSeen,
} from '../../nodes/components/node-utils';
import {formatCompactNumber, formatPercent} from './dashboard-utils';
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
const sortedNodes = [...nodes].sort((left, right) => {
if (right.active_event_count !== left.active_event_count) {
return right.active_event_count - left.active_event_count;
}
const leftPressure = Math.max(
left.cpu_usage_percent,
left.memory_usage_percent,
left.storage_usage_percent,
);
const rightPressure = Math.max(
right.cpu_usage_percent,
right.memory_usage_percent,
right.storage_usage_percent,
);
return rightPressure - leftPressure;
});
return (
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle className="text-sm font-semibold">节点健康列表</CardTitle>
<CardDescription className="text-xs">
按异常数量和资源压力排序。
</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-8 text-xs" asChild>
<Link href="/openflare/nodes">进入节点页</Link>
</Button>
</CardHeader>
<CardContent>
{sortedNodes.length === 0 ? (
<EmptyStateWithBorder
title="暂无节点"
description="节点接入后,这里会展示系统健康与容量摘要。"
/>
) : (
<div className="border border-dashed rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="py-2 h-8">节点</TableHead>
<TableHead className="py-2 h-8">状态</TableHead>
<TableHead className="py-2 h-8">CPU / 内存</TableHead>
<TableHead className="py-2 h-8">请求 / 错误</TableHead>
<TableHead className="py-2 h-8">活动异常</TableHead>
<TableHead className="py-2 h-8">最近心跳</TableHead>
<TableHead className="py-2 h-8 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedNodes.slice(0, 12).map((node) => (
<TableRow key={node.node_id} className="border-dashed align-top">
<TableCell className="py-3">
<div className="space-y-1">
<p className="font-medium">{node.name}</p>
<p className="text-xs text-muted-foreground">{node.node_id}</p>
<p className="text-xs text-muted-foreground">
{node.geo_name || '未配置地图点位'}
</p>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-muted-foreground">
{formatPercent(node.cpu_usage_percent)} /{' '}
{formatPercent(node.memory_usage_percent)}
</TableCell>
<TableCell className="py-3 text-sm text-muted-foreground">
{formatCompactNumber(node.request_count)} /{' '}
{formatCompactNumber(node.error_count)}
</TableCell>
<TableCell className="py-3">
<NodeStatusBadge
label={String(node.active_event_count)}
tone={node.active_event_count > 0 ? 'danger' : 'success'}
/>
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: node.last_seen_at
? formatRelativeTime(node.last_seen_at)
: '暂无'}
</TableCell>
<TableCell className="py-3 text-right">
<Button variant="ghost" size="sm" className="h-8 text-xs" asChild>
<Link href={`/openflare/nodes/detail?id=${node.id}`}>详情</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,93 @@
'use client';
import {Activity, Cpu, Gauge, Server, Users} from 'lucide-react';
import {Card, CardContent, CardHeader} from '@/components/ui/card';
import {Progress} from '@/components/ui/progress';
import type {DashboardCapacity, DashboardSummary, DashboardTraffic,} from '@/lib/services/openflare';
import {formatCompactNumber, formatPercent} from './dashboard-utils';
export function DashboardStatCards({
summary,
traffic,
capacity,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
}) {
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
: 0;
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">24h 请求量</span>
<Activity className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">
{formatCompactNumber(traffic.request_count)}
</div>
<p className="text-[10px] text-muted-foreground">
独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
{traffic.estimated_qps.toFixed(2)}
</p>
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">集群容量</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">
{formatPercent(capacity.average_cpu_usage_percent)}
<span className="ml-2 text-sm font-normal text-muted-foreground">
CPU
</span>
</div>
<Progress value={capacity.average_cpu_usage_percent} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">
平均内存 {formatPercent(capacity.average_memory_usage_percent)} · 高负载节点 CPU{' '}
{capacity.high_cpu_nodes} / 内存 {capacity.high_memory_nodes} / 存储{' '}
{capacity.high_storage_nodes}
</p>
</CardContent>
</Card>
<Card className="border-dashed shadow-none md:col-span-2 xl:col-span-1">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">节点概览</span>
<Server className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">
{summary.online_nodes}
<span className="text-sm font-normal text-muted-foreground">
{' '}
/ {summary.total_nodes} 在线
</span>
</div>
<Progress value={onlineRate} className="h-1.5" />
<p className="text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="inline-flex items-center gap-1">
<Users className="size-3" />
待接入 {summary.pending_nodes}
</span>
<span className="inline-flex items-center gap-1">
<Gauge className="size-3" />
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
</span>
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,122 @@
'use client';
import {Area, AreaChart, CartesianGrid, Line, XAxis, YAxis} from 'recharts';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import type {TrafficTrendPoint} from '@/lib/services/openflare';
import {formatCompactNumber, formatTrendHour} from './dashboard-utils';
const chartConfig = {
requests: {
label: '请求量',
color: 'hsl(var(--chart-1))',
},
errors: {
label: '错误量',
color: 'hsl(var(--chart-5))',
},
} satisfies ChartConfig;
export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) {
const data = points.map((point) => ({
hour: formatTrendHour(point.bucket_started_at),
requests: point.request_count,
errors: point.error_count,
}));
if (data.length === 0) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时请求趋势</CardTitle>
<CardDescription className="text-xs">
观察整体请求量和错误量是否出现异常抬升。
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
暂无趋势数据
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时请求趋势</CardTitle>
<CardDescription className="text-xs">
观察整体请求量和错误量是否出现异常抬升。
</CardDescription>
</CardHeader>
<CardContent className="pl-2 pr-4">
<div className="h-[280px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trafficRequestsFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-requests)" stopOpacity={0.25} />
<stop offset="95%" stopColor="var(--color-requests)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => formatCompactNumber(Number(value))}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
formatter={(value, name) => (
<span className="font-mono tabular-nums">
{formatCompactNumber(Number(value))}
<span className="ml-1 text-muted-foreground">
{name === 'requests' ? '请求' : '错误'}
</span>
</span>
)}
/>
}
/>
<Area
type="monotone"
dataKey="requests"
stroke="var(--color-requests)"
fill="url(#trafficRequestsFill)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="errors"
stroke="var(--color-errors)"
strokeWidth={2}
dot={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,173 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Copy} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import type {NodeItem} from '@/lib/services/openflare';
import {
buildRelayDockerInstallCommand,
buildRelayInstallCommand,
buildTunnelDockerInstallCommand,
buildTunnelInstallCommand,
getServerUrl,
} from './node-utils';
type InstallVariant = 'relay' | 'tunnel';
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel: string;
dockerLabel: string;
}
> = {
relay: {
title: '中继部署命令',
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
tokenLabel: 'Discovery Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
tunnel: {
title: '隧道部署命令',
description: '使用 Tunnel Token 将 openflared 客户端接入控制端。',
tokenLabel: 'Tunnel Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
};
export function InstallCommand({
node,
variant,
}: {
node: NodeItem;
variant: InstallVariant;
}) {
const [serverUrl, setServerUrl] = useState('');
const meta = variantMeta[variant];
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
const normalizedServerUrl = getServerUrl(serverUrl);
const scriptCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
return '';
}
return variant === 'relay'
? buildRelayInstallCommand(normalizedServerUrl, node.access_token)
: buildTunnelInstallCommand(normalizedServerUrl, node.access_token);
}, [normalizedServerUrl, node.access_token, variant]);
const dockerCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
return '';
}
return variant === 'relay'
? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_token)
: buildTunnelDockerInstallCommand(normalizedServerUrl, node.access_token);
}, [normalizedServerUrl, node.access_token, variant]);
const handleCopy = async (value: string, message: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(message);
} catch {
toast.error('复制失败,请手动选择命令文本。');
}
};
return (
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-start justify-between space-y-0 gap-4">
<div>
<CardTitle className="text-base font-semibold">{meta.title}</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{scriptCommand ? (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(scriptCommand, '部署脚本命令已复制')}
>
<Copy className="size-3.5 mr-1" />
复制脚本
</Button>
) : null}
{dockerCommand ? (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(dockerCommand, 'Docker 部署命令已复制')}
>
<Copy className="size-3.5 mr-1" />
复制 Docker
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border px-3 py-3">
<p className="text-xs text-muted-foreground">Node ID</p>
<p className="mt-1 text-sm break-all font-medium">{node.node_id}</p>
</div>
<div className="rounded-lg border px-3 py-3">
<p className="text-xs text-muted-foreground">{meta.tokenLabel}</p>
<p className="mt-1 text-sm break-all font-medium">{node.access_token || '暂无'}</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`server-url-${variant}`}>Server URL(控制端地址)</Label>
<Input
id={`server-url-${variant}`}
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
placeholder="https://openflare.example.com"
/>
<p className="text-xs text-muted-foreground">
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
</p>
</div>
{!node.access_token ? (
<p className="text-sm text-muted-foreground">节点尚未生成接入 Token,请稍后刷新。</p>
) : !normalizedServerUrl ? (
<p className="text-sm text-muted-foreground">请填写有效的 Server URL 以生成部署命令。</p>
) : (
<>
<div className="space-y-2">
<p className="text-sm font-medium">{meta.scriptLabel}</p>
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap">
{scriptCommand}
</pre>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{meta.dockerLabel}</p>
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap">
{dockerCommand}
</pre>
</div>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,385 @@
'use client';
import {useMemo, useState} from 'react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {Loader2, 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {formatDateTime} from '@/lib/utils';
import {NodeService} from '@/lib/services/openflare';
import {NodeStatusBadge} from './node-status-badge';
import {
formatBytes,
formatRelativeTime,
formatUptime,
formatUsageRatio,
getErrorMessage,
getHealthEventLabel,
getHealthEventTone,
isMeaningfulTime,
} from './node-utils';
type HealthEventFilter = 'all' | 'active' | 'resolved';
function MetricBar({
label,
value,
progress,
hint,
}: {
label: string;
value: string;
progress?: number | null;
hint?: string;
}) {
return (
<div className="space-y-2 rounded-lg border px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">{label}</p>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</div>
<p className="text-sm font-medium">{value}</p>
</div>
{progress !== null && progress !== undefined ? (
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
) : null}
</div>
);
}
export function NodeObservability({
nodeId,
connectionHint = '活动连接数',
}: {
nodeId: number;
connectionHint?: string;
}) {
const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] = useState<HealthEventFilter>('all');
const [cleanupOpen, setCleanupOpen] = useState(false);
const observabilityQuery = useQuery({
queryKey: ['openflare', 'node-observability', nodeId],
queryFn: () => NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
refetchInterval: 10000,
});
const cleanupMutation = useMutation({
mutationFn: () => NodeService.cleanupHealthEvents(nodeId),
onSuccess: async (result) => {
toast.success(
result.deleted_count > 0
? `已清理 ${result.deleted_count} 条健康事件日志`
: '当前没有可清理的健康事件日志',
);
setCleanupOpen(false);
await queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', nodeId],
});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const observability = observabilityQuery.data ?? null;
const profile = observability?.profile ?? null;
const latestMetric = observability?.metric_snapshots?.[0] ?? null;
const activeHealthEvents = useMemo(
() => observability?.health_events.filter((event) => event.status === 'active') ?? [],
[observability?.health_events],
);
const resolvedHealthEvents = useMemo(
() => observability?.health_events.filter((event) => event.status === 'resolved') ?? [],
[observability?.health_events],
);
const filteredHealthEvents = useMemo(() => {
switch (healthEventFilter) {
case 'active':
return activeHealthEvents;
case 'resolved':
return resolvedHealthEvents;
default:
return observability?.health_events ?? [];
}
}, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
const memoryUsageRatio = formatUsageRatio(
latestMetric?.memory_used_bytes,
latestMetric?.memory_total_bytes,
);
const storageUsageRatio = formatUsageRatio(
latestMetric?.storage_used_bytes,
latestMetric?.storage_total_bytes,
);
if (observabilityQuery.isLoading) {
return (
<Card className="border-dashed shadow-none">
<CardContent className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="size-4 mr-2 animate-spin" />
加载运行观测数据中...
</CardContent>
</Card>
);
}
if (observabilityQuery.isError) {
return (
<Card className="border-dashed shadow-none">
<CardContent className="py-6">
<p className="text-sm text-destructive">{getErrorMessage(observabilityQuery.error)}</p>
</CardContent>
</Card>
);
}
return (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>运行诊断</CardDescription>
<CardTitle className="text-base font-semibold">
{activeHealthEvents.length ? `${activeHealthEvents.length} 个活动异常` : '运行稳定'}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>系统核心</CardDescription>
<CardTitle className="text-base font-semibold">
{profile?.hostname || '—'}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>在线时长</CardDescription>
<CardTitle className="text-base font-semibold">
{formatUptime(profile?.uptime_seconds)}
</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">系统画像</CardTitle>
<CardDescription>节点上报的主机与硬件信息</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{profile ? (
<>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">操作系统</span>
<span>
{profile.os_name || 'unknown'}
{profile.os_version ? ` ${profile.os_version}` : ''}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">内核 / 架构</span>
<span>
{profile.kernel_version || 'unknown'} · {profile.architecture || 'unknown'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">CPU</span>
<span>
{profile.cpu_model || 'unknown'}({profile.cpu_cores || 0} 核)
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">总内存</span>
<span>{formatBytes(profile.total_memory_bytes)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">总存储</span>
<span>{formatBytes(profile.total_disk_bytes)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">上报时间</span>
<span>
{isMeaningfulTime(profile.reported_at)
? formatDateTime(profile.reported_at)
: '—'}
</span>
</div>
</>
) : (
<p className="text-muted-foreground">节点已接入,但尚未上报完整系统画像。</p>
)}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">实时资源快照</CardTitle>
<CardDescription>最近一次 metrics 上报</CardDescription>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className="grid gap-3 sm:grid-cols-2">
<MetricBar
label="CPU"
value={`${latestMetric.cpu_usage_percent.toFixed(1)}%`}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
: undefined
}
/>
<MetricBar
label="内存"
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label="存储"
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label="活动连接"
value={
latestMetric.openresty_connections
? String(latestMetric.openresty_connections)
: '—'
}
progress={null}
hint={connectionHint}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">节点已接入,但尚未上报资源快照。</p>
)}
</CardContent>
</Card>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base font-semibold">健康事件时间线</CardTitle>
<CardDescription>保留活动与已恢复事件,用于运行异常诊断</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
disabled={cleanupMutation.isPending || !observability?.health_events.length}
onClick={() => setCleanupOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
清理日志
</Button>
</CardHeader>
<CardContent className="space-y-4">
{observability?.health_events.length ? (
<>
<div className="flex flex-wrap gap-2">
{(['all', 'active', 'resolved'] as const).map((filter) => (
<Button
key={filter}
type="button"
variant={healthEventFilter === filter ? 'secondary' : 'outline'}
size="sm"
className="h-7 text-xs rounded-full"
onClick={() => setHealthEventFilter(filter)}
>
{filter === 'all' ? '全部' : filter === 'active' ? '活动中' : '已恢复'}
</Button>
))}
</div>
{filteredHealthEvents.slice(0, 8).map((event) => (
<div key={`${event.event_type}-${event.last_triggered_at}-${event.status}`} className="rounded-lg border px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<NodeStatusBadge
label={getHealthEventLabel(event)}
tone={getHealthEventTone(event)}
/>
<NodeStatusBadge
label={event.status === 'active' ? '活动中' : '已恢复'}
tone={event.status === 'active' ? 'warning' : 'success'}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{event.message || '暂无详细消息'}
</p>
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3">
<p>
首次触发:
{isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'}
</p>
<p>
最近触发:
{isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'}
</p>
<p>
恢复时间:
{isMeaningfulTime(event.resolved_at) && event.resolved_at
? ` ${formatDateTime(event.resolved_at)}`
: ' —'}
</p>
</div>
</div>
))}
{filteredHealthEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">当前筛选下没有健康事件。</p>
) : null}
</>
) : (
<p className="text-sm text-muted-foreground">节点当前还没有上报健康事件记录。</p>
)}
</CardContent>
</Card>
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
<AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -9,7 +9,7 @@ export function isWSConnectedLastSeen(value: string | null | undefined) {
return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN;
}
export function isMeaningfulTime(value: string | null | undefined) {
export function isMeaningfulTime(value: string | null | undefined): value is string {
return (
Boolean(value) &&
!isWSConnectedLastSeen(value) &&
@@ -96,3 +96,133 @@ export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const relayInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
const flaredInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh';
export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) {
return [
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --discovery-token ${discoveryToken}`,
].join('\n');
}
export function buildRelayDockerInstallCommand(serverUrl: string, discoveryToken: string) {
const image = 'ghcr.io/rain-kl/openflare-relay:latest';
return [
`docker pull ${image}`,
`docker rm -f openflare-relay 2>/dev/null || true`,
`docker run -d --name openflare-relay --net host --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_DISCOVERY_TOKEN=${discoveryToken} \\`,
` ${image}`,
].join('\n');
}
export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) {
return [
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --tunnel-token ${tunnelToken}`,
].join('\n');
}
export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken: string) {
const image = 'ghcr.io/rain-kl/openflared:latest';
return [
`docker pull ${image}`,
`docker rm -f openflared 2>/dev/null || true`,
`docker run -d --name openflared --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`,
` ${image}`,
].join('\n');
}
export function formatBytes(bytes?: number | null, decimals = 1) {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) {
return '—';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** index;
return `${value.toFixed(decimals)} ${units[index]}`;
}
export function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) {
return null;
}
return Math.max(0, Math.min(100, (used / total) * 100));
}
export function formatUptime(seconds?: number | null) {
if (!seconds || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} 天 ${hours} 小时`;
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
}
return `${minutes} 分钟`;
}
export function getHealthEventTone(
event: { status: string; severity: string },
): StatusTone {
if (event.status === 'resolved') {
return 'success';
}
if (event.severity === 'critical') {
return 'danger';
}
if (event.severity === 'warning') {
return 'warning';
}
return 'info';
}
export function getHealthEventLabel(event: { event_type: string }) {
return event.event_type.replaceAll('_', ' ');
}
export function getFlaredStatusLabel(node: NodeItem) {
if (isWSConnectedLastSeen(node.last_seen_at)) {
return 'WS 已连接';
}
if (node.status === 'online') {
return '运行中';
}
if (node.status === 'pending') {
return '待接入';
}
return '离线';
}
export function getFlaredStatusTone(node: NodeItem): StatusTone {
if (isWSConnectedLastSeen(node.last_seen_at) || node.status === 'online') {
return 'success';
}
if (node.status === 'pending') {
return 'warning';
}
return 'danger';
}
@@ -0,0 +1,394 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {ArrowLeft, ExternalLink, FileText, RefreshCw, RotateCcw, Trash2, Upload,} 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {InstallCommand} from './install-command';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getNodeStatusLabel,
getNodeStatusTone,
getRelayStatusLabel,
getRelayStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('中继节点已更新');
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const webServerMutation = useMutation({
mutationFn: (enabled: boolean) =>
NodeService.updateNode(node.id, {
node_type: node.node_type,
name: node.name,
ip: node.ip,
ip_manual_override: node.ip_manual_override,
auto_update_enabled: node.auto_update_enabled,
geo_name: node.geo_name,
geo_latitude: node.geo_latitude ?? null,
geo_longitude: node.geo_longitude ?? null,
geo_manual_override: node.geo_manual_override,
relay_bind_port: node.relay_bind_port,
relay_vhost_http_port: node.relay_vhost_http_port,
relay_web_server_enabled: enabled,
}),
onSuccess: async () => {
toast.success('FRPS WebUI 设置已更新');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向中继节点 ${updated.name} 下发强制同步指令`);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
}),
onSuccess: async (updated) => {
toast.success(
`已向中继节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('中继节点已删除');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/openflare/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
]);
};
const webUiUrl =
node.relay_web_server_enabled && node.relay_bind_port
? `http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`
: null;
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4" />
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{node.name}</h1>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
编辑
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleRefresh}>
<RefreshCw className="size-3.5 mr-1" />
刷新
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1" />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => setUpgradeOpen(true)}
>
<Upload className="size-3.5 mr-1" />
{node.update_requested ? '查看升级' : '升级 Relay'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
删除
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>节点 ID</CardDescription>
<CardTitle className="text-sm font-medium break-all">{node.node_id}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>Relay 版本</CardDescription>
<CardTitle className="text-sm font-medium">{node.version || 'unknown'}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>frps 核心版本</CardDescription>
<CardTitle className="text-sm font-medium">{node.ext_version || 'unknown'}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>最近心跳</CardDescription>
<CardTitle className="text-sm font-medium">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无'}
</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">中继网络</CardTitle>
<CardDescription>隧道中继节点 (tunnel_relay / frps)</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">运行状态</span>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">中继健康</span>
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
tone={getRelayStatusTone(node.relay_status)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">绑定控制端口</span>
<span>{node.relay_bind_port || '—'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">VHost HTTP 端口</span>
<span>{node.relay_vhost_http_port || '—'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Agent 接入地址</span>
<span className="text-right break-all">{node.relay_agent_access_addr || '—'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">活动连接 / 代理数</span>
<span>
{node.relay_frps_connections ?? '—'} / {node.relay_frps_proxy_count ?? '—'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">IP 地址</span>
<span>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">最近应用</span>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</div>
{node.last_error ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-destructive">
{node.last_error}
</div>
) : null}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base font-semibold">FRPS WebUI</CardTitle>
<CardDescription>控制 frps 内置 Web 管理界面是否启用</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/openflare/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
<FileText className="size-3.5 mr-1" />
应用记录
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border px-3 py-3">
<div className="space-y-1">
<Label>启用 Web 管理界面</Label>
<p className="text-xs text-muted-foreground">
默认监听绑定端口 + 500,例如 {node.relay_bind_port || 7000} →{' '}
{(node.relay_bind_port || 7000) + 500}
</p>
</div>
<Switch
checked={node.relay_web_server_enabled}
disabled={webServerMutation.isPending}
onCheckedChange={(checked) => webServerMutation.mutate(checked)}
/>
</div>
{webUiUrl ? (
<a
href={webUiUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sm text-primary hover:underline"
>
打开 FRPS WebUI
<ExternalLink className="size-3 ml-1" />
</a>
) : (
<p className="text-sm text-muted-foreground">WebUI 已禁用或未配置绑定端口。</p>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<span>创建时间</span>
<span>{formatDateTime(node.created_at)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>更新时间</span>
<span>{formatDateTime(node.updated_at)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
<InstallCommand node={node} variant="relay" />
<NodeObservability nodeId={node.id} connectionHint="中继承载活动连接数" />
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除中继节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,369 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {ArrowLeft, FileText, RefreshCw, RotateCcw, Trash2, Upload,} 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {InstallCommand} from './install-command';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getFlaredStatusLabel,
getFlaredStatusTone,
getNodeStatusLabel,
getNodeStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('隧道节点已更新');
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向隧道节点 ${updated.name} 下发强制同步指令`);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
}),
onSuccess: async (updated) => {
toast.success(
`已向隧道节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('隧道节点已删除');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/openflare/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
]);
};
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4" />
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{node.name}</h1>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
编辑
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleRefresh}>
<RefreshCw className="size-3.5 mr-1" />
刷新
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1" />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => setUpgradeOpen(true)}
>
<Upload className="size-3.5 mr-1" />
{node.update_requested ? '查看升级' : '升级 openflared'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
删除
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>节点 ID</CardDescription>
<CardTitle className="text-sm font-medium break-all">{node.node_id}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>openflared 版本</CardDescription>
<CardTitle className="text-sm font-medium">{node.version || 'unknown'}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>当前配置版本</CardDescription>
<CardTitle className="text-sm font-medium">{node.current_version || '未应用'}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>最近心跳</CardDescription>
<CardTitle className="text-sm font-medium">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无'}
</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">隧道客户端</CardTitle>
<CardDescription>隧道客户端节点 (tunnel_client / openflared)</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">运行状态</span>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">flared 状态</span>
<NodeStatusBadge
label={getFlaredStatusLabel(node)}
tone={getFlaredStatusTone(node)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Tunnel Token</span>
<span className="text-right break-all max-w-[60%]">
{node.access_token || '暂无'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">IP 地址</span>
<span>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">最近应用</span>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">自动更新</span>
<span>{node.auto_update_enabled ? '已启用' : '手动'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">创建时间</span>
<span>{formatDateTime(node.created_at)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">更新时间</span>
<span>{formatDateTime(node.updated_at)}</span>
</div>
{node.last_error ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-destructive">
{node.last_error}
</div>
) : null}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base font-semibold">同步状态</CardTitle>
<CardDescription>配置追平与应用结果</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/openflare/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
<FileText className="size-3.5 mr-1" />
应用记录
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">最近应用结果</span>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">最近应用时间</span>
<span>
{isMeaningfulTime(node.latest_apply_at) && node.latest_apply_at
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
</span>
</div>
{node.latest_apply_checksum ? (
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">同步文件数</span>
<span>{node.latest_support_file_count} 个</span>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">升级状态</span>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
}
tone={node.update_requested ? 'warning' : 'info'}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">最近心跳详情</span>
<span className="text-right">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WebSocket 长连接已建立'
: isMeaningfulTime(node.last_seen_at)
? formatDateTime(node.last_seen_at)
: '暂无'}
</span>
</div>
</CardContent>
</Card>
</div>
<InstallCommand node={node} variant="tunnel" />
<NodeObservability nodeId={node.id} connectionHint="隧道并发连接数" />
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除隧道节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,96 @@
'use client';
import Link from 'next/link';
import {useMemo} from 'react';
import {useSearchParams} from 'next/navigation';
import {useQuery} from '@tanstack/react-query';
import {ArrowLeft, Server} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {NodeService} from '@/lib/services/openflare';
import {EdgeNodeDetail} from '../components/edge-node-detail';
import {RelayNodeDetail} from '../components/relay-node-detail';
import {TunnelNodeDetail} from '../components/tunnel-node-detail';
import {getErrorMessage} from '../components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodeDetailPageClient() {
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const node = useMemo(() => {
if (!nodeId) return null;
return (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null;
}, [nodeId, nodesQuery.data]);
if (!nodeId) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder
icon={Server}
description="缺少节点 ID,请从节点列表进入详情页。"
/>
</div>
);
}
if (nodesQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Server} description="加载节点详情中..." />
</div>
);
}
if (nodesQuery.isError) {
return (
<div className="py-6 px-1">
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={() => void nodesQuery.refetch()}
className="justify-center"
/>
</div>
</div>
);
}
if (!node) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4 mr-1" />
返回节点列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description="节点不存在,可能已被删除或 ID 无效。"
/>
</div>
);
}
if (node.node_type === 'tunnel_relay') {
return <RelayNodeDetail node={node} />;
}
if (node.node_type === 'tunnel_client') {
return <TunnelNodeDetail node={node} />;
}
return <EdgeNodeDetail node={node} />;
}
@@ -1,103 +1,23 @@
'use client';
import {Suspense} from 'react';
import Link from 'next/link';
import {useMemo} from 'react';
import {useSearchParams} from 'next/navigation';
import {useQuery} from '@tanstack/react-query';
import {ArrowLeft, Server} from 'lucide-react';
import {Skeleton} from '@/components/ui/skeleton';
import {Button} from '@/components/ui/button';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {NodeService} from '@/lib/services/openflare';
import {NodeDetailPageClient} from './page-client';
import {EdgeNodeDetail} from '../components/edge-node-detail';
import {getErrorMessage} from '../components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
function NodeDetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export default function NodeDetailPage() {
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const node = useMemo(() => {
if (!nodeId) return null;
return (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null;
}, [nodeId, nodesQuery.data]);
if (!nodeId) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder
icon={Server}
description="缺少节点 ID,请从节点列表进入详情页。"
/>
</div>
);
}
if (nodesQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Server} description="加载节点详情中..." />
</div>
);
}
if (nodesQuery.isError) {
return (
<div className="py-6 px-1">
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={() => void nodesQuery.refetch()}
className="justify-center"
/>
</div>
</div>
);
}
if (!node) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4 mr-1" />
返回节点列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description="节点不存在,可能已被删除或 ID 无效。"
/>
</div>
);
}
if (node.node_type !== 'edge_node') {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4 mr-1" />
返回节点列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description={`当前仅实现 Edge 节点详情骨架,${node.node_type} 详情将在后续任务中补齐。`}
/>
</div>
);
}
return <EdgeNodeDetail node={node} />;
}
return (
<Suspense fallback={<NodeDetailPageFallback />}>
<NodeDetailPageClient />
</Suspense>
);
}
@@ -0,0 +1,208 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {useSearchParams} from 'next/navigation';
import {Loader2, Plus, RefreshCw, Server} 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, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {NodeItem, NodeMutationPayload} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {NodeEditorDialog} from './components/node-editor-dialog';
import {filterNodesByType, getFilterDescription, getNodeFilter, NodeTypeFilter,} from './components/node-type-filter';
import {NodesTable} from './components/nodes-table';
import {getErrorMessage} from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
const nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
);
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
if (editingNode) {
return NodeService.updateNode(editingNode.id, payload);
}
return NodeService.createNode(payload);
},
onSuccess: async () => {
toast.success(editingNode ? '节点已更新' : '节点已创建');
setEditingNode(null);
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => NodeService.deleteNode(id),
onSuccess: async () => {
toast.success('节点已删除');
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const handleCreate = () => {
setEditingNode(null);
setEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setEditingNode(node);
setEditorOpen(true);
};
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey });
};
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Server className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">节点管理</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/apply-logs">应用记录</Link>
</Button>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
<Plus className="size-3.5 mr-1" />
新增节点
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold">节点列表</CardTitle>
<CardDescription>{getFilterDescription(nodeFilter)}</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
disabled={nodesQuery.isFetching}
>
{nodesQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1" />
)}
立即刷新
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<NodeTypeFilter />
{nodesQuery.isLoading ? (
<LoadingStateWithBorder icon={Server} description="加载节点列表中..." />
) : nodesQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={handleRefresh}
className="justify-center"
/>
</div>
) : filteredNodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={nodes.length === 0 ? '暂无节点,请先创建一个节点。' : '当前筛选无结果'}
/>
) : (
<NodesTable
nodes={filteredNodes}
deletingId={deleteMutation.isPending ? deleteTarget?.id ?? null : null}
onEdit={handleEdit}
onDelete={setDeleteTarget}
/>
)}
</CardContent>
</Card>
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{deleteTarget?.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,208 +1,28 @@
'use client';
import {Suspense} from 'react';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {useSearchParams} from 'next/navigation';
import {Loader2, Plus, RefreshCw, Server} from 'lucide-react';
import {toast} from 'sonner';
import {Skeleton} from '@/components/ui/skeleton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {NodeItem, NodeMutationPayload} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {NodeEditorDialog} from './components/node-editor-dialog';
import {filterNodesByType, getFilterDescription, getNodeFilter, NodeTypeFilter,} from './components/node-type-filter';
import {NodesTable} from './components/nodes-table';
import {getErrorMessage} from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export default function NodesPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
const nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
);
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
if (editingNode) {
return NodeService.updateNode(editingNode.id, payload);
}
return NodeService.createNode(payload);
},
onSuccess: async () => {
toast.success(editingNode ? '节点已更新' : '节点已创建');
setEditingNode(null);
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => NodeService.deleteNode(id),
onSuccess: async () => {
toast.success('节点已删除');
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const handleCreate = () => {
setEditingNode(null);
setEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setEditingNode(node);
setEditorOpen(true);
};
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey });
};
import {NodesPageClient} from './page-client';
function NodesPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Server className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">节点管理</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/apply-logs">应用记录</Link>
</Button>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
<Plus className="size-3.5 mr-1" />
新增节点
</Button>
<Skeleton className="h-8 w-40" />
<div className="flex gap-2">
<Skeleton className="h-7 w-20" />
<Skeleton className="h-7 w-24" />
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold">节点列表</CardTitle>
<CardDescription>{getFilterDescription(nodeFilter)}</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
disabled={nodesQuery.isFetching}
>
{nodesQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1" />
)}
立即刷新
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<NodeTypeFilter />
{nodesQuery.isLoading ? (
<LoadingStateWithBorder icon={Server} description="加载节点列表中..." />
) : nodesQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={handleRefresh}
className="justify-center"
/>
</div>
) : filteredNodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={nodes.length === 0 ? '暂无节点,请先创建一个节点。' : '当前筛选无结果'}
/>
) : (
<NodesTable
nodes={filteredNodes}
deletingId={deleteMutation.isPending ? deleteTarget?.id ?? null : null}
onEdit={handleEdit}
onDelete={setDeleteTarget}
/>
)}
</CardContent>
</Card>
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{deleteTarget?.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Skeleton className="h-96 w-full" />
</div>
);
}
export default function NodesPage() {
return (
<Suspense fallback={<NodesPageFallback />}>
<NodesPageClient />
</Suspense>
);
}
@@ -0,0 +1,167 @@
"use client"
import {useEffect} from "react"
import {useMutation, useQueryClient} from "@tanstack/react-query"
import {zodResolver} from "@hookform/resolvers/zod"
import {useForm} from "react-hook-form"
import {Loader2} from "lucide-react"
import {toast} from "sonner"
import {z} from "zod"
import {Button} from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {type OriginItem, type OriginMutationPayload, OriginService} from "@/lib/services/openflare"
const originSchema = z.object({
name: z.string().max(255),
address: z
.string()
.trim()
.min(1, "请输入源站地址")
.refine(
(value) => !/[/?#]/.test(value) && !value.includes("://"),
"源站地址格式不合法",
),
remark: z.string().max(255),
})
type OriginFormValues = z.infer<typeof originSchema>
const originsQueryKey = ["openflare", "origins"] as const
function toFormValues(origin?: OriginItem | null): OriginFormValues {
if (!origin) return { name: "", address: "", remark: "" }
return {
name: origin.name,
address: origin.address,
remark: origin.remark || "",
}
}
function toPayload(values: OriginFormValues): OriginMutationPayload {
return {
name: values.name.trim(),
address: values.address.trim(),
remark: values.remark.trim(),
}
}
interface OriginEditorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
origin?: OriginItem | null
onSaved?: () => void
}
export function OriginEditorDialog({
open,
onOpenChange,
origin,
onSaved,
}: OriginEditorDialogProps) {
const queryClient = useQueryClient()
const form = useForm<OriginFormValues>({
resolver: zodResolver(originSchema),
defaultValues: toFormValues(origin),
})
useEffect(() => {
if (open) form.reset(toFormValues(origin))
}, [form, origin, open])
const mutation = useMutation({
mutationFn: async (values: OriginFormValues) => {
const payload = toPayload(values)
return origin
? OriginService.update(origin.id, payload)
: OriginService.create(payload)
},
onSuccess: async () => {
toast.success(origin ? "源站已更新" : "源站已创建")
await queryClient.invalidateQueries({ queryKey: originsQueryKey })
if (origin) {
await queryClient.invalidateQueries({
queryKey: ["openflare", "origins", String(origin.id)],
})
}
onSaved?.()
onOpenChange(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "保存失败")
},
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{origin ? "编辑源站" : "新增源站"}</DialogTitle>
<DialogDescription>
源站作为规则里的可复用地址目录,协议和端口仍由规则决定。
</DialogDescription>
</DialogHeader>
<form
id="origin-editor-form"
className="space-y-4"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className="space-y-1.5">
<Label htmlFor="address">源站地址</Label>
<Input
id="address"
placeholder="origin.internal"
{...form.register("address")}
/>
{form.formState.errors.address ? (
<p className="text-xs text-destructive">
{form.formState.errors.address.message}
</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="name">源站名</Label>
<Input id="name" placeholder="主站源站" {...form.register("name")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="remark">备注</Label>
<Textarea id="remark" rows={2} {...form.register("remark")} />
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="submit"
form="origin-editor-form"
disabled={mutation.isPending}
>
{mutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
保存中...
</>
) : origin ? (
"保存修改"
) : (
"新增源站"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,226 @@
"use client"
import Link from "next/link"
import {useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {useSearchParams} from "next/navigation"
import {ArrowLeft, MapPin, Trash2} from "lucide-react"
import {toast} from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {OriginService} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
import {OriginEditorDialog} from "../components/origin-editor-dialog"
export function OriginDetailPageClient() {
const searchParams = useSearchParams()
const queryClient = useQueryClient()
const originId = searchParams.get("id")?.trim() ?? ""
const parsedId = Number(originId)
const enabled = originId !== "" && Number.isFinite(parsedId)
const [editorOpen, setEditorOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const originQuery = useQuery({
queryKey: ["openflare", "origins", originId],
queryFn: () => OriginService.get(parsedId),
enabled,
})
const deleteMutation = useMutation({
mutationFn: () => OriginService.delete(parsedId),
onSuccess: async () => {
toast.success("源站已删除")
await queryClient.invalidateQueries({ queryKey: ["openflare", "origins"] })
window.location.href = "/openflare/origins"
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "删除失败")
},
})
const origin = originQuery.data
if (!enabled) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder description="缺少有效的源站 ID。" />
</div>
)
}
if (originQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={MapPin} description="加载源站详情..." />
</div>
)
}
if (originQuery.isError) {
return (
<div className="py-6 px-1">
<ErrorInline
message={
originQuery.error instanceof Error
? originQuery.error.message
: "加载失败"
}
onRetry={() => void originQuery.refetch()}
/>
</div>
)
}
if (!origin) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/openflare/origins">
<ArrowLeft className="size-4 mr-1" />
返回列表
</Link>
</Button>
<EmptyStateWithBorder description="源站不存在或已被删除。" />
</div>
)
}
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<Button variant="ghost" size="sm" className="h-8 px-2 -ml-2" asChild>
<Link href="/openflare/origins">
<ArrowLeft className="size-4 mr-1" />
返回列表
</Link>
</Button>
<div className="flex items-center gap-2">
<MapPin className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">{origin.name}</h1>
</div>
<p className="text-sm text-muted-foreground">{origin.address}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}>
编辑源站
</Button>
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
<Trash2 className="size-3.5 mr-1" />
删除源站
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">绑定规则</p>
<Badge variant="outline" className="mt-2 text-[10px]">
{origin.route_count} 条规则
</Badge>
</div>
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">创建时间</p>
<p className="mt-2 text-sm">{formatDateTime(origin.created_at)}</p>
</div>
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新时间</p>
<p className="mt-2 text-sm">{formatDateTime(origin.updated_at)}</p>
</div>
<div className="rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
<p className="mt-2 text-sm text-muted-foreground">{origin.remark || "暂无备注"}</p>
</div>
</div>
<div className="border border-dashed rounded-lg overflow-hidden bg-background">
<div className="px-4 py-3 border-b border-dashed">
<h2 className="text-sm font-semibold">关联规则</h2>
<p className="text-xs text-muted-foreground mt-1">
展示当前源站作为主源站绑定的规则。
</p>
</div>
{origin.routes.length === 0 ? (
<EmptyStateWithBorder
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">更新时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{origin.routes.map((route) => (
<TableRow key={route.id} className="border-dashed">
<TableCell className="text-xs font-medium">{route.domain}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{route.origin_url}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{route.enabled ? "启用" : "停用"}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(route.updated_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<OriginEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
origin={origin}
onSaved={() => void originQuery.refetch()}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除源站</AlertDialogTitle>
<AlertDialogDescription>
确认删除源站 {origin.name} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate()}
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,23 @@
import {Suspense} from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import {OriginDetailPageClient} from './page-client';
function OriginDetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export default function OriginDetailPage() {
return (
<Suspense fallback={<OriginDetailPageFallback />}>
<OriginDetailPageClient />
</Suspense>
);
}
@@ -1,7 +1,208 @@
import {MapPin} from 'lucide-react';
"use client"
import {PlaceholderPage} from '../components/placeholder-page';
import Link from "next/link"
import {useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {MapPin, Plus, RefreshCw, Trash2} from "lucide-react"
import {toast} from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {type OriginItem, OriginService} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
import {OriginEditorDialog} from "./components/origin-editor-dialog"
const originsQueryKey = ["openflare", "origins"] as const
export default function OriginsPage() {
return <PlaceholderPage title="源站" icon={MapPin} />;
}
const queryClient = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null)
const [deleteTarget, setDeleteTarget] = useState<OriginItem | null>(null)
const originsQuery = useQuery({
queryKey: originsQueryKey,
queryFn: () => OriginService.list(),
})
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data])
const deleteMutation = useMutation({
mutationFn: (id: number) => OriginService.delete(id),
onSuccess: async () => {
toast.success("源站已删除")
setDeleteTarget(null)
await queryClient.invalidateQueries({ queryKey: originsQueryKey })
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "删除失败")
},
})
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">
<MapPin className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">源站</h1>
<p className="text-sm text-muted-foreground">
集中维护规则复用的源站地址,减少批量改地址时的重复操作。
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void originsQuery.refetch()}
disabled={originsQuery.isFetching}
>
<RefreshCw
className={`size-3.5 mr-1 ${originsQuery.isFetching ? "animate-spin" : ""}`}
/>
刷新
</Button>
<Button
size="sm"
onClick={() => {
setEditingOrigin(null)
setEditorOpen(true)
}}
>
<Plus className="size-3.5 mr-1" />
新增源站
</Button>
</div>
</div>
{originsQuery.isError ? (
<ErrorInline
message={
originsQuery.error instanceof Error
? originsQuery.error.message
: "加载失败"
}
onRetry={() => void originsQuery.refetch()}
/>
) : null}
<div className="border border-dashed rounded-lg overflow-hidden bg-background">
{originsQuery.isLoading ? (
<LoadingStateWithBorder />
) : origins.length === 0 ? (
<EmptyStateWithBorder
title="暂无源站"
description="点击右上角新增源站,后续规则可直接复用这些地址。"
/>
) : (
<div className="grid gap-0 md:grid-cols-2">
{origins.map((origin) => (
<article
key={origin.id}
className="border-b border-dashed p-4 md:[&:nth-child(odd)]:border-r"
>
<div className="flex flex-col gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold">{origin.name}</h2>
<Badge
variant="outline"
className={`text-[10px] ${
origin.route_count > 0
? "text-emerald-600 border-emerald-500/20"
: "text-amber-600 border-amber-500/20"
}`}
>
{origin.route_count} 条规则
</Badge>
</div>
<p className="text-sm">{origin.address}</p>
<p className="text-sm text-muted-foreground">
{origin.remark || "暂无备注"}
</p>
<p className="text-xs text-muted-foreground">
最后更新:{formatDateTime(origin.updated_at)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/openflare/origins/detail?id=${origin.id}`}>
详情
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingOrigin(origin)
setEditorOpen(true)
}}
>
编辑
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteTarget(origin)}
>
<Trash2 className="size-3.5 mr-1" />
删除
</Button>
</div>
</div>
</article>
))}
</div>
)}
</div>
<OriginEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
origin={editingOrigin}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除源站</AlertDialogTitle>
<AlertDialogDescription>
确认删除源站 {deleteTarget?.name} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id)
}}
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+90 -4
View File
@@ -1,7 +1,93 @@
import {LayoutDashboard} from 'lucide-react';
'use client';
import {PlaceholderPage} from './components/placeholder-page';
import {useQuery} from '@tanstack/react-query';
import {LayoutDashboard, RefreshCw} from 'lucide-react';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {Button} from '@/components/ui/button';
import {DashboardService} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {CapacityTrendChart} from './components/dashboard/capacity-trend-chart';
import {GeoDistributionList} from './components/dashboard/geo-distribution-list';
import {NodeHealthTable} from './components/dashboard/node-health-table';
import {DashboardStatCards} from './components/dashboard/stat-cards';
import {TrafficTrendChart} from './components/dashboard/traffic-trend-chart';
import {getErrorMessage} from './nodes/components/node-utils';
const dashboardQueryKey = ['openflare', 'dashboard', 'overview'];
export default function OpenFlareDashboardPage() {
return <PlaceholderPage title="总览" icon={LayoutDashboard} />;
}
const overviewQuery = useQuery({
queryKey: dashboardQueryKey,
queryFn: () => DashboardService.getOverview(),
refetchInterval: 30_000,
});
const overview = overviewQuery.data;
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<LayoutDashboard className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">总览</h1>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{overview?.generated_at ? (
<span>数据生成于 {formatDateTime(overview.generated_at)}</span>
) : null}
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => overviewQuery.refetch()}
disabled={overviewQuery.isFetching}
>
<RefreshCw
className={`size-3.5 mr-1.5 ${overviewQuery.isFetching ? 'animate-spin' : ''}`}
/>
刷新
</Button>
</div>
</div>
{overviewQuery.isLoading ? (
<LoadingStateWithBorder
title="加载总览数据"
description="正在聚合节点健康、流量与容量指标..."
/>
) : overviewQuery.isError ? (
<ErrorInline
message={`总览看板加载失败:${getErrorMessage(overviewQuery.error)}`}
onRetry={() => overviewQuery.refetch()}
/>
) : !overview ? (
<EmptyStateWithBorder
title="暂无总览数据"
description="系统已经启动,但还没有可展示的总览聚合结果。"
/>
) : (
<>
<DashboardStatCards
summary={overview.summary}
traffic={overview.traffic}
capacity={overview.capacity}
/>
<div className="grid gap-6 xl:grid-cols-2">
<TrafficTrendChart points={overview.trends.traffic_24h} />
<CapacityTrendChart points={overview.trends.capacity_24h} />
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<NodeHealthTable nodes={overview.nodes} />
<GeoDistributionList items={overview.distributions.source_countries} />
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,177 @@
"use client"
import {useRef, useState} from "react"
import {useMutation, useQueryClient} from "@tanstack/react-query"
import {Loader2, UploadCloud} from "lucide-react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Progress} from "@/components/ui/progress"
import {PagesService} from "@/lib/services/openflare"
import {cn} from "@/lib/utils"
import {deploymentsQueryKey, formatBytes, projectQueryKey, projectsQueryKey} from "./pages-utils"
interface DeploymentUploadDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: number
}
export function DeploymentUploadDialog({
open,
onOpenChange,
projectId,
}: DeploymentUploadDialogProps) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [isDragActive, setIsDragActive] = useState(false)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const resetForm = () => {
setFile(null)
setIsDragActive(false)
setUploadProgress(null)
if (fileInputRef.current) fileInputRef.current.value = ""
}
const handleClose = (nextOpen: boolean) => {
if (!nextOpen) resetForm()
onOpenChange(nextOpen)
}
const uploadMutation = useMutation({
mutationFn: () => {
if (!file) throw new Error("请选择 zip 部署包")
return PagesService.uploadDeployment(projectId, {
file,
onProgress: setUploadProgress,
})
},
onSuccess: async () => {
toast.success("部署包上传成功")
await Promise.all([
queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
])
handleClose(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "上传失败")
setUploadProgress(null)
},
})
const handleFileSelect = (selected: File | null) => {
if (!selected) return
if (!selected.name.toLowerCase().endsWith(".zip")) {
toast.error("仅支持 zip 格式的文件")
return
}
setFile(selected)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>上传部署包</DialogTitle>
<DialogDescription>上传已构建的 zip 静态资源包,部署后可在列表中激活。</DialogDescription>
</DialogHeader>
<div
className={cn(
"rounded-lg border border-dashed p-8 text-center transition",
isDragActive ? "border-primary bg-primary/5" : "bg-muted/20",
)}
onDragEnter={(e) => {
e.preventDefault()
setIsDragActive(true)
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={(e) => {
e.preventDefault()
setIsDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setIsDragActive(false)
handleFileSelect(e.dataTransfer.files[0] ?? null)
}}
>
<UploadCloud className="size-8 mx-auto text-muted-foreground" />
<p className="mt-3 text-sm">拖拽 zip 文件到此处,或点击选择文件</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => fileInputRef.current?.click()}
>
选择文件
</Button>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
/>
</div>
{file ? (
<div className="rounded-lg border border-dashed px-4 py-3 text-sm">
<p className="font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground mt-1">{formatBytes(file.size)}</p>
</div>
) : null}
{uploadProgress !== null ? (
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} />
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="entryFile">入口文件</Label>
<Input id="entryFile" defaultValue="index.html" disabled />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)}>
取消
</Button>
<Button
onClick={() => uploadMutation.mutate()}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
上传中...
</>
) : (
"上传并创建部署"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,22 @@
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export const projectsQueryKey = ['openflare', 'pages', 'projects'] as const;
export function projectQueryKey(projectId: string | number) {
return ['openflare', 'pages', 'project', String(projectId)] as const;
}
export function deploymentsQueryKey(projectId: number) {
return ['openflare', 'pages', 'deployments', projectId] as const;
}
export function deploymentFilesQueryKey(projectId: number, deploymentId: number) {
return ['openflare', 'pages', 'deployment-files', projectId, deploymentId] as const;
}
@@ -0,0 +1,278 @@
"use client"
import {useEffect} from "react"
import {useMutation, useQueryClient} from "@tanstack/react-query"
import {zodResolver} from "@hookform/resolvers/zod"
import {useForm} from "react-hook-form"
import {Loader2} from "lucide-react"
import {z} from "zod"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {Textarea} from "@/components/ui/textarea"
import {type PagesProject, PagesService} from "@/lib/services/openflare"
import {projectQueryKey, projectsQueryKey} from "./pages-utils"
const pagesProjectSchema = z
.object({
name: z.string().trim().min(1, "请输入项目名称").max(255),
slug: z.string().trim().max(255).optional().or(z.literal("")),
description: z.string().trim().max(1000).optional().or(z.literal("")),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal("")),
entry_file: z.string().trim().min(1, "请输入入口文件").max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith("/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["spa_fallback_path"],
message: "回退路径必须以 / 开头",
})
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith("/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["api_proxy_path"],
message: "匹配路径必须以 / 开头",
})
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["api_proxy_pass"],
message: "后端地址必须以 http:// 或 https:// 开头",
})
}
}
})
type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
if (!project) {
return {
name: "",
slug: "",
description: "",
spa_fallback_enabled: false,
spa_fallback_path: "/index.html",
api_proxy_enabled: false,
api_proxy_path: "",
api_proxy_pass: "",
api_proxy_rewrite: "",
root_dir: "",
entry_file: "index.html",
}
}
return {
name: project.name,
slug: project.slug,
description: project.description || "",
spa_fallback_enabled: project.spa_fallback_enabled,
spa_fallback_path: project.spa_fallback_path,
api_proxy_enabled: project.api_proxy_enabled || false,
api_proxy_path: project.api_proxy_path || "",
api_proxy_pass: project.api_proxy_pass || "",
api_proxy_rewrite: project.api_proxy_rewrite || "",
root_dir: project.root_dir || "",
entry_file: project.entry_file || "index.html",
}
}
interface ProjectEditorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
project?: PagesProject | null
}
export function ProjectEditorDialog({
open,
onOpenChange,
project,
}: ProjectEditorDialogProps) {
const queryClient = useQueryClient()
const form = useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
defaultValues: toFormValues(project),
})
useEffect(() => {
if (open) form.reset(toFormValues(project))
}, [form, project, open])
const mutation = useMutation({
mutationFn: async (values: PagesProjectFormValues) => {
const payload = {
name: values.name.trim(),
slug: values.slug?.trim() || "",
description: values.description?.trim() || "",
enabled: project ? project.enabled : true,
spa_fallback_enabled: values.spa_fallback_enabled,
spa_fallback_path: values.spa_fallback_enabled
? values.spa_fallback_path.trim()
: project?.spa_fallback_path || "/index.html",
api_proxy_enabled: values.api_proxy_enabled,
api_proxy_path: values.api_proxy_enabled ? values.api_proxy_path.trim() : "",
api_proxy_pass: values.api_proxy_enabled ? values.api_proxy_pass.trim() : "",
api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim()
: "",
root_dir: values.root_dir?.trim() || "",
entry_file: values.entry_file.trim(),
}
return project
? PagesService.updateProject(project.id, payload)
: PagesService.createProject(payload)
},
onSuccess: async () => {
toast.success(project ? "项目已更新" : "项目已创建")
await queryClient.invalidateQueries({ queryKey: projectsQueryKey })
if (project) {
await queryClient.invalidateQueries({
queryKey: projectQueryKey(project.id),
})
}
onOpenChange(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "保存失败")
},
})
const spaEnabled = form.watch("spa_fallback_enabled")
const apiEnabled = form.watch("api_proxy_enabled")
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{project ? "编辑 Pages 项目" : "新建 Pages 项目"}</DialogTitle>
<DialogDescription>
配置静态站点托管参数,上传部署包后在代理规则中选择 Pages 上游。
</DialogDescription>
</DialogHeader>
<form
id="pages-project-form"
className="space-y-4"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="name">项目名称</Label>
<Input id="name" {...form.register("name")} />
{form.formState.errors.name ? (
<p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="slug">项目标识</Label>
<Input id="slug" placeholder="留空自动生成" {...form.register("slug")} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">描述</Label>
<Textarea id="description" rows={2} {...form.register("description")} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="entry_file">入口文件</Label>
<Input id="entry_file" {...form.register("entry_file")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="root_dir">根目录</Label>
<Input id="root_dir" placeholder="可选" {...form.register("root_dir")} />
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed px-4 py-3">
<div>
<p className="text-sm font-medium">SPA fallback</p>
<p className="text-xs text-muted-foreground">未命中静态文件时回退到指定路径</p>
</div>
<Switch
checked={spaEnabled}
onCheckedChange={(checked) =>
form.setValue("spa_fallback_enabled", checked)
}
/>
</div>
{spaEnabled ? (
<div className="space-y-1.5">
<Label htmlFor="spa_fallback_path">回退路径</Label>
<Input id="spa_fallback_path" {...form.register("spa_fallback_path")} />
</div>
) : null}
<div className="flex items-center justify-between rounded-lg border border-dashed px-4 py-3">
<div>
<p className="text-sm font-medium">API 反向代理</p>
<p className="text-xs text-muted-foreground">为静态站点附加 API 反代规则</p>
</div>
<Switch
checked={apiEnabled}
onCheckedChange={(checked) =>
form.setValue("api_proxy_enabled", checked)
}
/>
</div>
{apiEnabled ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="api_proxy_path">匹配路径</Label>
<Input id="api_proxy_path" {...form.register("api_proxy_path")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="api_proxy_pass">后端地址</Label>
<Input id="api_proxy_pass" {...form.register("api_proxy_pass")} />
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="api_proxy_rewrite">重写规则</Label>
<Input id="api_proxy_rewrite" {...form.register("api_proxy_rewrite")} />
</div>
</div>
) : null}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" form="pages-project-form" disabled={mutation.isPending}>
{mutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
保存中...
</>
) : project ? (
"保存修改"
) : (
"创建项目"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,61 @@
"use client"
import Link from "next/link"
import {ChevronRight} from "lucide-react"
import {Badge} from "@/components/ui/badge"
import type {PagesProject} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
interface ProjectListItemProps {
project: PagesProject
}
export function ProjectListItem({ project }: ProjectListItemProps) {
return (
<Link
href={`/openflare/pages/detail?id=${project.id}`}
className="group block rounded-lg border border-dashed bg-background p-4 transition hover:bg-muted/20"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold">{project.name}</h2>
<Badge variant="outline" className="text-[10px]">
{project.enabled ? "已启用" : "已停用"}
</Badge>
<Badge variant="outline" className="text-[10px]">
{project.spa_fallback_enabled ? "SPA fallback" : "严格 404"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{project.slug}</p>
{project.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">{project.description}</p>
) : null}
</div>
<div className="rounded-lg border border-dashed px-4 py-3 text-sm md:min-w-36">
<p className="text-xs text-muted-foreground">当前激活</p>
<p className="mt-1 font-semibold">
{project.active_deployment
? `#${project.active_deployment.deployment_number}`
: "暂无"}
</p>
</div>
</div>
<div className="mt-4 flex items-center justify-between border-t border-dashed pt-3 text-xs text-muted-foreground">
<span>
激活时间:
{project.active_deployment?.activated_at
? formatDateTime(project.active_deployment.activated_at)
: "未激活"}
</span>
<span className="inline-flex items-center text-primary group-hover:translate-x-0.5 transition-transform">
查看详情
<ChevronRight className="size-3.5 ml-0.5" />
</span>
</div>
</Link>
)
}
@@ -0,0 +1,453 @@
"use client"
import Link from "next/link"
import {useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {useSearchParams} from "next/navigation"
import {ArrowLeft, ChevronDown, ChevronRight, FileText, Loader2, Trash2, Upload,} from "lucide-react"
import {toast} from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
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 PagesDeployment, PagesService} from "@/lib/services/openflare"
import {formatDateTime} from "@/lib/utils"
import {DeploymentUploadDialog} from "../components/deployment-upload-dialog"
import {ProjectEditorDialog} from "../components/project-editor-dialog"
import {
deploymentFilesQueryKey,
deploymentsQueryKey,
formatBytes,
projectQueryKey,
projectsQueryKey,
} from "../components/pages-utils"
function DeploymentFilesPanel({
projectId,
deployment,
}: {
projectId: number
deployment: PagesDeployment
}) {
const filesQuery = useQuery({
queryKey: deploymentFilesQueryKey(projectId, deployment.id),
queryFn: () => PagesService.listDeploymentFiles(projectId, deployment.id),
})
if (filesQuery.isLoading) {
return <p className="px-4 py-3 text-xs text-muted-foreground">加载文件清单...</p>
}
if (filesQuery.isError) {
return (
<p className="px-4 py-3 text-xs text-destructive">
{filesQuery.error instanceof Error ? filesQuery.error.message : "加载失败"}
</p>
)
}
const files = filesQuery.data ?? []
if (files.length === 0) {
return <p className="px-4 py-3 text-xs text-muted-foreground">暂无文件记录</p>
}
return (
<div className="border-t border-dashed bg-muted/10">
<Table>
<TableHeader>
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs">路径</TableHead>
<TableHead className="text-xs text-right">大小</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id} className="border-dashed">
<TableCell className="text-xs font-mono">{file.path}</TableCell>
<TableCell className="text-xs text-right text-muted-foreground">
{formatBytes(file.size)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
export function PagesDetailPageClient() {
const searchParams = useSearchParams()
const queryClient = useQueryClient()
const projectId = searchParams.get("id")?.trim() ?? ""
const parsedProjectId = Number(projectId)
const [editorOpen, setEditorOpen] = useState(false)
const [uploadOpen, setUploadOpen] = useState(false)
const [expandedDeploymentId, setExpandedDeploymentId] = useState<number | null>(null)
const [deleteProjectOpen, setDeleteProjectOpen] = useState(false)
const [pendingDeploymentAction, setPendingDeploymentAction] = useState<{
type: "activate" | "delete"
deployment: PagesDeployment
} | null>(null)
const enabled = projectId !== "" && Number.isFinite(parsedProjectId)
const projectQuery = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: () => PagesService.getProject(parsedProjectId),
enabled,
})
const deploymentsQuery = useQuery({
queryKey: deploymentsQueryKey(parsedProjectId),
queryFn: () => PagesService.listDeployments(parsedProjectId),
enabled,
})
const activateMutation = useMutation({
mutationFn: (deploymentId: number) =>
PagesService.activateDeployment(parsedProjectId, deploymentId),
onSuccess: async () => {
toast.success("部署已激活")
await Promise.all([
queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(parsedProjectId) }),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
])
setPendingDeploymentAction(null)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "激活失败")
},
})
const deleteDeploymentMutation = useMutation({
mutationFn: (deploymentId: number) =>
PagesService.deleteDeployment(parsedProjectId, deploymentId),
onSuccess: async () => {
toast.success("部署已删除")
await Promise.all([
queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(parsedProjectId) }),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
])
setPendingDeploymentAction(null)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "删除失败")
},
})
const deleteProjectMutation = useMutation({
mutationFn: () => PagesService.deleteProject(parsedProjectId),
onSuccess: async () => {
toast.success("项目已删除")
await queryClient.invalidateQueries({ queryKey: projectsQueryKey })
window.location.href = "/openflare/pages"
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "删除失败")
},
})
const project = projectQuery.data
const deployments = useMemo(() => deploymentsQuery.data ?? [], [deploymentsQuery.data])
if (!enabled) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder description="缺少有效的 Pages 项目 ID。" />
</div>
)
}
if (projectQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={FileText} description="加载项目详情..." />
</div>
)
}
if (projectQuery.isError) {
return (
<div className="py-6 px-1">
<ErrorInline
message={
projectQuery.error instanceof Error
? projectQuery.error.message
: "加载失败"
}
onRetry={() => void projectQuery.refetch()}
/>
</div>
)
}
if (!project) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/openflare/pages">
<ArrowLeft className="size-4 mr-1" />
返回列表
</Link>
</Button>
<EmptyStateWithBorder description="Pages 项目不存在或已被删除。" />
</div>
)
}
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<Button variant="ghost" size="sm" className="h-8 px-2 -ml-2" asChild>
<Link href="/openflare/pages">
<ArrowLeft className="size-4 mr-1" />
返回列表
</Link>
</Button>
<div className="flex items-center gap-2">
<FileText className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">{project.name}</h1>
</div>
<p className="text-sm text-muted-foreground">
{project.slug} · {project.deployment_count} 个部署
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}>
编辑项目
</Button>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Upload className="size-3.5 mr-1" />
上传部署包
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteProjectOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
删除项目
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">状态</p>
<Badge variant="outline" className="mt-2 text-[10px]">
{project.enabled ? "已启用" : "已停用"}
</Badge>
</div>
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前激活</p>
<p className="mt-2 text-sm font-semibold">
{project.active_deployment
? `#${project.active_deployment.deployment_number}`
: "暂无"}
</p>
</div>
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新时间</p>
<p className="mt-2 text-sm">{formatDateTime(project.updated_at)}</p>
</div>
</div>
<div className="border border-dashed rounded-lg overflow-hidden bg-background">
<div className="px-4 py-3 border-b border-dashed">
<h2 className="text-sm font-semibold">部署历史</h2>
<p className="text-xs text-muted-foreground mt-1">
部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。
</p>
</div>
{deploymentsQuery.isLoading ? (
<LoadingStateWithBorder />
) : deployments.length === 0 ? (
<EmptyStateWithBorder
title="暂无部署"
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
/>
) : (
<div className="divide-y divide-dashed">
{deployments.map((deployment) => {
const expanded = expandedDeploymentId === deployment.id
return (
<div key={deployment.id}>
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-2">
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() =>
setExpandedDeploymentId(expanded ? null : deployment.id)
}
>
{expanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
#{deployment.deployment_number}
</p>
{deployment.status === "active" ? (
<Badge variant="outline" className="text-[10px]">
已激活
</Badge>
) : null}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{deployment.checksum.slice(0, 16)} · {deployment.file_count} files ·{" "}
{formatBytes(deployment.total_size)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
创建于 {formatDateTime(deployment.created_at)}
</p>
</div>
</div>
<div className="flex gap-2 md:ml-9">
<Button
variant="outline"
size="sm"
disabled={
deployment.status === "active" || activateMutation.isPending
}
onClick={() =>
setPendingDeploymentAction({
type: "activate",
deployment,
})
}
>
激活
</Button>
<Button
variant="destructive"
size="sm"
disabled={
deployment.status === "active" ||
deleteDeploymentMutation.isPending
}
onClick={() =>
setPendingDeploymentAction({
type: "delete",
deployment,
})
}
>
删除
</Button>
</div>
</div>
{expanded ? (
<DeploymentFilesPanel
projectId={parsedProjectId}
deployment={deployment}
/>
) : null}
</div>
)
})}
</div>
)}
</div>
<ProjectEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
project={project}
/>
<DeploymentUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
projectId={parsedProjectId}
/>
<AlertDialog open={deleteProjectOpen} onOpenChange={setDeleteProjectOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
<AlertDialogDescription>
确认删除项目 {project.name} 吗?此操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteProjectMutation.mutate()}
>
{deleteProjectMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"确认删除"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={pendingDeploymentAction !== null}
onOpenChange={(open) => {
if (!open) setPendingDeploymentAction(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDeploymentAction?.type === "activate" ? "激活部署" : "删除部署"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDeploymentAction?.type === "activate"
? `确认激活部署 #${pendingDeploymentAction.deployment.deployment_number} 吗?`
: `确认删除部署 #${pendingDeploymentAction?.deployment.deployment_number} 吗?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className={
pendingDeploymentAction?.type === "delete"
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: undefined
}
onClick={() => {
if (!pendingDeploymentAction) return
if (pendingDeploymentAction.type === "activate") {
activateMutation.mutate(pendingDeploymentAction.deployment.id)
} else {
deleteDeploymentMutation.mutate(pendingDeploymentAction.deployment.id)
}
}}
>
确认
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,23 @@
import {Suspense} from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import {PagesDetailPageClient} from './page-client';
function PagesDetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export default function PagesDetailPage() {
return (
<Suspense fallback={<PagesDetailPageFallback />}>
<PagesDetailPageClient />
</Suspense>
);
}
@@ -1,7 +1,85 @@
import {FileText} from 'lucide-react';
"use client"
import {PlaceholderPage} from '../components/placeholder-page';
import {useState} from "react"
import {useQuery} from "@tanstack/react-query"
import {FileText, Plus, RefreshCw} from "lucide-react"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Button} from "@/components/ui/button"
import {PagesService} from "@/lib/services/openflare"
import {ProjectEditorDialog} from "./components/project-editor-dialog"
import {ProjectListItem} from "./components/project-list-item"
import {projectsQueryKey} from "./components/pages-utils"
export default function PagesPage() {
return <PlaceholderPage title="Pages" icon={FileText} />;
const [editorOpen, setEditorOpen] = useState(false)
const projectsQuery = useQuery({
queryKey: projectsQueryKey,
queryFn: () => PagesService.listProjects(),
})
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">
<FileText className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Pages</h1>
<p className="text-sm text-muted-foreground">
边缘静态站点托管,上传 zip 部署包并在代理规则中选择 Pages 上游。
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void projectsQuery.refetch()}
disabled={projectsQuery.isFetching}
>
<RefreshCw
className={`size-3.5 mr-1 ${projectsQuery.isFetching ? "animate-spin" : ""}`}
/>
刷新
</Button>
<Button size="sm" onClick={() => setEditorOpen(true)}>
<Plus className="size-3.5 mr-1" />
新建项目
</Button>
</div>
</div>
{projectsQuery.isError ? (
<ErrorInline
message={
projectsQuery.error instanceof Error
? projectsQuery.error.message
: "加载失败"
}
onRetry={() => void projectsQuery.refetch()}
/>
) : null}
<div className="space-y-3">
{projectsQuery.isLoading ? (
<LoadingStateWithBorder />
) : (projectsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
title="还没有 Pages 项目"
description="先创建一个项目,再上传静态资源部署包。"
/>
) : (
(projectsQuery.data ?? []).map((project) => (
<ProjectListItem key={project.id} project={project} />
))
)}
</div>
<ProjectEditorDialog open={editorOpen} onOpenChange={setEditorOpen} />
</div>
)
}
@@ -0,0 +1,269 @@
export type PerformanceFields = {
OpenRestyDefaultServerReturnStatus: string
OpenRestyWorkerProcesses: string
OpenRestyWorkerConnections: string
OpenRestyWorkerRlimitNofile: string
OpenRestyEventsUse: string
OpenRestyEventsMultiAcceptEnabled: boolean
OpenRestyKeepaliveTimeout: string
OpenRestyKeepaliveRequests: string
OpenRestyClientHeaderTimeout: string
OpenRestyClientBodyTimeout: string
OpenRestyClientMaxBodySize: string
OpenRestyLargeClientHeaderBuffers: string
OpenRestySendTimeout: string
OpenRestyProxyConnectTimeout: string
OpenRestyProxySendTimeout: string
OpenRestyProxyReadTimeout: string
OpenRestyWebsocketEnabled: boolean
OpenRestyHTTP3Enabled: boolean
OpenRestyProxyRequestBufferingEnabled: boolean
OpenRestyProxyBufferingEnabled: boolean
OpenRestyProxyBuffers: string
OpenRestyProxyBufferSize: string
OpenRestyProxyBusyBuffersSize: string
OpenRestyGzipEnabled: boolean
OpenRestyGzipMinLength: string
OpenRestyGzipCompLevel: string
OpenRestyCacheEnabled: boolean
OpenRestyCachePath: string
OpenRestyCacheLevels: string
OpenRestyCacheInactive: string
OpenRestyCacheMaxSize: string
OpenRestyCacheKeyTemplate: string
OpenRestyCacheLockEnabled: boolean
OpenRestyCacheLockTimeout: string
OpenRestyCacheUseStale: string
OpenRestyResolvers: string
}
export const defaultPerformanceFields: PerformanceFields = {
OpenRestyDefaultServerReturnStatus: "421",
OpenRestyWorkerProcesses: "auto",
OpenRestyWorkerConnections: "4096",
OpenRestyWorkerRlimitNofile: "65535",
OpenRestyEventsUse: "epoll",
OpenRestyEventsMultiAcceptEnabled: true,
OpenRestyKeepaliveTimeout: "20",
OpenRestyKeepaliveRequests: "1000",
OpenRestyClientHeaderTimeout: "15",
OpenRestyClientBodyTimeout: "15",
OpenRestyClientMaxBodySize: "64m",
OpenRestyLargeClientHeaderBuffers: "4 16k",
OpenRestySendTimeout: "30",
OpenRestyProxyConnectTimeout: "3",
OpenRestyProxySendTimeout: "60",
OpenRestyProxyReadTimeout: "60",
OpenRestyWebsocketEnabled: true,
OpenRestyHTTP3Enabled: true,
OpenRestyProxyRequestBufferingEnabled: false,
OpenRestyProxyBufferingEnabled: true,
OpenRestyProxyBuffers: "16 16k",
OpenRestyProxyBufferSize: "8k",
OpenRestyProxyBusyBuffersSize: "64k",
OpenRestyGzipEnabled: true,
OpenRestyGzipMinLength: "1024",
OpenRestyGzipCompLevel: "5",
OpenRestyCacheEnabled: false,
OpenRestyCachePath: "",
OpenRestyCacheLevels: "1:2",
OpenRestyCacheInactive: "30m",
OpenRestyCacheMaxSize: "1g",
OpenRestyCacheKeyTemplate: "$scheme$host$request_uri",
OpenRestyCacheLockEnabled: true,
OpenRestyCacheLockTimeout: "5s",
OpenRestyCacheUseStale:
"error timeout updating http_500 http_502 http_503 http_504",
OpenRestyResolvers: "",
}
export function optionsToMap(options: Array<{ key: string; value: string }>) {
return options.reduce<Record<string, string>>((acc, option) => {
acc[option.key] = option.value
return acc
}, {})
}
export function toBoolean(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback
return value === "true"
}
export function mapOptionsToFields(
optionMap: Record<string, string>,
): PerformanceFields {
return {
OpenRestyDefaultServerReturnStatus:
optionMap.OpenRestyDefaultServerReturnStatus ?? "421",
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? "auto",
OpenRestyWorkerConnections: optionMap.OpenRestyWorkerConnections ?? "4096",
OpenRestyWorkerRlimitNofile:
optionMap.OpenRestyWorkerRlimitNofile ?? "65535",
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? "epoll",
OpenRestyEventsMultiAcceptEnabled: toBoolean(
optionMap.OpenRestyEventsMultiAcceptEnabled,
true,
),
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? "20",
OpenRestyKeepaliveRequests: optionMap.OpenRestyKeepaliveRequests ?? "1000",
OpenRestyClientHeaderTimeout:
optionMap.OpenRestyClientHeaderTimeout ?? "15",
OpenRestyClientBodyTimeout: optionMap.OpenRestyClientBodyTimeout ?? "15",
OpenRestyClientMaxBodySize: optionMap.OpenRestyClientMaxBodySize ?? "64m",
OpenRestyLargeClientHeaderBuffers:
optionMap.OpenRestyLargeClientHeaderBuffers ?? "4 16k",
OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? "30",
OpenRestyProxyConnectTimeout: optionMap.OpenRestyProxyConnectTimeout ?? "3",
OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? "60",
OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? "60",
OpenRestyWebsocketEnabled: toBoolean(
optionMap.OpenRestyWebsocketEnabled,
true,
),
OpenRestyHTTP3Enabled: toBoolean(optionMap.OpenRestyHTTP3Enabled, false),
OpenRestyProxyRequestBufferingEnabled: toBoolean(
optionMap.OpenRestyProxyRequestBufferingEnabled,
false,
),
OpenRestyProxyBufferingEnabled: toBoolean(
optionMap.OpenRestyProxyBufferingEnabled,
true,
),
OpenRestyProxyBuffers: optionMap.OpenRestyProxyBuffers ?? "16 16k",
OpenRestyProxyBufferSize: optionMap.OpenRestyProxyBufferSize ?? "8k",
OpenRestyProxyBusyBuffersSize:
optionMap.OpenRestyProxyBusyBuffersSize ?? "64k",
OpenRestyGzipEnabled: toBoolean(optionMap.OpenRestyGzipEnabled, true),
OpenRestyGzipMinLength: optionMap.OpenRestyGzipMinLength ?? "1024",
OpenRestyGzipCompLevel: optionMap.OpenRestyGzipCompLevel ?? "5",
OpenRestyCacheEnabled: toBoolean(optionMap.OpenRestyCacheEnabled, false),
OpenRestyCachePath: optionMap.OpenRestyCachePath ?? "",
OpenRestyCacheLevels: optionMap.OpenRestyCacheLevels ?? "1:2",
OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? "30m",
OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? "1g",
OpenRestyCacheKeyTemplate:
optionMap.OpenRestyCacheKeyTemplate ?? "$scheme$host$request_uri",
OpenRestyCacheLockEnabled: toBoolean(
optionMap.OpenRestyCacheLockEnabled,
true,
),
OpenRestyCacheLockTimeout: optionMap.OpenRestyCacheLockTimeout ?? "5s",
OpenRestyCacheUseStale:
optionMap.OpenRestyCacheUseStale ??
"error timeout updating http_500 http_502 http_503 http_504",
OpenRestyResolvers: optionMap.OpenRestyResolvers ?? "",
}
}
function isPositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10)
return !Number.isNaN(parsed) && parsed > 0
}
function isSizeValue(value: string) {
return /^\d+[kKmMgG]?$/.test(value.trim())
}
function isProxyBuffersValue(value: string) {
return /^\d+\s+\d+[kKmMgG]?$/.test(value.trim())
}
function isDurationToken(value: string) {
return /^\d+[smhdwSMHDW]$/.test(value.trim())
}
function isCacheLevelsValue(value: string) {
return /^\d{1,2}(?::\d{1,2}){0,2}$/.test(value.trim())
}
export function validateRuntimeFields(fields: PerformanceFields) {
if (
fields.OpenRestyWorkerProcesses !== "auto" &&
!isPositiveInteger(fields.OpenRestyWorkerProcesses)
) {
throw new Error("worker_processes 必须为 auto 或大于 0 的整数")
}
const integers = [
fields.OpenRestyWorkerConnections,
fields.OpenRestyWorkerRlimitNofile,
fields.OpenRestyKeepaliveTimeout,
fields.OpenRestyKeepaliveRequests,
fields.OpenRestyClientHeaderTimeout,
fields.OpenRestyClientBodyTimeout,
fields.OpenRestySendTimeout,
]
if (integers.some((value) => !isPositiveInteger(value))) {
throw new Error("超时与连接参数必须为大于 0 的整数")
}
const status = Number.parseInt(fields.OpenRestyDefaultServerReturnStatus, 10)
if (Number.isNaN(status) || status < 100 || status > 999) {
throw new Error("空白页面返回状态码必须在 100 到 999 之间")
}
if (!isSizeValue(fields.OpenRestyClientMaxBodySize)) {
throw new Error("client_max_body_size 格式不合法")
}
if (!isProxyBuffersValue(fields.OpenRestyLargeClientHeaderBuffers)) {
throw new Error('large_client_header_buffers 格式必须类似 "4 16k"')
}
}
export function validateProxyFields(fields: PerformanceFields) {
const timeouts = [
fields.OpenRestyProxyConnectTimeout,
fields.OpenRestyProxySendTimeout,
fields.OpenRestyProxyReadTimeout,
]
if (timeouts.some((value) => !isPositiveInteger(value))) {
throw new Error("代理超时参数必须为大于 0 的整数秒")
}
if (!isProxyBuffersValue(fields.OpenRestyProxyBuffers)) {
throw new Error('proxy_buffers 格式必须类似 "16 16k"')
}
if (
!isSizeValue(fields.OpenRestyProxyBufferSize) ||
!isSizeValue(fields.OpenRestyProxyBusyBuffersSize)
) {
throw new Error("缓冲大小必须为整数或带 k/m/g 单位的值")
}
}
export function validateGzipFields(fields: PerformanceFields) {
if (!isPositiveInteger(fields.OpenRestyGzipMinLength)) {
throw new Error("gzip_min_length 必须为大于 0 的整数")
}
const level = Number.parseInt(fields.OpenRestyGzipCompLevel, 10)
if (Number.isNaN(level) || level < 1 || level > 9) {
throw new Error("gzip_comp_level 必须在 1 到 9 之间")
}
}
export function validateCacheFields(fields: PerformanceFields) {
if (!fields.OpenRestyCacheEnabled) return
if (!fields.OpenRestyCachePath.trim()) {
throw new Error("启用缓存时必须填写 proxy_cache_path 目录")
}
if (
!isCacheLevelsValue(fields.OpenRestyCacheLevels) ||
!isDurationToken(fields.OpenRestyCacheInactive) ||
!isSizeValue(fields.OpenRestyCacheMaxSize) ||
!isDurationToken(fields.OpenRestyCacheLockTimeout)
) {
throw new Error("缓存 levels、inactive、max_size 或 lock_timeout 格式不合法")
}
if (!fields.OpenRestyCacheKeyTemplate.trim()) {
throw new Error("启用缓存时必须填写缓存 Key 模板")
}
}
export function entriesFromKeys(
fields: PerformanceFields,
keys: Array<keyof PerformanceFields>,
): Array<{ key: string; value: string }> {
return keys.map((key) => ({
key,
value:
typeof fields[key] === "boolean"
? String(fields[key])
: String(fields[key]).trim(),
}))
}
@@ -1,7 +1,398 @@
import {Gauge} from 'lucide-react';
"use client"
import {PlaceholderPage} from '../components/placeholder-page';
import Link from "next/link"
import {useEffect, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {ExternalLink, Gauge, Loader2, Save} from "lucide-react"
import {toast} from "sonner"
import {useAuth} from "@/components/providers/auth-provider"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Switch} from "@/components/ui/switch"
import {ConfigVersionService, OptionService} from "@/lib/services/openflare"
import {
defaultPerformanceFields,
entriesFromKeys,
mapOptionsToFields,
optionsToMap,
type PerformanceFields,
validateCacheFields,
validateGzipFields,
validateProxyFields,
validateRuntimeFields,
} from "./components/performance-utils"
const optionsQueryKey = ["openflare", "options"] as const
export default function PerformancePage() {
return <PlaceholderPage title="性能" icon={Gauge} />;
}
const { user, loading: authLoading } = useAuth()
const queryClient = useQueryClient()
const [fields, setFields] = useState<PerformanceFields>(defaultPerformanceFields)
const [savingSection, setSavingSection] = useState<string | null>(null)
const optionsQuery = useQuery({
queryKey: optionsQueryKey,
queryFn: () => OptionService.list(),
enabled: !!user?.is_admin,
})
const previewQuery = useQuery({
queryKey: ["openflare", "config-preview"],
queryFn: () => ConfigVersionService.preview(),
enabled: !!user?.is_admin,
})
useEffect(() => {
if (!optionsQuery.data) return
setFields(mapOptionsToFields(optionsToMap(optionsQuery.data)))
}, [optionsQuery.data])
const saveMutation = useMutation({
mutationFn: async ({
section,
entries,
validator,
}: {
section: string
entries: Array<{ key: string; value: string }>
validator?: (fields: PerformanceFields) => void
}) => {
validator?.(fields)
setSavingSection(section)
await OptionService.updateBatch(entries)
},
onSuccess: async () => {
toast.success("性能参数已保存")
await Promise.all([
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
queryClient.invalidateQueries({ queryKey: ["openflare", "config-preview"] }),
queryClient.invalidateQueries({ queryKey: ["openflare", "config-versions"] }),
])
setSavingSection(null)
},
onError: (error) => {
setSavingSection(null)
toast.error(error instanceof Error ? error.message : "保存失败")
},
})
const updateField = <K extends keyof PerformanceFields>(
key: K,
value: PerformanceFields[K],
) => {
setFields((prev) => ({ ...prev, [key]: value }))
}
if (authLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Gauge} description="加载权限信息..." />
</div>
)
}
if (!user?.is_admin) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder
icon={Gauge}
title="权限不足"
description="只有管理员可以访问性能设置。"
/>
</div>
)
}
if (optionsQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Gauge} description="加载性能参数..." />
</div>
)
}
if (optionsQuery.isError) {
return (
<div className="py-6 px-1">
<ErrorInline
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: "加载失败"
}
onRetry={() => void optionsQuery.refetch()}
/>
</div>
)
}
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-2">
<Gauge className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">性能</h1>
<p className="text-sm text-muted-foreground">
集中管理 OpenResty 全局性能参数,保存后进入统一配置发布链路。
</p>
</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/openflare/config-versions">
<ExternalLink className="size-3.5 mr-1" />
查看配置预览
</Link>
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">发布链路</p>
<p className="mt-2 text-sm font-medium">受管模板渲染</p>
</div>
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">预览规则数</p>
<p className="mt-2 text-sm font-semibold">
{previewQuery.data?.route_count ?? "—"} 条
</p>
</div>
<div className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">配置预览</p>
<p className="mt-2 text-sm text-muted-foreground">
在配置发布页查看完整 nginx 渲染结果
</p>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">连接与事件</CardTitle>
<CardDescription>worker、超时、请求体大小等运行参数</CardDescription>
</div>
<Button
size="sm"
disabled={savingSection === "runtime"}
onClick={() =>
saveMutation.mutate({
section: "runtime",
validator: validateRuntimeFields,
entries: entriesFromKeys(fields, [
"OpenRestyDefaultServerReturnStatus",
"OpenRestyWorkerProcesses",
"OpenRestyResolvers",
"OpenRestyWorkerConnections",
"OpenRestyWorkerRlimitNofile",
"OpenRestyEventsUse",
"OpenRestyEventsMultiAcceptEnabled",
"OpenRestyKeepaliveTimeout",
"OpenRestyKeepaliveRequests",
"OpenRestyClientHeaderTimeout",
"OpenRestyClientBodyTimeout",
"OpenRestyClientMaxBodySize",
"OpenRestyLargeClientHeaderBuffers",
"OpenRestySendTimeout",
]),
})
}
>
{savingSection === "runtime" ? (
<Loader2 className="size-4 animate-spin mr-1" />
) : (
<Save className="size-3.5 mr-1" />
)}
保存
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<FieldInput label="worker_processes" value={fields.OpenRestyWorkerProcesses} onChange={(v) => updateField("OpenRestyWorkerProcesses", v)} />
<FieldInput label="worker_connections" value={fields.OpenRestyWorkerConnections} onChange={(v) => updateField("OpenRestyWorkerConnections", v)} type="number" />
<FieldInput label="worker_rlimit_nofile" value={fields.OpenRestyWorkerRlimitNofile} onChange={(v) => updateField("OpenRestyWorkerRlimitNofile", v)} type="number" />
<div className="space-y-1.5">
<Label>events use</Label>
<Select value={fields.OpenRestyEventsUse} onValueChange={(v) => updateField("OpenRestyEventsUse", v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="epoll">epoll</SelectItem>
<SelectItem value="kqueue">kqueue</SelectItem>
<SelectItem value="poll">poll</SelectItem>
<SelectItem value="select">select</SelectItem>
</SelectContent>
</Select>
</div>
<ToggleRow label="multi_accept" checked={fields.OpenRestyEventsMultiAcceptEnabled} onChange={(v) => updateField("OpenRestyEventsMultiAcceptEnabled", v)} />
<FieldInput label="keepalive_timeout" value={fields.OpenRestyKeepaliveTimeout} onChange={(v) => updateField("OpenRestyKeepaliveTimeout", v)} type="number" />
<FieldInput label="client_max_body_size" value={fields.OpenRestyClientMaxBodySize} onChange={(v) => updateField("OpenRestyClientMaxBodySize", v)} />
<FieldInput label="resolvers" value={fields.OpenRestyResolvers} onChange={(v) => updateField("OpenRestyResolvers", v)} placeholder="1.1.1.1 8.8.8.8" />
<FieldInput label="default_server_return_status" value={fields.OpenRestyDefaultServerReturnStatus} onChange={(v) => updateField("OpenRestyDefaultServerReturnStatus", v)} type="number" />
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">反代缓冲与超时</CardTitle>
<CardDescription>upstream 连接、缓冲与 WebSocket/HTTP3 开关</CardDescription>
</div>
<Button
size="sm"
disabled={savingSection === "proxy"}
onClick={() =>
saveMutation.mutate({
section: "proxy",
validator: validateProxyFields,
entries: entriesFromKeys(fields, [
"OpenRestyProxyConnectTimeout",
"OpenRestyProxySendTimeout",
"OpenRestyProxyReadTimeout",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyProxyBuffers",
"OpenRestyProxyBufferSize",
"OpenRestyProxyBusyBuffersSize",
]),
})
}
>
{savingSection === "proxy" ? <Loader2 className="size-4 animate-spin mr-1" /> : <Save className="size-3.5 mr-1" />}
保存
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FieldInput label="proxy_connect_timeout" value={fields.OpenRestyProxyConnectTimeout} onChange={(v) => updateField("OpenRestyProxyConnectTimeout", v)} type="number" />
<FieldInput label="proxy_read_timeout" value={fields.OpenRestyProxyReadTimeout} onChange={(v) => updateField("OpenRestyProxyReadTimeout", v)} type="number" />
<ToggleRow label="websocket" checked={fields.OpenRestyWebsocketEnabled} onChange={(v) => updateField("OpenRestyWebsocketEnabled", v)} />
<ToggleRow label="http3" checked={fields.OpenRestyHTTP3Enabled} onChange={(v) => updateField("OpenRestyHTTP3Enabled", v)} />
<ToggleRow label="proxy_buffering" checked={fields.OpenRestyProxyBufferingEnabled} onChange={(v) => updateField("OpenRestyProxyBufferingEnabled", v)} />
<FieldInput label="proxy_buffers" value={fields.OpenRestyProxyBuffers} onChange={(v) => updateField("OpenRestyProxyBuffers", v)} />
</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">
<CardTitle className="text-base">压缩</CardTitle>
<Button
size="sm"
disabled={savingSection === "gzip"}
onClick={() =>
saveMutation.mutate({
section: "gzip",
validator: validateGzipFields,
entries: entriesFromKeys(fields, [
"OpenRestyGzipEnabled",
"OpenRestyGzipMinLength",
"OpenRestyGzipCompLevel",
]),
})
}
>
保存
</Button>
</CardHeader>
<CardContent className="space-y-4">
<ToggleRow label="gzip" checked={fields.OpenRestyGzipEnabled} onChange={(v) => updateField("OpenRestyGzipEnabled", v)} />
<FieldInput label="gzip_min_length" value={fields.OpenRestyGzipMinLength} onChange={(v) => updateField("OpenRestyGzipMinLength", v)} type="number" />
<FieldInput label="gzip_comp_level" value={fields.OpenRestyGzipCompLevel} onChange={(v) => updateField("OpenRestyGzipCompLevel", v)} type="number" />
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">缓存</CardTitle>
<CardDescription>单节点反代缓存优化场景</CardDescription>
</div>
<Button
size="sm"
disabled={savingSection === "cache"}
onClick={() =>
saveMutation.mutate({
section: "cache",
validator: validateCacheFields,
entries: entriesFromKeys(fields, [
"OpenRestyCacheEnabled",
"OpenRestyCachePath",
"OpenRestyCacheLevels",
"OpenRestyCacheInactive",
"OpenRestyCacheMaxSize",
"OpenRestyCacheKeyTemplate",
"OpenRestyCacheLockEnabled",
"OpenRestyCacheLockTimeout",
"OpenRestyCacheUseStale",
]),
})
}
>
保存
</Button>
</CardHeader>
<CardContent className="space-y-4">
<ToggleRow label="cache_enabled" checked={fields.OpenRestyCacheEnabled} onChange={(v) => updateField("OpenRestyCacheEnabled", v)} />
<FieldInput label="proxy_cache_path" value={fields.OpenRestyCachePath} onChange={(v) => updateField("OpenRestyCachePath", v)} disabled={!fields.OpenRestyCacheEnabled} />
<FieldInput label="levels" value={fields.OpenRestyCacheLevels} onChange={(v) => updateField("OpenRestyCacheLevels", v)} disabled={!fields.OpenRestyCacheEnabled} />
<FieldInput label="max_size" value={fields.OpenRestyCacheMaxSize} onChange={(v) => updateField("OpenRestyCacheMaxSize", v)} disabled={!fields.OpenRestyCacheEnabled} />
</CardContent>
</Card>
</div>
</div>
)
}
function FieldInput({
label,
value,
onChange,
type = "text",
placeholder,
disabled,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
placeholder?: string
disabled?: boolean
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-xs"
/>
</div>
)
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (value: boolean) => void
}) {
return (
<div className="flex items-center justify-between rounded-lg border border-dashed px-3 py-2">
<Label className="text-xs">{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
)
}
@@ -1,4 +1,9 @@
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
import type {
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
ProxyRouteItem,
ProxyRouteMutationPayload,
} from '@/lib/services/openflare';
export const proxyRouteConfigSections = [
{
@@ -88,3 +93,235 @@ export function getUpstreamSummary(route: ProxyRouteItem): string {
}
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
}
const originHostPattern =
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function linesFromTextarea(value: string) {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function validateDomains(domains: string[]) {
if (domains.length === 0) {
return '请至少填写一个域名';
}
const seen = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase();
const error = validateDomain(normalized);
if (error && normalized) {
return `域名格式不合法:${domain}`;
}
if (!normalized) {
continue;
}
if (seen.has(normalized)) {
return `域名重复:${domain}`;
}
seen.add(normalized);
}
return null;
}
export function parseOriginUrls(value: string) {
const urls = linesFromTextarea(value);
if (urls.length === 0) {
return { urls: [], error: '请至少填写一个上游地址' };
}
let sharedScheme = '';
for (const originUrl of urls) {
let parsed: URL;
try {
parsed = new URL(originUrl);
} catch {
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
urls: [],
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
};
}
if (!parsed.hostname) {
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
}
if (urls.length > 1) {
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
return {
urls: [],
error: '多上游模式暂不支持带路径或查询参数的地址',
};
}
if (!sharedScheme) {
sharedScheme = parsed.protocol;
} else if (sharedScheme !== parsed.protocol) {
return {
urls: [],
error: '同一站点的多个上游必须使用相同协议',
};
}
}
}
return { urls, error: null };
}
export function validateOriginHost(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
if (
normalized.includes('://') ||
/[/\\\s]/.test(normalized) ||
!originHostPattern.test(normalized)
) {
return '回源 Host 格式不合法';
}
return null;
}
export function parseCustomHeadersText(value: string) {
const lines = linesFromTextarea(value);
const headers: ProxyRouteCustomHeader[] = [];
for (const line of lines) {
const separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
return {
headers: [],
error: `自定义请求头格式不合法:${line}`,
};
}
const key = line.slice(0, separatorIndex).trim();
const headerValue = line.slice(separatorIndex + 1).trim();
if (!headerKeyPattern.test(key)) {
return {
headers: [],
error: `自定义请求头名称不合法:${key}`,
};
}
headers.push({ key, value: headerValue });
}
return { headers, error: null };
}
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
}
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m 或纯数字';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
return normalized === '0' ? '' : normalized;
}
export function validateCacheRules(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
return null;
}
if (rules.length === 0) {
return '当前缓存策略至少需要一条规则';
}
if (policy === 'suffix') {
for (const rule of rules) {
const normalized = rule.replace(/^\./, '');
if (!normalized || /[/\\\s]/.test(normalized)) {
return `缓存后缀格式不合法:${rule}`;
}
}
return null;
}
for (const rule of rules) {
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
return `缓存路径规则格式不合法:${rule}`;
}
}
return null;
}
export function buildPayloadFromRoute(
route: ProxyRouteItem,
overrides: Partial<ProxyRouteMutationPayload>,
): ProxyRouteMutationPayload {
const primaryOrigin =
route.upstream_type === 'pages'
? parseOriginUrl('http://127.0.0.1')
: parseOriginUrl(route.origin_url);
return {
site_name: route.site_name,
domain: route.primary_domain,
domains: route.domains,
origin_id: null,
origin_url: route.origin_url,
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_host: route.origin_host || '',
upstreams: route.upstream_list.slice(1),
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id,
cert_ids: route.cert_ids,
domain_cert_ids: route.domain_cert_ids,
redirect_http: route.redirect_http,
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
cache_policy: route.cache_policy || 'url',
cache_rules: route.cache_rule_list,
custom_headers: route.custom_header_list,
remark: route.remark || '',
pow_enabled: route.pow_enabled,
pow_config: JSON.stringify(route.pow_config),
basic_auth_enabled: route.basic_auth_enabled,
basic_auth_username: route.basic_auth_username,
basic_auth_password: route.basic_auth_password,
upstream_type: route.upstream_type,
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: route.tunnel_target_protocol || '',
pages_project_id: route.pages_project_id ?? null,
...overrides,
};
}
@@ -1,25 +1,331 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {useEffect, useState} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {ProxyRouteItem, ProxyRoutePoWConfig} from '@/lib/services/openflare';
import {defaultPowConfig} from '@/app/(main)/openflare/waf/components/helpers';
import {PowConfigPanel} from '@/app/(main)/openflare/waf/components/pow-config-panel';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const authSchema = z
.object({
auth_mode: z.enum(['none', 'basic', 'pow']),
basic_auth_username: z.string(),
basic_auth_password: z.string(),
pow_enabled: z.boolean(),
pow_difficulty: z.string(),
pow_algorithm: z.enum(['fast', 'slow']),
pow_session_ttl: z.string(),
pow_challenge_ttl: z.string(),
})
.superRefine((value, context) => {
if (value.auth_mode === 'pow') {
const difficulty = Number(value.pow_difficulty);
if (!Number.isFinite(difficulty) || difficulty < 1 || difficulty > 10) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_difficulty'],
message: '难度需为 1-10 的整数',
});
}
const sessionTtl = Number(value.pow_session_ttl);
if (!Number.isFinite(sessionTtl) || sessionTtl < 60) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_session_ttl'],
message: '会话 TTL 需大于等于 60 秒',
});
}
const challengeTtl = Number(value.pow_challenge_ttl);
if (!Number.isFinite(challengeTtl) || challengeTtl < 30) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_challenge_ttl'],
message: '挑战 TTL 需大于等于 30 秒',
});
}
}
if (value.auth_mode === 'basic') {
if (!value.basic_auth_username.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['basic_auth_username'],
message: '请输入账号',
});
}
if (!value.basic_auth_password.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['basic_auth_password'],
message: '请输入密码',
});
}
}
});
type AuthValues = z.infer<typeof authSchema>;
function resolveAuthMode(route: ProxyRouteItem): AuthValues['auth_mode'] {
if (route.basic_auth_enabled) {
return 'basic';
}
if (route.pow_enabled) {
return 'pow';
}
return 'none';
}
function buildPowConfig(
values: AuthValues,
route: ProxyRouteItem,
currentConfig: ProxyRoutePoWConfig,
): ProxyRoutePoWConfig {
return {
...currentConfig,
difficulty: Number(values.pow_difficulty),
algorithm: values.pow_algorithm,
session_ttl: Number(values.pow_session_ttl),
challenge_ttl: Number(values.pow_challenge_ttl),
};
}
interface AuthSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function AuthSection({ route }: AuthSectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">认证配置</CardTitle>
<CardDescription>配置基础鉴权访问,需要输入账号密码才能访问网站。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
基础认证:{route.basic_auth_enabled ? '已启用' : '未启用'};PoW:{route.pow_enabled ? '已启用' : '未启用'}
</p>
</CardContent>
</Card>
export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const [powConfig, setPowConfig] = useState<ProxyRoutePoWConfig>(
route.pow_config ?? defaultPowConfig,
);
}
const form = useForm<AuthValues>({
resolver: zodResolver(authSchema),
defaultValues: {
auth_mode: resolveAuthMode(route),
basic_auth_username: route.basic_auth_username || '',
basic_auth_password: route.basic_auth_password || '',
pow_enabled: route.pow_enabled,
pow_difficulty: String(route.pow_config?.difficulty ?? defaultPowConfig.difficulty),
pow_algorithm: route.pow_config?.algorithm ?? defaultPowConfig.algorithm,
pow_session_ttl: String(route.pow_config?.session_ttl ?? defaultPowConfig.session_ttl),
pow_challenge_ttl: String(route.pow_config?.challenge_ttl ?? defaultPowConfig.challenge_ttl),
},
});
useEffect(() => {
const nextPowConfig = route.pow_config ?? defaultPowConfig;
setPowConfig(nextPowConfig);
form.reset({
auth_mode: resolveAuthMode(route),
basic_auth_username: route.basic_auth_username || '',
basic_auth_password: route.basic_auth_password || '',
pow_enabled: route.pow_enabled,
pow_difficulty: String(nextPowConfig.difficulty),
pow_algorithm: nextPowConfig.algorithm,
pow_session_ttl: String(nextPowConfig.session_ttl),
pow_challenge_ttl: String(nextPowConfig.challenge_ttl),
});
}, [form, route]);
const authMode = form.watch('auth_mode');
return (
<SectionShell
title="认证配置"
description="配置基础鉴权或 PoW 防护,限制未授权访问。"
formId={proxyRouteFormIds.auth}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.auth}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const nextPowConfig = buildPowConfig(values, route, powConfig);
const powEnabled = values.auth_mode === 'pow' || values.pow_enabled;
await save(
{
basic_auth_enabled: values.auth_mode === 'basic',
basic_auth_username:
values.auth_mode === 'basic' ? values.basic_auth_username.trim() : '',
basic_auth_password:
values.auth_mode === 'basic' ? values.basic_auth_password.trim() : '',
pow_enabled: powEnabled,
pow_config: JSON.stringify(
values.auth_mode === 'pow' ? nextPowConfig : route.pow_config ?? defaultPowConfig,
),
},
'认证配置已保存',
);
})}
>
<FormField
control={form.control}
name="auth_mode"
render={({ field }) => (
<FormItem>
<FormLabel>认证模式</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">无认证</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="pow">PoW 防护</SelectItem>
</SelectContent>
</Select>
<FormDescription>同一站点仅启用一种认证模式。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{authMode === 'basic' ? (
<>
<FormField
control={form.control}
name="basic_auth_username"
render={({ field }) => (
<FormItem>
<FormLabel>账号</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic_auth_password"
render={({ field }) => (
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input type="text" placeholder="secret123" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
) : null}
{authMode === 'pow' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={powConfig}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setPowConfig(config);
}}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="pow_difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>难度</FormLabel>
<FormControl>
<Input placeholder="4" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pow_algorithm"
render={({ field }) => (
<FormItem>
<FormLabel>算法</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fast">fast</SelectItem>
<SelectItem value="slow">slow</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pow_session_ttl"
render={({ field }) => (
<FormItem>
<FormLabel>会话 TTL (秒)</FormLabel>
<FormControl>
<Input placeholder="600" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pow_challenge_ttl"
render={({ field }) => (
<FormItem>
<FormLabel>挑战 TTL (秒)</FormLabel>
<FormControl>
<Input placeholder="300" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
) : null}
</form>
</Form>
</SectionShell>
);
}
@@ -1,25 +1,197 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {linesFromTextarea, validateCacheRules} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const cacheSchema = z
.object({
cache_enabled: z.boolean(),
cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']),
cache_rules_text: z.string(),
})
.superRefine((value, context) => {
if (!value.cache_enabled) {
return;
}
const rules = linesFromTextarea(value.cache_rules_text);
const error = validateCacheRules(value.cache_policy, rules);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['cache_rules_text'],
message: error,
});
}
});
type CacheValues = z.infer<typeof cacheSchema>;
interface CacheSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function CacheSection({ route }: CacheSectionProps) {
export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const form = useForm<CacheValues>({
resolver: zodResolver(cacheSchema),
defaultValues: {
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
});
useEffect(() => {
form.reset({
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
}, [form, route]);
const watchedEnabled = form.watch('cache_enabled');
const watchedPolicy = form.watch('cache_policy');
const rulesHint =
watchedPolicy === 'suffix'
? '每行一个后缀,例如 jpg、css、js。'
: watchedPolicy === 'path_prefix'
? '每行一个路径前缀,例如 /assets、/static。'
: watchedPolicy === 'path_exact'
? '每行一个精确路径,例如 /robots.txt。'
: '按 URL 缓存时无需额外规则。';
const rulesPlaceholder =
watchedPolicy === 'suffix'
? 'jpg\ncss\njs'
: watchedPolicy === 'path_prefix'
? '/assets\n/static'
: watchedPolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存时无需额外规则';
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">缓存</CardTitle>
<CardDescription>配置站点缓存策略。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
缓存状态:{route.cache_enabled ? '已启用' : '未启用'};策略:{route.cache_policy || 'url'}
</p>
</CardContent>
</Card>
<SectionShell
title="缓存"
description="保留现有安全绕过逻辑,只对当前站点生效。"
formId={proxyRouteFormIds.cache}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.cache}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const rules = linesFromTextarea(values.cache_rules_text);
await save(
{
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
cache_rules:
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
},
'缓存设置已保存',
);
})}
>
<FormField
control={form.control}
name="cache_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie 的请求。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cache_policy"
render={({ field }) => (
<FormItem>
<FormLabel>缓存策略</FormLabel>
<Select
disabled={!watchedEnabled}
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="url">按 URL 缓存</SelectItem>
<SelectItem value="suffix">按后缀缓存</SelectItem>
<SelectItem value="path_prefix">按路径前缀缓存</SelectItem>
<SelectItem value="path_exact">按精确路径缓存</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cache_rules_text"
render={({ field }) => (
<FormItem>
<FormLabel>缓存规则</FormLabel>
<FormControl>
<Textarea
className="min-h-32"
disabled={!watchedEnabled || watchedPolicy === 'url'}
placeholder={rulesPlaceholder}
{...field}
/>
</FormControl>
<FormDescription>{rulesHint}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
}
@@ -0,0 +1,262 @@
'use client';
import {useId, useMemo} from 'react';
import {Link2, Minus, Plus} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {WebsiteService} from '@/lib/services/openflare';
import {validateDomain} from '../../components/helpers';
import type {DomainListRow} from '../helpers';
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
function buildSuggestionSources(domains: string[]) {
const values = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
if (!normalized) {
continue;
}
values.add(normalized);
const segments = normalized.split('.');
for (let index = 1; index < segments.length - 1; index += 1) {
values.add(segments.slice(index).join('.'));
}
}
return Array.from(values);
}
function buildDomainSuggestions(input: string, sources: string[], rows: DomainListRow[]) {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const existingDomains = new Set(
rows
.map((row) => row.domain.trim().toLowerCase())
.filter((row) => row && row !== normalizedInput),
);
const suggestions: string[] = [];
for (const source of sources) {
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
suggestions.push(source);
}
const separatorIndex = normalizedInput.lastIndexOf('.');
if (separatorIndex <= 0) {
continue;
}
const prefix = normalizedInput.slice(0, separatorIndex);
const suffixInput = normalizedInput.slice(separatorIndex + 1);
if (!suffixInput || source.startsWith(suffixInput)) {
suggestions.push(`${prefix}.${source}`);
}
}
return suggestions.filter((suggestion, index) => {
return (
suggestion !== normalizedInput &&
!existingDomains.has(suggestion) &&
suggestions.indexOf(suggestion) === index
);
});
}
interface DomainListInputProps {
rows: DomainListRow[];
onChange: (rows: DomainListRow[]) => void;
onBlur?: () => void;
suggestionSources?: string[];
certificates?: TlsCertificateItem[];
domainPlaceholder?: string;
}
export function DomainListInput({
rows,
onChange,
onBlur,
suggestionSources = [],
certificates = [],
domainPlaceholder = 'app.example.com',
}: DomainListInputProps) {
const listId = useId();
const safeRows = ensureRows(rows);
const normalizedSources = useMemo(
() => buildSuggestionSources(suggestionSources),
[suggestionSources],
);
const updateRows = (nextRows: DomainListRow[]) => {
onChange(ensureRows(nextRows));
};
const applyManagedDomainCertificate = async (index: number, domain: string) => {
const normalized = domain.trim().toLowerCase();
if (!normalized || validateDomain(normalized)) {
return;
}
try {
const result = await WebsiteService.match(normalized);
if (!result.matched || !result.candidate?.certificate_id) {
return;
}
const nextRows = safeRows.slice();
if (!nextRows[index].certificateId) {
nextRows[index] = {
...nextRows[index],
certificateId: String(result.candidate.certificate_id),
};
updateRows(nextRows);
}
} catch {
// Ignore match failures; user can still pick a certificate manually.
}
};
return (
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(row.domain, normalizedSources, safeRows).slice(
0,
4,
);
return (
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[40px_minmax(0,1fr)_220px] md:items-start">
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
disabled={safeRows.length === 1}
aria-label={`删除域名输入框 ${index + 1}`}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(safeRows.filter((_, rowIndex) => rowIndex !== index));
}}
>
<Minus className="size-3.5" />
</Button>
<div className="min-w-0 space-y-2">
<Input
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
onBlur={() => {
onBlur?.();
void applyManagedDomainCertificate(index, row.domain);
}}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: event.target.value,
};
updateRows(nextRows);
}}
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="rounded-full border px-2.5 py-0.5 text-[11px] text-muted-foreground transition hover:border-primary hover:text-foreground"
onClick={() => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
void applyManagedDomainCertificate(index, suggestion);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
<Select
value={row.certificateId || 'none'}
onValueChange={(value) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: value === 'none' ? '' : value,
};
updateRows(nextRows);
}}
>
<SelectTrigger className="h-9 w-full">
<SelectValue
placeholder={certificates.length === 0 ? '暂无可选证书' : '选择证书'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{certificates.length === 0 ? '暂无可选证书' : '不绑定证书'}
</SelectItem>
{certificates.map((certificate) => (
<SelectItem key={certificate.id} value={String(certificate.id)}>
{certificate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus className="size-3.5" />
添加域名
</Button>
<Badge variant="outline" className="gap-1 text-[10px] font-normal">
<Link2 className="size-3" />
输入域名后将自动匹配托管域名证书
</Badge>
</div>
</div>
);
}
@@ -1,26 +1,237 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {useEffect, useMemo} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useQuery} from '@tanstack/react-query';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Switch} from '@/components/ui/switch';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
import {validateDomains} from '../../components/helpers';
import {
buildDomainCertificateIDs,
buildDomainRows,
normalizeSelectedCertificateIDs,
proxyRouteFormIds,
} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {DomainListInput} from './domain-list-input';
import {SectionShell} from './section-shell';
const domainSettingsSchema = z
.object({
site_name: z
.string()
.trim()
.min(1, '请输入站点标识')
.max(255, '站点标识不能超过 255 个字符'),
domain_rows: z
.array(
z.object({
domain: z.string(),
certificateId: z.string(),
}),
)
.min(1),
enabled: z.boolean(),
redirect_http: z.boolean(),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const error = validateDomains(domains);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: error,
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
interface DomainSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function DomainSection({ route }: DomainSectionProps) {
export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const certificatesQuery = useQuery({
queryKey: ['openflare', 'tls-certificates'],
queryFn: () => TlsCertificateService.list(),
});
const managedDomainsQuery = useQuery({
queryKey: ['openflare', 'managed-domains'],
queryFn: () => WebsiteService.list(),
});
const form = useForm<DomainSettingsValues>({
resolver: zodResolver(domainSettingsSchema),
defaultValues: {
site_name: route.site_name,
domain_rows: buildDomainRows(route),
enabled: route.enabled,
redirect_http: route.redirect_http,
},
});
useEffect(() => {
form.reset({
site_name: route.site_name,
domain_rows: buildDomainRows(route),
enabled: route.enabled,
redirect_http: route.redirect_http,
});
}, [form, route]);
const domainSuggestionSources = useMemo(
() => [
...(route.domains ?? []),
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[managedDomainsQuery.data, route.domains],
);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(form.watch('domain_rows'));
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">域名设置</CardTitle>
<CardDescription>维护站点标识、域名列表和证书绑定。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
当前主域名:{route.primary_domain || route.domain}
{route.domains.length > 1 ? `(共 ${route.domains.length} 个域名)` : ''}
</p>
</CardContent>
</Card>
<SectionShell
title="域名设置"
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
formId={proxyRouteFormIds.domains}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.domains}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const certIDs = normalizeSelectedCertificateIDs(values.domain_rows);
await save(
{
site_name: values.site_name.trim(),
domain: domains[0],
domains,
enabled: values.enabled,
enable_https: certIDs.length > 0,
cert_id: certIDs[0] ?? null,
cert_ids: certIDs,
domain_cert_ids: domainCertIDs,
redirect_http: certIDs.length > 0 ? values.redirect_http : false,
},
'域名设置已保存',
);
})}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>启用站点</FormLabel>
<FormDescription>关闭后会保留配置,但不会参与发布。</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="site_name"
render={({ field }) => (
<FormItem>
<FormLabel>站点标识</FormLabel>
<FormControl>
<Input placeholder="marketing-site" {...field} />
</FormControl>
<FormDescription>建议使用稳定、可读的业务标识,不必与域名完全一致。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain_rows"
render={({ field }) => (
<FormItem>
<FormLabel>域名列表</FormLabel>
<FormControl>
<DomainListInput
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={domainSuggestionSources}
certificates={certificatesQuery.data ?? []}
/>
</FormControl>
<FormDescription>
每行配置一个域名。可为不同域名选择不同证书,托管域名将自动匹配证书。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirect_http"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>HTTP 自动跳转到 HTTPS</FormLabel>
<FormDescription>
{selectedCertificateIDs.length > 0
? '开启后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
disabled={selectedCertificateIDs.length === 0}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -1,26 +1,150 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {normalizeLimitRate, validateLimitRate} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const rateLimitSchema = z
.object({
limit_conn_per_server: z.string(),
limit_conn_per_ip: z.string(),
limit_rate: z.string(),
})
.superRefine((value, context) => {
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) {
const rawValue = value[field].trim();
if (!rawValue) {
continue;
}
if (!/^\d+$/.test(rawValue)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: '请输入大于等于 0 的整数',
});
}
}
const limitRateError = validateLimitRate(value.limit_rate);
if (limitRateError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['limit_rate'],
message: limitRateError,
});
}
});
type RateLimitValues = z.infer<typeof rateLimitSchema>;
interface LimitsSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function LimitsSection({ route }: LimitsSectionProps) {
export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const form = useForm<RateLimitValues>({
resolver: zodResolver(rateLimitSchema),
defaultValues: {
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '',
limit_rate: route.limit_rate || '',
},
});
useEffect(() => {
form.reset({
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '',
limit_rate: route.limit_rate || '',
});
}, [form, route]);
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">流量限制</CardTitle>
<CardDescription>设置连接数和限速。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
当前限速:{route.limit_rate || '未配置'};单 IP 连接数:{route.limit_conn_per_ip};单 Server
连接数:{route.limit_conn_per_server}
</p>
</CardContent>
</Card>
<SectionShell
title="流量限制"
description="站点限流,空值或 0 表示关闭。"
formId={proxyRouteFormIds.limits}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.limits}
className="grid gap-5 md:grid-cols-2"
onSubmit={form.handleSubmit(async (values) => {
await save(
{
limit_conn_per_server: Number(values.limit_conn_per_server.trim() || '0'),
limit_conn_per_ip: Number(values.limit_conn_per_ip.trim() || '0'),
limit_rate: normalizeLimitRate(values.limit_rate),
},
'流量限制已保存',
);
})}
>
<FormField
control={form.control}
name="limit_conn_per_server"
render={({ field }) => (
<FormItem>
<FormLabel>并发限制</FormLabel>
<FormControl>
<Input placeholder="120" {...field} />
</FormControl>
<FormDescription>限制当前站点最大并发连接数。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="limit_conn_per_ip"
render={({ field }) => (
<FormItem>
<FormLabel>单 IP 限制</FormLabel>
<FormControl>
<Input placeholder="12" {...field} />
</FormControl>
<FormDescription>限制单个 IP 的最大并发数。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="limit_rate"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>限速</FormLabel>
<FormControl>
<Input placeholder="512k/1m" {...field} />
</FormControl>
<FormDescription>限制单请求带宽,例如 512k 或 1m。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -1,25 +1,438 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useQuery} from '@tanstack/react-query';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {getUpstreamSummary} from '../../components/helpers';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {NodeService, PagesService} from '@/lib/services/openflare';
import {
customHeadersToText,
parseCustomHeadersText,
parseOriginUrl,
parseOriginUrls,
validateOriginHost,
} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const reverseProxySchema = z
.object({
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
origin_host: z.string(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
custom_headers_text: z.string(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
} else if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const originHostError = validateOriginHost(value.origin_host);
if (originHostError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_host'],
message: originHostError,
});
}
const { error: headerError } = parseCustomHeadersText(value.custom_headers_text);
if (headerError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers_text'],
message: headerError,
});
}
});
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
interface ProxySectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function ProxySection({ route }: ProxySectionProps) {
export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const tunnelsQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const pagesProjectsQuery = useQuery({
queryKey: ['openflare', 'pages-projects'],
queryFn: () => PagesService.listProjects(),
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const form = useForm<ReverseProxyValues>({
resolver: zodResolver(reverseProxySchema),
defaultValues: {
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
},
});
useEffect(() => {
form.reset({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
});
}, [form, route]);
const upstreamType = form.watch('upstream_type');
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">反向代理</CardTitle>
<CardDescription>配置主回源和上游地址。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">当前上游:{getUpstreamSummary(route)}</p>
</CardContent>
</Card>
<SectionShell
title="反向代理"
description="配置请求回源上游的策略与地址。"
formId={proxyRouteFormIds.proxy}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.proxy}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
let originUrl = '';
let originScheme: 'http' | 'https' = 'http';
let originAddress = '';
let originPort = '';
let originUri = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = urls[0];
originScheme = primaryOrigin.scheme;
originAddress = primaryOrigin.address;
originPort = primaryOrigin.port;
originUri = primaryOrigin.uri;
upstreams = urls.slice(1);
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
originScheme = values.tunnel_target_protocol as 'http' | 'https';
originAddress = values.tunnel_target_addr || '';
} else {
originUrl = 'http://127.0.0.1';
originScheme = 'http';
originAddress = '127.0.0.1';
originPort = '80';
}
const { headers } = parseCustomHeadersText(values.custom_headers_text);
await save(
{
origin_id: null,
origin_url: originUrl,
origin_scheme: originScheme,
origin_address: originAddress,
origin_port: originPort,
origin_uri: originUri,
origin_host: values.origin_host.trim(),
upstreams,
custom_headers: headers,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol:
values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
},
'反向代理设置已保存',
);
})}
>
<FormField
control={form.control}
name="upstream_type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>回源方式</FormLabel>
<div className="flex flex-wrap gap-4">
{(
[
['direct', '直连上游'],
['tunnel', '内网穿透 (Tunnel)'],
['pages', 'Pages 静态站点'],
] as const
).map(([value, label]) => (
<label key={value} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
value={value}
checked={field.value === value}
onChange={() => field.onChange(value)}
className="size-4 accent-primary"
/>
<Label className="font-normal">{label}</Label>
</label>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{upstreamType === 'direct' ? (
<FormField
control={form.control}
name="origin_urls_text"
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Textarea
className="min-h-40 font-mono text-xs"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...field}
/>
</FormControl>
<FormDescription>
每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{upstreamType === 'tunnel' ? (
<div className="space-y-4 rounded-lg border border-dashed bg-muted/30 p-4">
<FormField
control={form.control}
name="tunnel_id"
render={({ field }) => (
<FormItem>
<FormLabel>选择内网穿透隧道</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
{tunnelClients.map((tunnel) => (
<SelectItem key={tunnel.id} value={String(tunnel.id)}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>将请求转发到该隧道连接的客户端节点。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tunnel_target_protocol"
render={({ field }) => (
<FormItem>
<FormLabel>内网服务协议</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tunnel_target_addr"
render={({ field }) => (
<FormItem>
<FormLabel>内网服务地址</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1:8080" {...field} />
</FormControl>
<FormDescription>例如: 127.0.0.1:8080 或 192.168.1.10:80</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{upstreamType === 'pages' ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4">
<FormField
control={form.control}
name="pages_project_id"
render={({ field }) => (
<FormItem>
<FormLabel>选择 Pages 项目</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
{pagesProjects.map((project) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.name} ({project.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>仅显示已启用且已有激活部署的 Pages 项目。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
<FormField
control={form.control}
name="origin_host"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Host Header</FormLabel>
<FormControl>
<Input placeholder="origin.example.internal" {...field} />
</FormControl>
<FormDescription>留空时默认透传访问域名 $host。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_headers_text"
render={({ field }) => (
<FormItem>
<FormLabel>自定义请求头</FormLabel>
<FormControl>
<Textarea
className="min-h-32 font-mono text-xs"
placeholder={'X-Trace-Id: $request_id\nX-Site: marketing'}
{...field}
/>
</FormControl>
<FormDescription>每行一条,格式为 Key: Value。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem>
<FormLabel>备注</FormLabel>
<FormControl>
<Textarea placeholder="例如:多活回源,优先使用上海入口" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -1,48 +1,183 @@
'use client';
import Link from 'next/link';
import {ArrowLeft, Route, Save, Upload} from 'lucide-react';
import {useCallback, useState} from 'react';
import {ArrowLeft, Loader2, Route, Save, Upload} from 'lucide-react';
import {toast} from 'sonner';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {DiffDialog} from '@/app/(main)/openflare/config-versions/components/diff-dialog';
import type {ConfigDiffResult, ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
import {ConfigVersionService} from '@/lib/services/openflare';
import {getErrorMessage} from '../../components/helpers';
import {submitProxyRouteSectionForm} from '../helpers';
function hasConfigDiff(diff: ConfigDiffResult) {
return (
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
diff.waf_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
interface RouteHeaderProps {
route: ProxyRouteItem;
activeSection: ProxyRouteConfigSection;
saving?: boolean;
}
export function RouteHeader({ route }: RouteHeaderProps) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-0 text-xs" asChild>
<Link href="/openflare/proxy-routes">
<ArrowLeft className="size-3.5" />
返回规则列表
</Link>
</Button>
export function RouteHeader({ route, activeSection, saving = false }: RouteHeaderProps) {
const [diffOpen, setDiffOpen] = useState(false);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
const [diffLoading, setDiffLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [publishing, setPublishing] = useState(false);
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Route className="size-5 text-primary" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{route.site_name}</h1>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
{route.enabled ? '已启用' : '已停用'}
</Badge>
const handleSave = useCallback(() => {
const submitted = submitProxyRouteSectionForm(activeSection);
if (!submitted) {
toast.error('当前分区无法保存');
}
}, [activeSection]);
const loadDiff = useCallback(async () => {
setDiffLoading(true);
setDiffError(null);
try {
const diffData = await ConfigVersionService.diff();
setDiff(diffData);
return diffData;
} catch (error) {
const message = getErrorMessage(error);
setDiffError(message);
return null;
} finally {
setDiffLoading(false);
}
}, []);
const handlePublishClick = useCallback(async () => {
setDiffOpen(true);
const diffData = diff ?? (await loadDiff());
if (!diffData) {
return;
}
if (!hasConfigDiff(diffData)) {
toast.info('当前配置与激活版本一致,无需发布');
return;
}
setPublishConfirmOpen(true);
}, [diff, loadDiff]);
const handlePublish = useCallback(async () => {
setPublishing(true);
try {
const version = await ConfigVersionService.publish();
toast.success('发布成功', { description: `版本 ${version.version}` });
setPublishConfirmOpen(false);
setDiffOpen(false);
setDiff(null);
} catch (error) {
toast.error('发布失败', { description: getErrorMessage(error) });
} finally {
setPublishing(false);
}
}, []);
return (
<>
<div className="space-y-4">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-0 text-xs" asChild>
<Link href="/openflare/proxy-routes">
<ArrowLeft className="size-3.5" />
返回规则列表
</Link>
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Route className="size-5 text-primary" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{route.site_name}</h1>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
{route.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
className="h-8 gap-1.5 text-xs"
disabled={saving}
onClick={handleSave}
>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
{saving ? '保存中...' : '保存当前分区'}
</Button>
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={publishing || diffLoading}
onClick={() => void handlePublishClick()}
>
{publishing || diffLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Upload className="size-3.5" />
)}
发布配置
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" className="h-8 gap-1.5 text-xs" disabled>
<Save className="size-3.5" />
保存(待实现)
</Button>
<Button size="sm" className="h-8 gap-1.5 text-xs" disabled>
<Upload className="size-3.5" />
发布配置(待实现)
</Button>
</div>
</div>
</div>
<DiffDialog
open={diffOpen}
onOpenChange={(open) => {
setDiffOpen(open);
if (!open) {
setPublishConfirmOpen(false);
}
}}
diff={diff}
loading={diffLoading}
error={diffError}
/>
<AlertDialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
<AlertDialogDescription>
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button onClick={() => void handlePublish()} disabled={publishing}>
{publishing ? <Loader2 className="size-4 animate-spin" /> : '确认发布'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
}
@@ -0,0 +1,37 @@
'use client';
import type {ReactNode} from 'react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
interface SectionShellProps {
title: string;
description: string;
formId: string;
saving?: boolean;
children: ReactNode;
}
export function SectionShell({
title,
description,
formId,
saving = false,
children,
}: SectionShellProps) {
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div className="space-y-1">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<Button type="submit" form={formId} size="sm" className="h-8 shrink-0 text-xs" disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
@@ -1,23 +1,148 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {useEffect, useMemo, useState} from 'react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {WafService} from '@/lib/services/openflare';
import {cn} from '@/lib/utils';
import {getErrorMessage} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {SectionShell} from './section-shell';
interface WafSectionProps {
route: ProxyRouteItem;
onSavingChange?: (saving: boolean) => void;
}
export function WafSection({ route }: WafSectionProps) {
export function WafSection({ route, onSavingChange }: WafSectionProps) {
const queryClient = useQueryClient();
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const wafQuery = useQuery({
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
queryFn: () => WafService.listSiteRuleGroups(route.id),
});
const wafMutation = useMutation({
mutationFn: (ids: number[]) => WafService.updateSiteRuleGroups(route.id, ids),
onMutate: () => {
onSavingChange?.(true);
},
onSettled: () => {
onSavingChange?.(false);
},
onSuccess: async (result) => {
setSelectedIDs(result.applied_ids);
toast.success('WAF 规则组已更新');
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
}),
queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
]);
},
onError: (error) => {
toast.error('保存失败', { description: getErrorMessage(error) });
},
});
useEffect(() => {
if (wafQuery.data) {
setSelectedIDs(wafQuery.data.applied_ids);
}
}, [wafQuery.data]);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">WAF</CardTitle>
<CardDescription>绑定 WAF 规则组,并查看当前站点生效策略。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">站点 ID:{route.id}</p>
</CardContent>
</Card>
<SectionShell
title="WAF"
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。"
formId={proxyRouteFormIds.waf}
saving={wafMutation.isPending}
>
{wafQuery.isLoading ? (
<LoadingStateWithBorder description="加载 WAF 规则组..." />
) : wafQuery.isError ? (
<ErrorInline
message={getErrorMessage(wafQuery.error)}
onRetry={() => void wafQuery.refetch()}
/>
) : (
<form
id={proxyRouteFormIds.waf}
className="space-y-5"
onSubmit={(event) => {
event.preventDefault();
wafMutation.mutate(selectedIDs);
}}
>
{wafQuery.data?.global_rule_group ? (
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Global Rule Group
</p>
<p className="mt-1 text-sm font-semibold">
{wafQuery.data.global_rule_group.name}
</p>
</div>
<Badge variant="outline">始终生效</Badge>
</div>
</div>
) : null}
<div className="grid gap-3 md:grid-cols-2">
{(wafQuery.data?.rule_groups ?? []).map((group) => (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition',
selectedSet.has(group.id) && 'border-primary bg-muted/40',
)}
>
<input
type="checkbox"
checked={selectedSet.has(group.id)}
onChange={(event) => {
const checked = event.target.checked;
setSelectedIDs((current) =>
checked
? [...current, group.id].sort((left, right) => left - right)
: current.filter((id) => id !== group.id),
);
}}
className="mt-1 size-4 rounded border accent-primary"
/>
<span className="min-w-0">
<span className="block text-sm font-semibold">{group.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{group.enabled ? '启用中' : '已停用'} ·{' '}
{group.ip_whitelist.length +
group.ip_blacklist.length +
group.country_whitelist.length +
group.country_blacklist.length}{' '}
条规则
</span>
</span>
</label>
))}
</div>
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
<EmptyStateWithBorder description="暂无自定义 WAF 规则组" />
) : null}
</form>
)}
</SectionShell>
);
}
}
@@ -0,0 +1,92 @@
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
export type DomainListRow = {
domain: string;
certificateId: string;
};
export const proxyRouteFormIds: Record<ProxyRouteConfigSection, string> = {
domains: 'proxy-route-domains-form',
limits: 'proxy-route-limits-form',
proxy: 'proxy-route-proxy-form',
cache: 'proxy-route-cache-form',
waf: 'proxy-route-waf-form',
auth: 'proxy-route-auth-form',
};
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
export function buildDomainRowsFromRoute(
domains: string[],
domainCertIDs: number[],
certIDs: number[],
): DomainListRow[] {
if (domains.length === 0) {
return ensureRows([]);
}
if (domainCertIDs.length === domains.length) {
return domains.map((domain, index) => ({
domain,
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
}));
}
if (certIDs.length === 0) {
return domains.map((domain) => ({ domain, certificateId: '' }));
}
if (certIDs.length === 1) {
return domains.map((domain) => ({
domain,
certificateId: String(certIDs[0]),
}));
}
return domains.map((domain, index) => ({
domain,
certificateId: certIDs[index] ? String(certIDs[index]) : '',
}));
}
export function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.filter((item) => item.domain.trim() !== '')
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
export function buildDomainCertificateIDs(rows: DomainListRow[]) {
return rows
.filter((item) => item.domain.trim() !== '')
.map((item) => {
const certificateID = Number(item.certificateId);
return Number.isFinite(certificateID) && certificateID > 0 ? certificateID : 0;
});
}
export function buildDomainRows(route: ProxyRouteItem) {
const selectedCertIDs =
route.cert_ids.length > 0
? route.cert_ids
: route.cert_id
? [route.cert_id]
: [];
return buildDomainRowsFromRoute(route.domains, route.domain_cert_ids, selectedCertIDs);
}
export function submitProxyRouteSectionForm(section: ProxyRouteConfigSection) {
const form = document.getElementById(proxyRouteFormIds[section]);
if (form instanceof HTMLFormElement) {
form.requestSubmit();
return true;
}
return false;
}
@@ -0,0 +1,40 @@
'use client';
import {useCallback, useState} from 'react';
import {toast} from 'sonner';
import type {ProxyRouteItem, ProxyRouteMutationPayload} from '@/lib/services/openflare';
import {ProxyRouteService} from '@/lib/services/openflare';
import {buildPayloadFromRoute, getErrorMessage} from '../../components/helpers';
export function useRouteSectionSave(
route: ProxyRouteItem,
onRouteUpdate: (route: ProxyRouteItem) => void,
onSavingChange?: (saving: boolean) => void,
) {
const [saving, setSaving] = useState(false);
const save = useCallback(
async (overrides: Partial<ProxyRouteMutationPayload>, message: string) => {
setSaving(true);
onSavingChange?.(true);
try {
const updated = await ProxyRouteService.update(
route.id,
buildPayloadFromRoute(route, overrides),
);
onRouteUpdate(updated);
toast.success(message);
} catch (error) {
toast.error('保存失败', { description: getErrorMessage(error) });
} finally {
setSaving(false);
onSavingChange?.(false);
}
},
[onRouteUpdate, onSavingChange, route],
);
return { saving, save };
}
@@ -26,6 +26,7 @@ export function ProxyRouteDetailPageClient() {
const [route, setRoute] = useState<ProxyRouteItem | null>(null);
const [loading, setLoading] = useState(true);
const [sectionSaving, setSectionSaving] = useState(false);
const handleSectionChange = useCallback(
(section: ProxyRouteConfigSection) => {
@@ -36,6 +37,10 @@ export function ProxyRouteDetailPageClient() {
[router, searchParams],
);
const handleRouteUpdate = useCallback((updatedRoute: ProxyRouteItem) => {
setRoute(updatedRoute);
}, []);
useEffect(() => {
if (!Number.isFinite(routeId) || routeId <= 0) {
setLoading(false);
@@ -99,9 +104,15 @@ export function ProxyRouteDetailPageClient() {
);
}
const sectionProps = {
route,
onRouteUpdate: handleRouteUpdate,
onSavingChange: setSectionSaving,
};
return (
<div className="py-6 px-1 space-y-6">
<RouteHeader route={route} />
<RouteHeader route={route} activeSection={activeSection} saving={sectionSaving} />
<Tabs
value={activeSection}
@@ -121,24 +132,24 @@ export function ProxyRouteDetailPageClient() {
</TabsList>
<TabsContent value="domains" className="focus-visible:outline-none">
<DomainSection route={route} />
<DomainSection {...sectionProps} />
</TabsContent>
<TabsContent value="limits" className="focus-visible:outline-none">
<LimitsSection route={route} />
<LimitsSection {...sectionProps} />
</TabsContent>
<TabsContent value="proxy" className="focus-visible:outline-none">
<ProxySection route={route} />
<ProxySection {...sectionProps} />
</TabsContent>
<TabsContent value="cache" className="focus-visible:outline-none">
<CacheSection route={route} />
<CacheSection {...sectionProps} />
</TabsContent>
<TabsContent value="waf" className="focus-visible:outline-none">
<WafSection route={route} />
<WafSection route={route} onSavingChange={setSectionSaving} />
</TabsContent>
<TabsContent value="auth" className="focus-visible:outline-none">
<AuthSection route={route} />
<AuthSection {...sectionProps} />
</TabsContent>
</Tabs>
</div>
);
}
}
@@ -0,0 +1,268 @@
import type {ProxyRoutePoWConfig, WAFRuleGroup, WAFRuleGroupPayload,} from '@/lib/services/openflare';
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
export type RuleListType = 'whitelist' | 'blacklist';
export type RuleDimension = 'ip' | 'ip_group' | 'country';
export type ListFieldKey =
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist';
export type CountryOption = {
code: string;
zhName: string;
label: string;
searchText: string;
};
export type RuleModalState = {
open: boolean;
listType: RuleListType;
dimension: RuleDimension;
ipValue: string;
ipGroupIDs: number[];
countryValues: string[];
};
export type RuleListRenderable = Pick<
WAFRuleGroupPayload,
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist'
| 'region_whitelist'
| 'region_blacklist'
>;
export const defaultPowConfig: ProxyRoutePoWConfig = {
difficulty: 4,
algorithm: 'fast',
session_ttl: 600,
challenge_ttl: 300,
whitelist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
blacklist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
};
export const emptyRuleGroupDraft: WAFRuleGroupPayload = {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: defaultPowConfig,
remark: '',
};
export const defaultRuleModalState: RuleModalState = {
open: false,
listType: 'blacklist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
export const wafTabItems: Array<{ id: WAFTab; label: string }> = [
{ id: 'basic', label: '基本信息' },
{ id: 'lists', label: '黑白名单' },
{ id: 'pow', label: 'PoW' },
{ id: 'block', label: '拦截返回' },
];
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
}
export function textToList(text: string) {
return text
.split(/[\n,,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
export function parseTextareaList(text: string) {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function normalizeItems(items: string[]) {
return Array.from(
new Set(items.map((item) => item.trim()).filter(Boolean)),
).sort((left, right) => left.localeCompare(right));
}
export function buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
if (!group) {
return { ...emptyRuleGroupDraft };
}
return {
name: group.name,
enabled: group.enabled,
block_status_code: group.block_status_code || 418,
block_response_body: group.block_response_body ?? '',
ip_whitelist: group.ip_whitelist ?? [],
ip_blacklist: group.ip_blacklist ?? [],
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
country_whitelist: group.country_whitelist ?? [],
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
region_blacklist: group.region_blacklist ?? [],
pow_enabled: group.pow_enabled ?? false,
pow_config: group.pow_config ?? defaultPowConfig,
remark: group.remark ?? '',
};
}
export function countRuleEntries(group: RuleListRenderable) {
return (
group.ip_whitelist.length +
group.ip_blacklist.length +
group.ip_whitelist_group_ids.length +
group.ip_blacklist_group_ids.length +
group.country_whitelist.length +
group.country_blacklist.length +
group.region_whitelist.length +
group.region_blacklist.length
);
}
export function buildCountryOptions() {
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
const options: CountryOption[] = [];
for (let first = 65; first <= 90; first += 1) {
for (let second = 65; second <= 90; second += 1) {
const code = String.fromCharCode(first, second);
const zhName = zhDisplayNames.of(code);
const enName = enDisplayNames.of(code);
if (
!zhName ||
zhName === code ||
/未知/.test(zhName) ||
!enName ||
enName === code ||
/Unknown/.test(enName)
) {
continue;
}
options.push({
code,
zhName,
label: `${code} ${zhName}`,
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
});
}
}
return options.sort((left, right) => left.code.localeCompare(right.code));
}
export function getListFieldKey(
listType: RuleListType,
dimension: RuleDimension,
): ListFieldKey {
if (dimension === 'ip') {
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
}
if (dimension === 'ip_group') {
return listType === 'whitelist'
? 'ip_whitelist_group_ids'
: 'ip_blacklist_group_ids';
}
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
}
export function updateDraftList(
draft: WAFRuleGroupPayload,
key: ListFieldKey,
updater: (items: string[]) => string[],
) {
switch (key) {
case 'ip_whitelist':
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
case 'ip_blacklist':
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
case 'ip_whitelist_group_ids':
return {
...draft,
ip_whitelist_group_ids: updater(
draft.ip_whitelist_group_ids.map(String),
).map(Number),
};
case 'ip_blacklist_group_ids':
return {
...draft,
ip_blacklist_group_ids: updater(
draft.ip_blacklist_group_ids.map(String),
).map(Number),
};
case 'country_whitelist':
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
case 'country_blacklist':
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
}
}
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
return labelMap.get(code) ?? code;
}
export function parseAutomaticConfig(text: string): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
return parsed as Record<string, unknown>;
}
export const ipGroupTypeLabels = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
} as const;
export const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
];
@@ -0,0 +1,453 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Plus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {
WAFIPGroup,
WAFIPGroupPayload,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
} from '@/lib/services/openflare';
import {automaticPresetRules, listToText, parseAutomaticConfig, parseTextareaList,} from './helpers';
const ipGroupSchema = z
.object({
name: z.string().trim().min(1, '请输入 IP 组名称').max(255),
type: z.enum(['manual', 'automatic', 'subscription']),
enabled: z.boolean(),
ip_list_text: z.string(),
auto_config_text: z.string(),
subscription_url: z.string(),
subscription_format: z.enum(['text', 'json']),
subscription_mapping_rule: z.string(),
sync_interval_minutes: z.number().int().min(5),
remark: z.string().max(500),
})
.superRefine((value, context) => {
if (value.type === 'subscription' && !value.subscription_url.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['subscription_url'],
message: '订阅类型需要填写订阅 URL',
});
}
if (value.type === 'automatic') {
try {
parseAutomaticConfig(value.auto_config_text);
} catch (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['auto_config_text'],
message: error instanceof Error ? error.message : '自动配置格式错误',
});
}
}
});
type IPGroupFormValues = z.infer<typeof ipGroupSchema>;
const defaultValues: IPGroupFormValues = {
name: '',
type: 'manual',
enabled: true,
ip_list_text: '',
auto_config_text: '{}',
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
sync_interval_minutes: 1440,
remark: '',
};
function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
if (!group) return defaultValues;
return {
name: group.name,
type: group.type,
enabled: group.enabled,
ip_list_text: listToText(group.ip_list),
auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2),
subscription_url: group.subscription_url ?? '',
subscription_format: group.subscription_format ?? 'text',
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
sync_interval_minutes: group.sync_interval_minutes || 1440,
remark: group.remark ?? '',
};
}
function buildPayload(values: IPGroupFormValues): WAFIPGroupPayload {
const autoConfig =
values.type === 'automatic'
? parseAutomaticConfig(values.auto_config_text)
: {};
return {
name: values.name.trim(),
type: values.type,
enabled: values.enabled,
ip_list: parseTextareaList(values.ip_list_text),
auto_config: autoConfig,
subscription_url: values.subscription_url.trim(),
subscription_format: values.subscription_format,
subscription_mapping_rule: values.subscription_mapping_rule.trim(),
sync_interval_minutes: values.sync_interval_minutes,
remark: values.remark.trim(),
};
}
function appendAutomaticPresetRule(
autoConfigText: string,
rule: (typeof automaticPresetRules)[number],
) {
const config = parseAutomaticConfig(autoConfigText);
const rules = Array.isArray(config.rules) ? config.rules : [];
const exists = rules.some(
(item) =>
item &&
typeof item === 'object' &&
'expr' in item &&
(item as { expr?: unknown }).expr === rule.expr,
);
const nextRules = exists ? rules : [...rules, rule];
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number' ? config.lookback_minutes : 60,
...config,
rules: nextRules,
},
null,
2,
);
}
interface IPGroupDialogProps {
open: boolean;
group: WAFIPGroup | null;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: WAFIPGroupPayload) => Promise<void>;
}
export function IPGroupDialog({
open,
group,
submitting,
onOpenChange,
onSubmit,
}: IPGroupDialogProps) {
const form = useForm<IPGroupFormValues>({
resolver: zodResolver(ipGroupSchema),
defaultValues,
});
const type = form.watch('type');
useEffect(() => {
if (!open) return;
form.reset(buildFormValues(group));
}, [form, group, open]);
const handleSubmit = form.handleSubmit(async (values) => {
try {
await onSubmit(buildPayload(values));
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
});
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{group ? `编辑 ${group.name}` : '新建 IP 组'}</DialogTitle>
<DialogDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>IP 组名称</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>类型</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as WAFIPGroupType)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="manual">手动</SelectItem>
<SelectItem value="automatic">自动</SelectItem>
<SelectItem value="subscription">订阅</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4 md:col-span-2">
<div className="space-y-0.5">
<FormLabel>启用 IP 组</FormLabel>
<FormDescription>
关闭后保留配置,但发布时不会展开到 WAF 运行时名单。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>备注</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{type === 'subscription' ? (
<div className="grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4">
<FormField
control={form.control}
name="subscription_url"
render={({ field }) => (
<FormItem>
<FormLabel>订阅 URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/ip-list.txt" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_format"
render={({ field }) => (
<FormItem>
<FormLabel>订阅格式</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as WAFIPGroupSubscriptionFormat)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">文本列表</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sync_interval_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
min={5}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<FormDescription>最小 5 分钟,默认 1440 分钟。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_mapping_rule"
render={({ field }) => (
<FormItem>
<FormLabel>JSON 映射规则</FormLabel>
<FormControl>
<Input
disabled={form.watch('subscription_format') !== 'json'}
placeholder="留空表示根数组"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{type === 'automatic' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<FormField
control={form.control}
name="sync_interval_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
min={5}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<FormDescription>
定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>预设规则</FormLabel>
<div className="flex flex-wrap gap-2">
{automaticPresetRules.map((rule) => (
<Button
key={rule.expr}
type="button"
size="sm"
variant="outline"
onClick={() => {
const current = form.getValues('auto_config_text');
form.setValue(
'auto_config_text',
appendAutomaticPresetRule(current, rule),
);
}}
>
<Plus className="size-3.5 mr-1" />
{rule.name}
</Button>
))}
</div>
</div>
<FormField
control={form.control}
name="auto_config_text"
render={({ field }) => (
<FormItem>
<FormLabel>自动配置 JSON</FormLabel>
<FormControl>
<Textarea className="min-h-48 font-mono text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{type !== 'automatic' ? (
<FormField
control={form.control}
name="ip_list_text"
render={({ field }) => (
<FormItem>
<FormLabel>IP / IP 段</FormLabel>
<FormControl>
<Textarea
className="min-h-48 font-mono text-xs"
placeholder={'203.0.113.10\n198.51.100.0/24'}
{...field}
/>
</FormControl>
<FormDescription>
{type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : '保存 IP 组'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import type {WAFIPGroupAutoTestResult} from '@/lib/services/openflare';
interface IPGroupTestDialogProps {
open: boolean;
loading: boolean;
result: WAFIPGroupAutoTestResult | null;
onOpenChange: (open: boolean) => void;
}
export function IPGroupTestDialog({
open,
loading,
result,
onOpenChange,
}: IPGroupTestDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>自动规则测试结果</DialogTitle>
<DialogDescription>
基于当前自动配置 JSON 对请求日志进行回看测试。
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="text-sm text-muted-foreground py-6 text-center">测试中...</p>
) : result ? (
<div className="space-y-4">
<div className="rounded-lg border border-dashed p-4 text-sm">
<p>
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count} 条 · 命中{' '}
{result.matched_count} 个 IP
</p>
<p className="text-xs text-muted-foreground mt-1">
测试时间:{new Date(result.tested_at).toLocaleString()}
</p>
</div>
{result.matched_count > 0 ? (
<pre className="max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap break-all">
{result.matched_ips.join('\n')}
</pre>
) : (
<p className="text-sm text-muted-foreground">当前没有匹配到任何 IP。</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-6 text-center">暂无测试结果。</p>
)}
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,120 @@
'use client';
import {Download, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {ipGroupTypeLabels} from './helpers';
interface IPGroupsTableProps {
groups: WAFIPGroup[];
syncingId: number | null;
onEdit: (group: WAFIPGroup) => void;
onDelete: (group: WAFIPGroup) => void;
onSync: (group: WAFIPGroup) => void;
onTest: (group: WAFIPGroup) => void;
}
export function IPGroupsTable({
groups,
syncingId,
onEdit,
onDelete,
onSync,
onTest,
}: IPGroupsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>IP 数</TableHead>
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell>
<Badge variant="outline">{ipGroupTypeLabels[group.type]}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{group.ip_list.length}</TableCell>
<TableCell>{group.referenced_by_rule_count}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{group.last_sync_status
? `${group.last_sync_status}: ${group.last_sync_message}`
: '尚无同步记录'}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{group.type === 'automatic' ? (
<DropdownMenuItem onClick={() => onTest(group)}>
<Play className="size-4 mr-2" />
测试规则
</DropdownMenuItem>
) : null}
{group.type === 'subscription' || group.type === 'automatic' ? (
<DropdownMenuItem
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download className="size-4 mr-2" />
{syncingId === group.id
? '同步中...'
: group.type === 'automatic'
? '立即执行'
: '立即同步'}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,43 @@
'use client';
import {Info} from 'lucide-react';
import {Switch} from '@/components/ui/switch';
import {Label} from '@/components/ui/label';
import type {ProxyRoutePoWConfig} from '@/lib/services/openflare';
interface PowConfigPanelProps {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}
export function PowConfigPanel({ enabled, config, onChange }: PowConfigPanelProps) {
return (
<div className="space-y-4">
<div className="flex gap-3 rounded-lg border border-dashed bg-muted/40 p-4 text-sm">
<Info className="size-4 shrink-0 text-primary mt-0.5" />
<div className="space-y-1">
<p className="font-medium">PoW 配置面板(占位)</p>
<p className="text-muted-foreground text-xs">
完整 PoW 黑白名单与难度参数编辑将在后续迭代中实装。当前可先切换启用状态并保存规则组。
</p>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-1">
<Label htmlFor="pow-enabled">启用 PoW 防护</Label>
<p className="text-xs text-muted-foreground">
算法 {config.algorithm} · 难度 {config.difficulty}
</p>
</div>
<Switch
id="pow-enabled"
checked={enabled}
onCheckedChange={(checked) => onChange(checked, config)}
/>
</div>
</div>
);
}
@@ -0,0 +1,283 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Search} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {
type CountryOption,
normalizeItems,
type RuleDimension,
type RuleListType,
type RuleModalState,
} from './helpers';
interface RuleEntryDialogProps {
state: RuleModalState;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
onClose: () => void;
onChange: (patch: Partial<RuleModalState>) => void;
onSubmit: () => void;
}
export function RuleEntryDialog({
state,
countryOptions,
ipGroups,
onClose,
onChange,
onSubmit,
}: RuleEntryDialogProps) {
const [keyword, setKeyword] = useState('');
useEffect(() => {
if (!state.open) return;
setKeyword('');
}, [state.dimension, state.open]);
const selectedCountrySet = useMemo(
() => new Set(state.countryValues),
[state.countryValues],
);
const selectedIPGroupSet = useMemo(
() => new Set(state.ipGroupIDs),
[state.ipGroupIDs],
);
const filteredCountries = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return countryOptions
.filter((option) => !normalized || option.searchText.includes(normalized))
.sort((left, right) => {
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
return rightSelected - leftSelected || left.code.localeCompare(right.code);
});
}, [countryOptions, keyword, selectedCountrySet]);
const toggleCountry = (code: string) => {
const values = selectedCountrySet.has(code)
? state.countryValues.filter((item) => item !== code)
: normalizeItems([...state.countryValues, code]);
onChange({ countryValues: values });
};
const toggleIPGroup = (id: number) => {
const values = selectedIPGroupSet.has(id)
? state.ipGroupIDs.filter((item) => item !== id)
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
onChange({ ipGroupIDs: values });
};
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
const dimensionLabel =
state.dimension === 'ip'
? 'IP'
: state.dimension === 'ip_group'
? 'IP 组'
: '地域';
return (
<Dialog open={state.open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加{typeLabel}规则</DialogTitle>
<DialogDescription>
当前准备新增 {dimensionLabel} 维度的{typeLabel}项。
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>类型</Label>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'blacklist', label: '黑名单' },
{ value: 'whitelist', label: '白名单' },
].map((option) => (
<Button
key={option.value}
type="button"
variant={state.listType === option.value ? 'default' : 'outline'}
onClick={() =>
onChange({ listType: option.value as RuleListType })
}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>维度</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: 'ip', label: 'IP' },
{ value: 'ip_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={state.dimension === option.value ? 'default' : 'outline'}
onClick={() =>
onChange({ dimension: option.value as RuleDimension })
}
>
{option.label}
</Button>
))}
</div>
</div>
</div>
{state.dimension === 'ip' ? (
<div className="space-y-2">
<Label>IP / IP 段</Label>
<Textarea
value={state.ipValue}
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
onChange={(event) => onChange({ ipValue: event.target.value })}
/>
<p className="text-xs text-muted-foreground">
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
</p>
</div>
) : null}
{state.dimension === 'ip_group' ? (
<div className="space-y-3 rounded-lg border border-dashed p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">选择 IP 组</p>
<p className="text-xs text-muted-foreground">
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum 差异同步。
</p>
</div>
<span className="text-xs text-muted-foreground">
已选 {state.ipGroupIDs.length}
</span>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{ipGroups.length > 0 ? (
ipGroups.map((group) => {
const selected = selectedIPGroupSet.has(group.id);
return (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2',
selected && 'border-primary bg-muted/50',
)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleIPGroup(group.id)}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium truncate">
{group.name}
</span>
<span className="block text-xs text-muted-foreground">
{group.type} · {group.ip_list.length} 条 ·{' '}
{group.enabled ? '启用' : '停用'}
</span>
</span>
</label>
);
})
) : (
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
)}
</div>
</div>
) : null}
{state.dimension === 'country' ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索国家代码或中文名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
onChange({
countryValues: normalizeItems([
...state.countryValues,
...filteredCountries.map((option) => option.code),
]),
})
}
>
全选当前
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange({ countryValues: [] })}
>
清空
</Button>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-dashed p-3">
{filteredCountries.map((option) => {
const selected = selectedCountrySet.has(option.code);
return (
<label
key={option.code}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2',
selected && 'border-primary bg-muted/50',
)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleCountry(option.code)}
/>
<span className="text-sm">{option.label}</span>
</label>
);
})}
</div>
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
取消
</Button>
<Button type="button" onClick={onSubmit}>
添加
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,404 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Plus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Switch} from '@/components/ui/switch';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {Textarea} from '@/components/ui/textarea';
import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
import {
buildCountryOptions,
buildRuleGroupDraft,
countRuleEntries,
defaultRuleModalState,
emptyRuleGroupDraft,
formatCountryItem,
getListFieldKey,
type ListFieldKey,
normalizeItems,
type RuleModalState,
textToList,
updateDraftList,
} from './helpers';
import {PowConfigPanel} from './pow-config-panel';
import {RuleEntryDialog} from './rule-entry-dialog';
import {RuleListSection} from './rule-list-section';
const ruleGroupSchema = z.object({
name: z.string().trim().min(1, '请输入规则组名称').max(255, '名称不能超过 255 个字符'),
enabled: z.boolean(),
block_status_code: z.number().int().min(400).max(599),
block_response_body: z.string(),
remark: z.string().max(500, '备注不能超过 500 个字符'),
pow_enabled: z.boolean(),
});
type RuleGroupFormValues = z.infer<typeof ruleGroupSchema>;
interface RuleGroupDialogProps {
open: boolean;
group: WAFRuleGroup | null;
ipGroups: WAFIPGroup[];
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: WAFRuleGroupPayload) => Promise<void>;
}
export function RuleGroupDialog({
open,
group,
ipGroups,
submitting,
onOpenChange,
onSubmit,
}: RuleGroupDialogProps) {
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
const [ruleModal, setRuleModal] = useState<RuleModalState>(defaultRuleModalState);
const countryOptions = useMemo(() => buildCountryOptions(), []);
const countryLabelMap = useMemo(
() => new Map(countryOptions.map((option) => [option.code, option.label])),
[countryOptions],
);
const form = useForm<RuleGroupFormValues>({
resolver: zodResolver(ruleGroupSchema),
defaultValues: {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
remark: '',
pow_enabled: false,
},
});
useEffect(() => {
if (!open) return;
const draft = buildRuleGroupDraft(group);
setListsDraft(draft);
form.reset({
name: draft.name,
enabled: draft.enabled,
block_status_code: draft.block_status_code,
block_response_body: draft.block_response_body,
remark: draft.remark,
pow_enabled: draft.pow_enabled,
});
setRuleModal(defaultRuleModalState);
}, [form, group, open]);
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
const whitelistGroupItems = listsDraft.ip_whitelist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((item): item is WAFIPGroup => Boolean(item));
const blacklistGroupItems = listsDraft.ip_blacklist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((item): item is WAFIPGroup => Boolean(item));
const removeRuleItem = (key: ListFieldKey, value: string) => {
setListsDraft((current) =>
updateDraftList(current, key, (items) => items.filter((item) => item !== value)),
);
};
const removeRuleGroup = (key: ListFieldKey, id: number) => {
setListsDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== String(id)),
),
);
};
const applyRuleModal = () => {
const values =
ruleModal.dimension === 'ip'
? textToList(ruleModal.ipValue)
: ruleModal.dimension === 'ip_group'
? ruleModal.ipGroupIDs.map(String)
: normalizeItems(ruleModal.countryValues);
if (values.length === 0) return;
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
setListsDraft((current) =>
updateDraftList(current, listKey, (items) =>
normalizeItems([...items, ...values]),
),
);
setRuleModal(defaultRuleModalState);
};
const handleSubmit = form.handleSubmit(async (values) => {
const payload: WAFRuleGroupPayload = {
...listsDraft,
name: values.name,
enabled: values.enabled,
block_status_code: values.block_status_code,
block_response_body: values.block_response_body,
remark: values.remark,
pow_enabled: values.pow_enabled,
};
try {
await onSubmit(payload);
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
});
}
});
const isGlobal = group?.is_global ?? false;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{group ? `编辑 ${group.name}` : '新建规则组'}</DialogTitle>
<DialogDescription>
白名单命中后直接放行;未命中白名单时继续判断黑名单。当前规则数{' '}
{countRuleEntries(listsDraft)} 条。
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<Tabs defaultValue="basic">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">基本信息</TabsTrigger>
<TabsTrigger value="lists">黑白名单</TabsTrigger>
<TabsTrigger value="pow">PoW</TabsTrigger>
<TabsTrigger value="block">拦截返回</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4 mt-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>规则组名称</FormLabel>
<FormControl>
<Input {...field} disabled={isGlobal} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<FormLabel>启用规则组</FormLabel>
<FormDescription>关闭后保留配置,但不会参与匹配。</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>备注</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="lists" className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
通过子对话框添加 IP、IP 组或地域规则。
</p>
<Button
type="button"
size="sm"
onClick={() =>
setRuleModal({ ...defaultRuleModalState, open: true })
}
>
<Plus className="size-3.5 mr-1" />
添加规则
</Button>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RuleListSection
title="IP 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={listsDraft.ip_whitelist}
groupItems={whitelistGroupItems}
tone="whitelist"
emptyText="暂无 IP 白名单规则。"
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
onRemoveGroup={(id) => removeRuleGroup('ip_whitelist_group_ids', id)}
/>
<RuleListSection
title="IP 黑名单"
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
items={listsDraft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) => removeRuleGroup('ip_blacklist_group_ids', id)}
/>
<RuleListSection
title="地域白名单"
description="显示格式为国家代码与中文名,命中后直接放行。"
items={listsDraft.country_whitelist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="whitelist"
emptyText="暂无地域白名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_whitelist', code);
}}
/>
<RuleListSection
title="地域黑名单"
description="当请求未命中白名单时,命中这些地域将被拦截。"
items={listsDraft.country_blacklist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="blacklist"
emptyText="暂无地域黑名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_blacklist', code);
}}
/>
</div>
</TabsContent>
<TabsContent value="pow" className="mt-4">
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={listsDraft.pow_config}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setListsDraft((current) => ({
...current,
pow_enabled: enabled,
pow_config: config,
}));
}}
/>
</TabsContent>
<TabsContent value="block" className="space-y-4 mt-4">
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<FormField
control={form.control}
name="block_status_code"
render={({ field }) => (
<FormItem>
<FormLabel>拦截状态码</FormLabel>
<FormControl>
<Input
type="number"
min={400}
max={599}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<div className="flex flex-wrap gap-2 pt-2">
{[403, 418, 451, 503].map((code) => (
<Button
key={code}
type="button"
size="sm"
variant={field.value === code ? 'default' : 'outline'}
onClick={() => field.onChange(code)}
>
{code}
</Button>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="block_response_body"
render={({ field }) => (
<FormItem>
<FormLabel>拦截页面</FormLabel>
<FormControl>
<Textarea
className="min-h-48"
placeholder="<html><body><h1>Request blocked</h1></body></html>"
{...field}
/>
</FormControl>
<FormDescription>留空时只返回状态码。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : '保存规则组'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<RuleEntryDialog
state={ruleModal}
countryOptions={countryOptions}
ipGroups={ipGroups}
onClose={() => setRuleModal(defaultRuleModalState)}
onChange={(patch) => setRuleModal((current) => ({ ...current, ...patch, open: true }))}
onSubmit={applyRuleModal}
/>
</>
);
}
@@ -0,0 +1,114 @@
'use client';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2, Users} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFRuleGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {countRuleEntries} from './helpers';
interface RuleGroupsTableProps {
groups: WAFRuleGroup[];
onEdit: (group: WAFRuleGroup) => void;
onDelete: (group: WAFRuleGroup) => void;
onBindSites: (group: WAFRuleGroup) => void;
}
export function RuleGroupsTable({
groups,
onEdit,
onDelete,
onBindSites,
}: RuleGroupsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>规则数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<div className="flex items-center gap-2">
{group.is_global ? (
<Globe2 className="size-4 text-primary shrink-0" />
) : (
<ShieldCheck className="size-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium">{group.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{group.is_global ? '全局' : '自定义'}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{countRuleEntries(group)}</TableCell>
<TableCell>
{group.is_global
? '全部网站'
: `${group.applied_site_count} 个网站`}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{!group.is_global ? (
<DropdownMenuItem onClick={() => onBindSites(group)}>
<Users className="size-4 mr-2" />
绑定网站
</DropdownMenuItem>
) : null}
{!group.is_global ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,78 @@
'use client';
import {X} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
interface RuleListSectionProps {
title: string;
description: string;
items: string[];
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
tone: 'whitelist' | 'blacklist';
emptyText: string;
onRemove: (item: string) => void;
onRemoveGroup?: (id: number) => void;
}
export function RuleListSection({
title,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: RuleListSectionProps) {
const total = items.length + groupItems.length;
const badgeVariant = tone === 'whitelist' ? 'secondary' : 'destructive';
return (
<div className="rounded-lg border border-dashed p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<Badge variant="outline">{total}</Badge>
</div>
{total > 0 ? (
<div className="flex flex-wrap gap-2">
{groupItems.map((group) => (
<Badge key={`group-${group.id}`} variant={badgeVariant} className="gap-1 pr-1">
IP组: {group.name}
{!group.enabled ? ' (停用)' : ''}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4 hover:bg-transparent"
onClick={() => onRemoveGroup?.(group.id)}
>
<X className="size-3" />
</Button>
</Badge>
))}
{items.map((item) => (
<Badge key={item} variant={badgeVariant} className="gap-1 pr-1 font-mono text-xs">
{item}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4 hover:bg-transparent"
onClick={() => onRemove(item)}
>
<X className="size-3" />
</Button>
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{emptyText}</p>
)}
</div>
);
}
@@ -0,0 +1,135 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Check, Search} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
import {cn} from '@/lib/utils';
import type {ProxyRouteItem, WAFRuleGroup} from '@/lib/services/openflare';
interface SiteBindingSheetProps {
group: WAFRuleGroup | null;
routes: ProxyRouteItem[];
open: boolean;
pending: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ids: number[]) => void;
}
export function SiteBindingSheet({
group,
routes,
open,
pending,
onOpenChange,
onSave,
}: SiteBindingSheetProps) {
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
useEffect(() => {
setSelectedIDs(group?.applied_site_ids ?? []);
setKeyword('');
}, [group, open]);
const filteredRoutes = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return routes;
return routes.filter((route) =>
[route.site_name, route.primary_domain, ...route.domains]
.join(' ')
.toLowerCase()
.includes(normalized),
);
}, [keyword, routes]);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const toggleID = (id: number) => {
setSelectedIDs((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id].sort((left, right) => left - right),
);
};
const selectFiltered = () => {
const next = new Set(selectedIDs);
filteredRoutes.forEach((route) => next.add(route.id));
setSelectedIDs([...next].sort((left, right) => left - right));
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>{group ? `绑定 ${group.name}` : '绑定规则组'}</SheetTitle>
<SheetDescription>
选择这个自定义规则组要叠加到哪些网站。
</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-4 pb-4">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索网站或域名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button type="button" variant="ghost" size="sm" onClick={selectFiltered}>
全选当前
</Button>
</div>
<div className="space-y-2">
{filteredRoutes.map((route) => (
<button
key={route.id}
type="button"
onClick={() => toggleID(route.id)}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition',
selectedSet.has(route.id) && 'border-primary bg-muted/50',
)}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded border',
selectedSet.has(route.id) && 'border-primary bg-primary text-primary-foreground',
)}
>
{selectedSet.has(route.id) ? <Check className="size-3" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{route.site_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{route.domains.join(', ')}
</span>
</span>
</button>
))}
</div>
</div>
<SheetFooter className="px-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
disabled={!group || pending}
onClick={() => onSave(selectedIDs)}
>
{pending ? '保存中...' : '保存应用范围'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,269 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {ArrowLeft, Loader2, Network, Plus, RefreshCw} from 'lucide-react';
import {useState} from '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, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {WAFIPGroup, WAFIPGroupAutoTestResult, WAFIPGroupPayload,} from '@/lib/services/openflare';
import {WafService} from '@/lib/services/openflare';
import {getErrorMessage, parseAutomaticConfig} from '../components/helpers';
import {IPGroupDialog} from '../components/ip-group-dialog';
import {IPGroupTestDialog} from '../components/ip-group-test-dialog';
import {IPGroupsTable} from '../components/ip-groups-table';
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
export default function WafIPGroupsPage() {
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null);
const [deleteTarget, setDeleteTarget] = useState<WAFIPGroup | null>(null);
const [testOpen, setTestOpen] = useState(false);
const [testResult, setTestResult] = useState<WAFIPGroupAutoTestResult | null>(null);
const [syncingId, setSyncingId] = useState<number | null>(null);
const groupsQuery = useQuery({
queryKey: ipGroupsQueryKey,
queryFn: () => WafService.listIPGroups(),
});
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ipGroupsQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
]);
};
const saveMutation = useMutation({
mutationFn: async ({
group,
payload,
}: {
group: WAFIPGroup | null;
payload: WAFIPGroupPayload;
}) => {
if (group) {
return WafService.updateIPGroup(group.id, payload);
}
return WafService.createIPGroup(payload);
},
onSuccess: async () => {
toast.success(editingGroup ? 'IP 组已更新' : 'IP 组已创建');
setEditingGroup(null);
setEditorOpen(false);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WafService.deleteIPGroup(id),
onSuccess: async () => {
toast.success('IP 组已删除');
setDeleteTarget(null);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const syncMutation = useMutation({
mutationFn: (id: number) => WafService.syncIPGroup(id),
onMutate: (id) => {
setSyncingId(id);
},
onSuccess: async (result) => {
toast.success(result.message || '同步完成');
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
onSettled: () => {
setSyncingId(null);
},
});
const testMutation = useMutation({
mutationFn: (group: WAFIPGroup) =>
WafService.testIPGroup({
auto_config: parseAutomaticConfig(
JSON.stringify(group.auto_config ?? {}, null, 2),
),
}),
onSuccess: (result) => {
setTestResult(result);
toast.success(
result.matched_count > 0
? `规则测试完成,命中 ${result.matched_count} 个 IP`
: '规则测试完成,当前未命中任何 IP',
);
},
onError: (error) => {
setTestResult(null);
toast.error(getErrorMessage(error));
},
});
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ipGroupsQueryKey });
};
const handleCreate = () => {
setEditingGroup(null);
setEditorOpen(true);
};
const handleEdit = (group: WAFIPGroup) => {
setEditingGroup(group);
setEditorOpen(true);
};
const handleTest = (group: WAFIPGroup) => {
setTestResult(null);
setTestOpen(true);
testMutation.mutate(group);
};
const groups = groupsQuery.data ?? [];
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Network className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">IP 组</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/waf">
<ArrowLeft className="size-3.5 mr-1" />
返回 WAF
</Link>
</Button>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
<Plus className="size-3.5 mr-1" />
新建 IP 组
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold">IP 组列表</CardTitle>
<CardDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
disabled={groupsQuery.isFetching}
>
{groupsQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1" />
)}
刷新
</Button>
</div>
</CardHeader>
<CardContent>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder icon={Network} description="加载 IP 组中..." />
) : groupsQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(groupsQuery.error)}
onRetry={handleRefresh}
className="justify-center"
/>
</div>
) : groups.length === 0 ? (
<EmptyStateWithBorder icon={Network} description="暂无 IP 组,请先创建一个。" />
) : (
<IPGroupsTable
groups={groups}
syncingId={syncingId}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onSync={(group) => syncMutation.mutate(group.id)}
onTest={handleTest}
/>
)}
</CardContent>
</Card>
<IPGroupDialog
open={editorOpen}
group={editingGroup}
submitting={saveMutation.isPending}
onOpenChange={(open) => {
setEditorOpen(open);
if (!open) setEditingGroup(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync({ group: editingGroup, payload });
}}
/>
<IPGroupTestDialog
open={testOpen}
loading={testMutation.isPending}
result={testResult}
onOpenChange={setTestOpen}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 IP 组</AlertDialogTitle>
<AlertDialogDescription>
确认删除 IP 组「{deleteTarget?.name}」吗?删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,7 +1,259 @@
import {Shield} from 'lucide-react';
'use client';
import {PlaceholderPage} from '../components/placeholder-page';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {Loader2, Network, Plus, RefreshCw, Shield} from 'lucide-react';
import {useState} from '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, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
import {ProxyRouteService, WafService} from '@/lib/services/openflare';
import {getErrorMessage} from './components/helpers';
import {RuleGroupDialog} from './components/rule-group-dialog';
import {RuleGroupsTable} from './components/rule-groups-table';
import {SiteBindingSheet} from './components/site-binding-sheet';
const ruleGroupsQueryKey = ['openflare', 'waf', 'rule-groups'];
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
const routesQueryKey = ['openflare', 'proxy-routes'];
export default function WafPage() {
return <PlaceholderPage title="WAF" icon={Shield} />;
}
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<WAFRuleGroup | null>(null);
const [deleteTarget, setDeleteTarget] = useState<WAFRuleGroup | null>(null);
const [bindingGroup, setBindingGroup] = useState<WAFRuleGroup | null>(null);
const groupsQuery = useQuery({
queryKey: ruleGroupsQueryKey,
queryFn: () => WafService.listRuleGroups(),
});
const ipGroupsQuery = useQuery({
queryKey: ipGroupsQueryKey,
queryFn: () => WafService.listIPGroups(),
});
const routesQuery = useQuery({
queryKey: routesQueryKey,
queryFn: () => ProxyRouteService.list(),
});
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey }),
queryClient.invalidateQueries({ queryKey: ipGroupsQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
]);
};
const saveMutation = useMutation({
mutationFn: async ({
group,
payload,
}: {
group: WAFRuleGroup | null;
payload: WAFRuleGroupPayload;
}) => {
if (group) {
return WafService.updateRuleGroup(group.id, payload);
}
return WafService.createRuleGroup(payload);
},
onSuccess: async () => {
toast.success(editingGroup ? '规则组已更新' : '规则组已创建');
setEditingGroup(null);
setEditorOpen(false);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WafService.deleteRuleGroup(id),
onSuccess: async () => {
toast.success('规则组已删除');
setDeleteTarget(null);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const bindMutation = useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
WafService.updateRuleGroupSites(id, ids),
onSuccess: async () => {
toast.success('规则组应用范围已更新');
setBindingGroup(null);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey });
};
const handleCreate = () => {
setEditingGroup(null);
setEditorOpen(true);
};
const handleEdit = (group: WAFRuleGroup) => {
setEditingGroup(group);
setEditorOpen(true);
};
const groups = groupsQuery.data ?? [];
const loading = groupsQuery.isLoading || ipGroupsQuery.isLoading || routesQuery.isLoading;
const error =
groupsQuery.error ?? ipGroupsQuery.error ?? routesQuery.error ?? null;
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Shield className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">WAF</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/waf/ip-groups">
<Network className="size-3.5 mr-1" />
IP 组
</Link>
</Button>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
<Plus className="size-3.5 mr-1" />
新建规则组
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold">规则组</CardTitle>
<CardDescription>
按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
disabled={groupsQuery.isFetching}
>
{groupsQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1" />
)}
刷新
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<LoadingStateWithBorder icon={Shield} description="加载 WAF 规则组中..." />
) : error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(error)}
onRetry={handleRefresh}
className="justify-center"
/>
</div>
) : groups.length === 0 ? (
<EmptyStateWithBorder
icon={Shield}
description="暂无规则组,系统通常会自动创建全局规则组。"
/>
) : (
<RuleGroupsTable
groups={groups}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onBindSites={setBindingGroup}
/>
)}
</CardContent>
</Card>
<RuleGroupDialog
open={editorOpen}
group={editingGroup}
ipGroups={ipGroupsQuery.data ?? []}
submitting={saveMutation.isPending}
onOpenChange={(open) => {
setEditorOpen(open);
if (!open) setEditingGroup(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync({ group: editingGroup, payload });
}}
/>
<SiteBindingSheet
group={bindingGroup}
routes={routesQuery.data ?? []}
open={Boolean(bindingGroup)}
pending={bindMutation.isPending}
onOpenChange={(open) => !open && setBindingGroup(null)}
onSave={(ids) => {
if (bindingGroup) {
bindMutation.mutate({ id: bindingGroup.id, ids });
}
}}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除规则组</AlertDialogTitle>
<AlertDialogDescription>
确认删除规则组「{deleteTarget?.name}」吗?删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,334 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {FileKey, Plus, RefreshCw, 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {TlsCertificateService} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
import {CertificateImportDialog} from '../components/certificate-import-dialog';
import {WebsiteStatusBadge} from '../components/status-badge';
import {getCertificateStatus, getErrorMessage} from '../components/website-utils';
const certificatesQueryKey = ['openflare', 'tls-certificates'];
type CertificateApplyMode = 'edit-acme' | 'convert-upload';
export default function CertificatesPage() {
const queryClient = useQueryClient();
const [importOpen, setImportOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<TlsCertificateItem | null>(null);
const [selectedCertificateId, setSelectedCertificateId] = useState<number | null>(null);
const [applyCertificate, setApplyCertificate] = useState<TlsCertificateItem | null>(null);
const [applyMode, setApplyMode] = useState<CertificateApplyMode>('edit-acme');
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: () => TlsCertificateService.list(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.delete(id),
onSuccess: async () => {
toast.success('证书已删除');
setDeleteTarget(null);
await queryClient.invalidateQueries({queryKey: certificatesQueryKey});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const renewMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.renew(id),
onSuccess: async (cert) => {
toast.success(`证书 ${cert.name} 续期任务已提交`);
await queryClient.invalidateQueries({queryKey: certificatesQueryKey});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const handleOpenEditor = (certificate: TlsCertificateItem) => {
if (certificate.provider === 'acme') {
setApplyMode('edit-acme');
setApplyCertificate(certificate);
setApplyOpen(true);
} else {
setSelectedCertificateId(certificate.id);
setEditorOpen(true);
}
};
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">
<FileKey className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">证书</h1>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites">返回网站</Link>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites/dns-accounts">DNS 账号</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => void queryClient.invalidateQueries({queryKey: certificatesQueryKey})}
>
<RefreshCw className="size-3.5 mr-1" />
刷新证书
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => setImportOpen(true)}
>
导入证书
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => setApplyOpen(true)}>
<Plus className="size-3.5 mr-1" />
申请证书
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">证书列表</CardTitle>
<CardDescription>
展示证书有效期、备注和状态,支持查看详情、编辑内容或删除证书。
</CardDescription>
</CardHeader>
<CardContent>
{certificatesQuery.isLoading ? (
<LoadingStateWithBorder icon={FileKey} description="加载证书列表中..." />
) : certificatesQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(certificatesQuery.error)}
onRetry={() => void certificatesQuery.refetch()}
className="justify-center"
/>
</div>
) : certificates.length === 0 ? (
<EmptyStateWithBorder
icon={FileKey}
description="暂无证书,点击右上角「导入证书」或「申请证书」开始录入。"
/>
) : (
<div className="space-y-3">
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
return (
<div
key={certificate.id}
className="rounded-lg border bg-card px-4 py-3"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold">{certificate.name}</p>
<WebsiteStatusBadge label={status.label} tone={status.tone} />
</div>
<div className="text-xs leading-5 text-muted-foreground space-y-0.5">
<p>生效:{formatDateTime(certificate.not_before)}</p>
<p>到期:{formatDateTime(certificate.not_after)}</p>
<p>
来源:
{certificate.provider === 'acme' ? 'ACME 申请' : '手动上传'}
</p>
{certificate.apply_status === 'applying' ? (
<p className="text-blue-600">
状态:
{certificate.provider === 'upload'
? '转换申请中...'
: '申请中...'}
</p>
) : null}
{certificate.apply_status === 'error' ? (
<p className="text-destructive">
状态:
{certificate.provider === 'upload' ? '转换失败' : '申请失败'}
({certificate.apply_message})
</p>
) : null}
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
setSelectedCertificateId(certificate.id);
setDetailOpen(true);
}}
>
查看
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => handleOpenEditor(certificate)}
>
编辑
</Button>
{certificate.provider === 'acme' ? (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={renewMutation.isPending}
onClick={() => renewMutation.mutate(certificate.id)}
>
续期
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setDeleteTarget(certificate)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<CertificateImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={(certificate) =>
toast.success(`证书 ${certificate.name} 已导入`)
}
/>
<CertificateApplyDialog
open={applyOpen && !applyCertificate}
onOpenChange={setApplyOpen}
onApplied={(certificate) =>
toast.success(`证书 ${certificate.name} 申请任务已提交`)
}
/>
{applyCertificate ? (
<CertificateApplyDialog
open={applyOpen}
onOpenChange={(open) => {
setApplyOpen(open);
if (!open) setApplyCertificate(null);
}}
mode={applyMode}
certificate={applyCertificate}
onApplied={(certificate) => {
setApplyCertificate(null);
toast.success(
applyMode === 'convert-upload'
? `证书 ${certificate.name} 转换申请已提交`
: `证书 ${certificate.name} 配置已更新,重新申请中...`,
);
}}
/>
) : null}
<CertificateDetailDialog
certificateId={selectedCertificateId}
open={detailOpen}
onOpenChange={setDetailOpen}
onEdit={() => {
setDetailOpen(false);
const item = certificates.find((c) => c.id === selectedCertificateId);
if (item) handleOpenEditor(item);
}}
onDelete={() => {
const item = certificates.find((c) => c.id === selectedCertificateId);
if (item) {
setDetailOpen(false);
setDeleteTarget(item);
}
}}
deleting={deleteMutation.isPending}
/>
<CertificateEditorDialog
certificateId={selectedCertificateId}
open={editorOpen}
onOpenChange={setEditorOpen}
onSaved={(certificate) => toast.success(`证书 ${certificate.name} 已更新`)}
onConvert={(certificate) => {
setEditorOpen(false);
setApplyMode('convert-upload');
setApplyCertificate(certificate);
setApplyOpen(true);
}}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
<AlertDialogDescription>
确认删除证书 {deleteTarget?.name} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,314 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useEffect, useState} from 'react';
import {useForm} from 'react-hook-form';
import {ChevronDown, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {DnsAccountService, TlsCertificateService} from '@/lib/services/openflare';
import {type AcmeApplyFormValues, acmeApplySchema, defaultAcmeApplyValues,} from './schemas';
import {getErrorMessage} from './website-utils';
const certificatesQueryKey = ['openflare', 'tls-certificates'];
type CertificateApplyMode = 'create' | 'edit-acme' | 'convert-upload';
interface CertificateApplyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onApplied?: (certificate: TlsCertificateItem) => void;
mode?: CertificateApplyMode;
certificate?: TlsCertificateItem | null;
}
export function CertificateApplyDialog({
open,
onOpenChange,
onApplied,
mode = 'create',
certificate,
}: CertificateApplyDialogProps) {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const dnsAccountsQuery = useQuery({
queryKey: ['openflare', 'dns-accounts'],
queryFn: () => DnsAccountService.list(),
enabled: open,
});
const defaultAcmeAccountQuery = useQuery({
queryKey: ['openflare', 'acme-accounts', 'default'],
queryFn: () => TlsCertificateService.getDefaultAcmeAccount(),
enabled: open,
});
const form = useForm<AcmeApplyFormValues>({
resolver: zodResolver(acmeApplySchema),
defaultValues: defaultAcmeApplyValues,
});
useEffect(() => {
if (!open) return;
setError('');
setShowAdvanced(false);
if (certificate) {
form.reset({
name: certificate.name,
primary_domain:
mode === 'convert-upload' ? '' : certificate.primary_domain || '',
other_domains:
mode === 'convert-upload' ? '' : certificate.other_domains || '',
remark: certificate.remark || '',
acme_account_id:
mode === 'convert-upload' ? 0 : certificate.acme_account_id,
dns_account_id:
mode === 'convert-upload' ? 0 : certificate.dns_account_id,
key_algorithm: certificate.key_algorithm || 'EC256',
auto_renew: mode === 'convert-upload' ? true : certificate.auto_renew,
dns1: mode === 'convert-upload' ? '' : certificate.dns1 || '',
dns2: mode === 'convert-upload' ? '' : certificate.dns2 || '',
disable_cname:
mode === 'convert-upload' ? false : certificate.disable_cname,
skip_dns: mode === 'convert-upload' ? false : certificate.skip_dns,
});
if (
mode !== 'convert-upload' &&
(certificate.dns1 ||
certificate.dns2 ||
certificate.disable_cname ||
certificate.skip_dns)
) {
setShowAdvanced(true);
}
} else {
form.reset(defaultAcmeApplyValues);
}
}, [certificate, form, mode, open]);
useEffect(() => {
if (defaultAcmeAccountQuery.data && form.getValues('acme_account_id') === 0) {
form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id);
}
}, [defaultAcmeAccountQuery.data, form, open]);
const applyMutation = useMutation({
mutationFn: (values: AcmeApplyFormValues) => {
if (mode === 'edit-acme' && certificate) {
return TlsCertificateService.updateAcme(certificate.id, values);
}
if (mode === 'convert-upload' && certificate) {
return TlsCertificateService.convertToAcme(certificate.id, values);
}
return TlsCertificateService.apply(values);
},
onSuccess: async (result) => {
await queryClient.invalidateQueries({queryKey: certificatesQueryKey});
onApplied?.(result);
onOpenChange(false);
},
onError: (err) => setError(getErrorMessage(err)),
});
const title =
mode === 'edit-acme'
? '编辑并重新申请证书'
: mode === 'convert-upload'
? '转换为申请证书'
: '申请证书';
const description =
mode === 'edit-acme'
? '修改 ACME 证书配置。保存后将使用新配置重新申请证书。'
: mode === 'convert-upload'
? '填写 ACME 申请资料。申请成功后,当前手动证书会原地转换为可自动续签的申请证书。'
: "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={form.handleSubmit((values) => {
setError('');
applyMutation.mutate(values);
})}
>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>证书名称</Label>
<Input placeholder="例如:主站证书" {...form.register('name')} />
</div>
<div className="space-y-2">
<Label>主域名</Label>
<Input
placeholder="example.com 或 *.example.com"
{...form.register('primary_domain')}
/>
</div>
</div>
<div className="space-y-2">
<Label>其他域名</Label>
<Textarea
rows={3}
placeholder="example.net"
{...form.register('other_domains')}
/>
<p className="text-xs text-muted-foreground">
每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>DNS 服务商账号</Label>
<Select
value={String(form.watch('dns_account_id') || 0)}
onValueChange={(value) =>
form.setValue('dns_account_id', Number(value), {shouldValidate: true})
}
>
<SelectTrigger>
<SelectValue placeholder="请选择 DNS 账号" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">请选择 DNS 账号</SelectItem>
{dnsAccountsQuery.data?.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name} ({account.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>密钥算法</Label>
<Select
value={form.watch('key_algorithm')}
onValueChange={(value) => form.setValue('key_algorithm', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RSA2048">RSA 2048</SelectItem>
<SelectItem value="RSA4096">RSA 4096</SelectItem>
<SelectItem value="EC256">ECC 256</SelectItem>
<SelectItem value="EC384">ECC 384</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>备注</Label>
<Input placeholder="可选,用于记录证书用途。" {...form.register('remark')} />
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">开启自动续签</p>
<p className="text-xs text-muted-foreground">
开启后,将在证书过期前 7 天自动续期。
</p>
</div>
<Switch
checked={form.watch('auto_renew')}
onCheckedChange={(checked) => form.setValue('auto_renew', checked)}
/>
</div>
<div className="overflow-hidden rounded-lg border">
<Button
type="button"
variant="ghost"
className="w-full justify-between rounded-none"
onClick={() => setShowAdvanced((current) => !current)}
>
高级选项
<ChevronDown
className={`size-4 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
/>
</Button>
{showAdvanced ? (
<div className="space-y-4 border-t p-3">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>DNS 验证服务器 1</Label>
<Input placeholder="为空则使用默认权威 DNS" {...form.register('dns1')} />
</div>
<div className="space-y-2">
<Label>DNS 验证服务器 2</Label>
<Input placeholder="为空则使用默认权威 DNS" {...form.register('dns2')} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">跳过 CNAME 检查</p>
<p className="text-xs text-muted-foreground">
在执行 DNS-01 验证时不追踪 CNAME 记录。
</p>
</div>
<Switch
checked={form.watch('disable_cname')}
onCheckedChange={(checked) => form.setValue('disable_cname', checked)}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">跳过 DNS 前置检查</p>
<p className="text-xs text-muted-foreground">
直接请求 Let&apos;s Encrypt 验证而不做本地校验。
</p>
</div>
<Switch
checked={form.watch('skip_dns')}
onCheckedChange={(checked) => form.setValue('skip_dns', checked)}
/>
</div>
</div>
</div>
) : null}
</div>
<Button type="submit" disabled={applyMutation.isPending}>
{applyMutation.isPending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
提交中...
</>
) : mode === 'edit-acme' ? (
'保存并申请'
) : mode === 'convert-upload' ? (
'开始转换'
) : (
'开始申请'
)}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,189 @@
'use client';
import {useQuery} from '@tanstack/react-query';
import {Copy, Loader2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {TlsCertificateService} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {WebsiteStatusBadge} from './status-badge';
import {getCertificateStatus, getErrorMessage} from './website-utils';
interface CertificateDetailDialogProps {
certificateId: number | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: () => void;
onDelete: () => void;
deleting?: boolean;
}
export function CertificateDetailDialog({
certificateId,
open,
onOpenChange,
onEdit,
onDelete,
deleting = false,
}: CertificateDetailDialogProps) {
const certificateQuery = useQuery({
queryKey: ['openflare', 'tls-certificates', 'detail', certificateId],
queryFn: () => TlsCertificateService.getById(certificateId as number),
enabled: open && certificateId !== null,
});
const contentQuery = useQuery({
queryKey: ['openflare', 'tls-certificates', 'content', certificateId],
queryFn: () => TlsCertificateService.getContent(certificateId as number),
enabled: open && certificateId !== null,
});
const certificate = certificateQuery.data;
const content = contentQuery.data;
const status = certificate ? getCertificateStatus(certificate) : null;
const loading = certificateQuery.isLoading || contentQuery.isLoading;
const hasError = certificateQuery.isError || contentQuery.isError;
const handleCopy = async (value: string, message: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(message);
} catch (error) {
toast.error(getErrorMessage(error));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>证书详情</DialogTitle>
<DialogDescription>
查看证书元信息、备注以及当前保存的 PEM 内容。
</DialogDescription>
</DialogHeader>
{loading ? (
<LoadingStateWithBorder description="加载证书详情中..." />
) : hasError ? (
<ErrorInline
message={getErrorMessage(certificateQuery.error ?? contentQuery.error)}
className="justify-center"
/>
) : !certificate || !content ? (
<EmptyStateWithBorder description="证书不存在,可能已被删除。" />
) : (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
证书名称
</p>
<p className="mt-1 text-sm">{certificate.name}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">状态</p>
<div className="mt-1">
{status ? <WebsiteStatusBadge label={status.label} tone={status.tone} /> : null}
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
生效时间
</p>
<p className="mt-1 text-sm">{formatDateTime(certificate.not_before)}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
到期时间
</p>
<p className="mt-1 text-sm">{formatDateTime(certificate.not_after)}</p>
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
<p className="mt-1 text-sm">{certificate.remark || '暂无备注'}</p>
</div>
<div className="space-y-3">
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium">证书 PEM</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(content.cert_pem, '证书 PEM 已复制')}
>
<Copy className="mr-1 size-3" />
复制
</Button>
</div>
<pre className="max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap">
{content.cert_pem}
</pre>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium">私钥 PEM</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(content.key_pem, '私钥 PEM 已复制')}
>
<Copy className="mr-1 size-3" />
复制
</Button>
</div>
<pre className="max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap">
{content.key_pem}
</pre>
</div>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button type="button" onClick={onEdit} disabled={!certificate}>
编辑证书
</Button>
<Button
type="button"
variant="destructive"
onClick={onDelete}
disabled={!certificate || deleting}
>
{deleting ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
删除中...
</>
) : (
'删除证书'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,182 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useEffect} from 'react';
import {useForm} from 'react-hook-form';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {TlsCertificateService} from '@/lib/services/openflare';
import {defaultManualImportValues, type ManualImportFormValues, manualImportSchema,} from './schemas';
import {getErrorMessage, toManualPayload} from './website-utils';
const certificatesQueryKey = ['openflare', 'tls-certificates'];
interface CertificateEditorDialogProps {
certificateId: number | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved?: (certificate: TlsCertificateItem) => void;
onConvert?: (certificate: TlsCertificateItem) => void;
}
export function CertificateEditorDialog({
certificateId,
open,
onOpenChange,
onSaved,
onConvert,
}: CertificateEditorDialogProps) {
const queryClient = useQueryClient();
const form = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
defaultValues: defaultManualImportValues,
});
const certificateQuery = useQuery({
queryKey: ['openflare', 'tls-certificates', 'content', certificateId],
queryFn: () => TlsCertificateService.getContent(certificateId as number),
enabled: open && certificateId !== null,
});
const updateMutation = useMutation({
mutationFn: (values: ManualImportFormValues) =>
TlsCertificateService.update(certificateId as number, toManualPayload(values)),
onSuccess: async (certificate) => {
await Promise.all([
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
queryClient.invalidateQueries({queryKey: ['openflare', 'managed-domains']}),
]);
onSaved?.(certificate);
handleClose();
},
});
useEffect(() => {
if (!open || !certificateQuery.data) return;
form.reset({
name: certificateQuery.data.name,
cert_pem: certificateQuery.data.cert_pem,
key_pem: certificateQuery.data.key_pem,
remark: certificateQuery.data.remark || '',
});
}, [certificateQuery.data, form, open]);
const handleSubmit = form.handleSubmit((values) => {
updateMutation.mutate(values);
});
const handleClose = () => {
updateMutation.reset();
form.reset(defaultManualImportValues);
onOpenChange(false);
};
const canConvert =
certificateQuery.data?.provider === 'upload' &&
certificateQuery.data.apply_status !== 'applying';
return (
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>编辑证书</DialogTitle>
<DialogDescription>
可以修改证书名称、备注,以及重新上传 PEM 证书和私钥内容。
</DialogDescription>
</DialogHeader>
{certificateQuery.isLoading ? (
<LoadingStateWithBorder description="加载证书内容中..." />
) : certificateQuery.isError ? (
<ErrorInline
message={getErrorMessage(certificateQuery.error)}
className="justify-center"
/>
) : !certificateQuery.data ? (
<EmptyStateWithBorder description="证书不存在,可能已被删除。" />
) : (
<form id="certificate-editor-form" className="space-y-4" onSubmit={handleSubmit}>
{updateMutation.isError ? (
<p className="text-sm text-destructive">
{getErrorMessage(updateMutation.error)}
</p>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>证书名称</Label>
<Input {...form.register('name')} />
</div>
<div className="space-y-2">
<Label>备注</Label>
<Input {...form.register('remark')} />
</div>
</div>
<div className="space-y-2">
<Label>证书 PEM</Label>
<Textarea className="min-h-32 font-mono text-xs" {...form.register('cert_pem')} />
</div>
<div className="space-y-2">
<Label>私钥 PEM</Label>
<Textarea className="min-h-32 font-mono text-xs" {...form.register('key_pem')} />
</div>
<DialogFooter className="sm:justify-between">
<div>
{canConvert ? (
<Button
type="button"
variant="outline"
onClick={() => {
if (certificateQuery.data) {
onConvert?.(certificateQuery.data);
}
}}
disabled={updateMutation.isPending}
>
转换来源
</Button>
) : null}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
取消
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
保存中...
</>
) : (
'保存证书'
)}
</Button>
</div>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,264 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {type FormEvent, useEffect, useState} from 'react';
import {useForm} from 'react-hook-form';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {Textarea} from '@/components/ui/textarea';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {TlsCertificateService} from '@/lib/services/openflare';
import {
defaultFileImportValues,
defaultManualImportValues,
type FileImportFormValues,
type ManualImportFormValues,
manualImportSchema,
} from './schemas';
import {getErrorMessage, toFilePayload, toManualPayload} from './website-utils';
const certificatesQueryKey = ['openflare', 'tls-certificates'];
interface CertificateImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported?: (certificate: TlsCertificateItem) => void;
}
export function CertificateImportDialog({
open,
onOpenChange,
onImported,
}: CertificateImportDialogProps) {
const queryClient = useQueryClient();
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [error, setError] = useState('');
const [fileForm, setFileForm] = useState<FileImportFormValues>(defaultFileImportValues);
const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null);
const [fileInputNonce, setFileInputNonce] = useState(0);
const manualForm = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
defaultValues: defaultManualImportValues,
});
useEffect(() => {
if (!open) return;
setError('');
}, [open]);
const invalidateQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
queryClient.invalidateQueries({queryKey: ['openflare', 'managed-domains']}),
]);
};
const resetFileForm = () => {
setFileForm(defaultFileImportValues);
setCertFile(null);
setKeyFile(null);
setFileInputNonce((value) => value + 1);
};
const resetAll = () => {
setImportMode('manual');
setError('');
manualForm.reset(defaultManualImportValues);
resetFileForm();
};
const handleClose = () => {
resetAll();
onOpenChange(false);
};
const manualImportMutation = useMutation({
mutationFn: (values: ManualImportFormValues) =>
TlsCertificateService.create(toManualPayload(values)),
onSuccess: async (certificate) => {
await invalidateQueries();
onImported?.(certificate);
handleClose();
},
onError: (err) => setError(getErrorMessage(err)),
});
const fileImportMutation = useMutation({
mutationFn: (values: FileImportFormValues) =>
TlsCertificateService.importFile(toFilePayload(values, certFile, keyFile)),
onSuccess: async (certificate) => {
await invalidateQueries();
onImported?.(certificate);
handleClose();
},
onError: (err) => setError(getErrorMessage(err)),
});
const handleManualSubmit = manualForm.handleSubmit((values) => {
setError('');
manualImportMutation.mutate(values);
});
const handleFileSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
fileImportMutation.mutate(fileForm);
};
const pending = manualImportMutation.isPending || fileImportMutation.isPending;
return (
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>添加证书</DialogTitle>
<DialogDescription>
支持手动粘贴 PEM 或上传证书文件。导入成功后可立即在网站表单里选择。
</DialogDescription>
</DialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Tabs value={importMode} onValueChange={(value) => setImportMode(value as 'manual' | 'file')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="manual">手动导入</TabsTrigger>
<TabsTrigger value="file">文件导入</TabsTrigger>
</TabsList>
<TabsContent value="manual" className="space-y-4">
<form className="space-y-4" onSubmit={handleManualSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>证书名称</Label>
<Input placeholder="example-com" {...manualForm.register('name')} />
{manualForm.formState.errors.name ? (
<p className="text-xs text-destructive">
{manualForm.formState.errors.name.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label>备注</Label>
<Input placeholder="例如:主站生产证书" {...manualForm.register('remark')} />
</div>
</div>
<div className="space-y-2">
<Label>证书 PEM</Label>
<Textarea
className="min-h-32 font-mono text-xs"
placeholder="-----BEGIN CERTIFICATE-----"
{...manualForm.register('cert_pem')}
/>
</div>
<div className="space-y-2">
<Label>私钥 PEM</Label>
<Textarea
className="min-h-32 font-mono text-xs"
placeholder="-----BEGIN PRIVATE KEY-----"
{...manualForm.register('key_pem')}
/>
</div>
<Button type="submit" disabled={pending}>
{pending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
导入中...
</>
) : (
'导入证书'
)}
</Button>
</form>
</TabsContent>
<TabsContent value="file" className="space-y-4">
<form className="space-y-4" onSubmit={handleFileSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>证书名称</Label>
<Input
value={fileForm.name}
onChange={(event) =>
setFileForm((current) => ({...current, name: event.target.value}))
}
placeholder="wildcard-example"
/>
</div>
<div className="space-y-2">
<Label>备注</Label>
<Input
value={fileForm.remark}
onChange={(event) =>
setFileForm((current) => ({...current, remark: event.target.value}))
}
placeholder="例如:泛域名生产证书"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>证书文件</Label>
<Input
key={`cert-${fileInputNonce}`}
type="file"
accept=".pem,.crt,.cer"
onChange={(event) => setCertFile(event.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
{certFile ? `已选择:${certFile.name}` : '请选择 PEM/CRT 文件'}
</p>
</div>
<div className="space-y-2">
<Label>私钥文件</Label>
<Input
key={`key-${fileInputNonce}`}
type="file"
accept=".key,.pem"
onChange={(event) => setKeyFile(event.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
{keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'}
</p>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{pending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
上传中...
</>
) : (
'上传文件'
)}
</Button>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={resetFileForm}
>
清空文件
</Button>
</div>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,148 @@
'use client';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {useForm} from 'react-hook-form';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {DnsAccountService} from '@/lib/services/openflare';
import {getErrorMessage} from './website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
type DnsAccountFormValues = {
name: string;
type: string;
authorization: string;
};
interface DnsAccountCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: () => void;
}
export function DnsAccountCreateDialog({
open,
onOpenChange,
onCreated,
}: DnsAccountCreateDialogProps) {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const {register, handleSubmit, setValue, watch, formState, reset} =
useForm<DnsAccountFormValues>({
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
});
const createMutation = useMutation({
mutationFn: DnsAccountService.create,
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
reset();
setError('');
onCreated?.();
onOpenChange(false);
},
onError: (err) => setError(getErrorMessage(err)),
});
const onSubmit = handleSubmit((values) => {
setError('');
let auth = values.authorization.trim();
if (!auth.startsWith('{')) {
auth = JSON.stringify({api_token: values.authorization});
}
createMutation.mutate({...values, authorization: auth});
});
const handleClose = () => {
reset();
setError('');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加 DNS 账号</DialogTitle>
<DialogDescription>
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={onSubmit}>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="space-y-2">
<Label>账号名称</Label>
<Input
placeholder="Cloudflare 邮箱账号"
{...register('name', {required: '请输入名称'})}
/>
{formState.errors.name ? (
<p className="text-xs text-destructive">{formState.errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<Label>DNS 服务商</Label>
<Select
value={watch('type')}
onValueChange={(value) => setValue('type', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>API Token</Label>
<Input
{...register('authorization', {required: '请输入 Token'})}
placeholder="请勿使用 Global API Key"
/>
{formState.errors.authorization ? (
<p className="text-xs text-destructive">
{formState.errors.authorization.message}
</p>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
取消
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
提交中...
</>
) : (
'提交'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
import {z} from 'zod';
export const managedDomainSchema = z.object({
domain: z
.string()
.trim()
.min(1, '请输入域名')
.max(255, '域名不能超过 255 个字符')
.refine(
(value) => !value.includes('://') && !value.includes('/'),
'域名格式不合法',
)
.refine(
(value) =>
/^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
value,
),
'域名格式不合法',
)
.refine(
(value) =>
!value.includes('*') ||
(value.startsWith('*.') && value.indexOf('*', 1) === -1),
'通配符域名仅支持 *.example.com 格式',
),
cert_id: z.string(),
enabled: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
export const manualImportSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入证书名称')
.max(255, '证书名称不能超过 255 个字符'),
cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
export type FileImportFormValues = {
name: string;
remark: string;
};
export const defaultManagedDomainValues: ManagedDomainFormValues = {
domain: '',
cert_id: '',
enabled: true,
remark: '',
};
export const defaultManualImportValues: ManualImportFormValues = {
name: '',
cert_pem: '',
key_pem: '',
remark: '',
};
export const defaultFileImportValues: FileImportFormValues = {
name: '',
remark: '',
};
export const acmeApplySchema = z.object({
name: z.string().trim().min(1, '请输入证书名称').max(255),
primary_domain: z.string().trim().min(1, '请输入主域名'),
other_domains: z.string(),
dns_account_id: z.number().min(1, '请选择 DNS 账号'),
acme_account_id: z.number(),
key_algorithm: z.string(),
auto_renew: z.boolean(),
disable_cname: z.boolean(),
skip_dns: z.boolean(),
dns1: z.string(),
dns2: z.string(),
remark: z.string().max(255),
});
export type AcmeApplyFormValues = z.infer<typeof acmeApplySchema>;
export const defaultAcmeApplyValues: AcmeApplyFormValues = {
name: '',
primary_domain: '',
other_domains: '',
dns_account_id: 0,
acme_account_id: 0,
key_algorithm: 'RSA2048',
auto_renew: true,
disable_cname: false,
skip_dns: false,
dns1: '',
dns2: '',
remark: '',
};
@@ -0,0 +1,30 @@
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
import type {StatusTone} from './website-utils';
const toneClassName: Record<StatusTone, string> = {
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600',
warning: 'bg-amber-500/10 border-amber-500/20 text-amber-600',
danger: 'bg-destructive/10 border-destructive/20 text-destructive',
info: 'bg-blue-500/10 border-blue-500/20 text-blue-600',
};
export function WebsiteStatusBadge({
label,
tone,
className,
}: {
label: string;
tone: StatusTone;
className?: string;
}) {
return (
<Badge
variant="outline"
className={cn('text-[10px] rounded-full py-0 px-2 font-medium', toneClassName[tone], className)}
>
{label}
</Badge>
);
}
@@ -0,0 +1,250 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useEffect} from 'react';
import {useForm, useWatch} from 'react-hook-form';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import type {ManagedDomainItem, TlsCertificateItem} from '@/lib/services/openflare';
import {WebsiteService} from '@/lib/services/openflare';
import {defaultManagedDomainValues, type ManagedDomainFormValues, managedDomainSchema,} from './schemas';
import {WebsiteStatusBadge} from './status-badge';
import {
buildCertificateLabel,
getErrorMessage,
getMatchTypeMeta,
toManagedDomainFormValues,
toManagedDomainPayload,
} from './website-utils';
const domainsQueryKey = ['openflare', 'managed-domains'];
interface WebsiteEditorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
onRequestImportCertificate: () => void;
certificates: TlsCertificateItem[];
certificatesLoading?: boolean;
initialDomain?: ManagedDomainItem | null;
preferredCertificateId?: number | null;
}
export function WebsiteEditorDialog({
open,
onOpenChange,
onSaved,
onRequestImportCertificate,
certificates,
certificatesLoading = false,
initialDomain = null,
preferredCertificateId = null,
}: WebsiteEditorDialogProps) {
const queryClient = useQueryClient();
const form = useForm<ManagedDomainFormValues>({
resolver: zodResolver(managedDomainSchema),
defaultValues: defaultManagedDomainValues,
});
const watchedDomain = useWatch({control: form.control, name: 'domain'});
const watchedCertId = useWatch({control: form.control, name: 'cert_id'});
const watchedEnabled = useWatch({control: form.control, name: 'enabled'});
const saveMutation = useMutation({
mutationFn: async (values: ManagedDomainFormValues) => {
const payload = toManagedDomainPayload(values);
return initialDomain
? WebsiteService.update(initialDomain.id, payload)
: WebsiteService.create(payload);
},
onSuccess: async (domain) => {
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
onSaved?.(domain, initialDomain ? 'update' : 'create');
handleClose();
},
});
const currentCertificate = watchedCertId
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
: null;
const handleSubmit = form.handleSubmit((values) => {
saveMutation.mutate(values);
});
const handleClose = () => {
saveMutation.reset();
form.reset(defaultManagedDomainValues);
onOpenChange(false);
};
useEffect(() => {
if (!open) return;
form.reset(
initialDomain
? toManagedDomainFormValues(initialDomain)
: defaultManagedDomainValues,
);
}, [form, initialDomain, open]);
useEffect(() => {
if (!open || !preferredCertificateId) return;
form.setValue('cert_id', String(preferredCertificateId), {
shouldDirty: true,
shouldValidate: true,
});
}, [form, open, preferredCertificateId]);
return (
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{initialDomain ? '编辑网站' : '新增网站'}</DialogTitle>
<DialogDescription>
录入域名并选择绑定证书。证书可在弹窗内直接新增,导入成功后会自动回填。
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{saveMutation.isError ? (
<p className="text-sm text-destructive">{getErrorMessage(saveMutation.error)}</p>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain">域名</Label>
<Input
id="domain"
placeholder="example.com 或 *.example.com"
{...form.register('domain')}
/>
{form.formState.errors.domain ? (
<p className="text-xs text-destructive">
{form.formState.errors.domain.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label>绑定证书</Label>
<Select
value={watchedCertId || 'none'}
disabled={certificatesLoading}
onValueChange={(value) =>
form.setValue('cert_id', value === 'none' ? '' : value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<SelectTrigger>
<SelectValue placeholder="不绑定证书" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不绑定证书</SelectItem>
{certificates.map((certificate) => (
<SelectItem key={certificate.id} value={String(certificate.id)}>
{buildCertificateLabel(certificate)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onRequestImportCertificate}
>
添加证书
</Button>
</div>
</div>
<div className="grid gap-3 rounded-lg border border-dashed p-3 md:grid-cols-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前域名</p>
<p className="mt-1 text-sm">{watchedDomain?.trim() || '未填写域名'}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">匹配类型</p>
<div className="mt-1">
{watchedDomain?.trim() ? (
<WebsiteStatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
) : (
<WebsiteStatusBadge label="等待输入" tone="warning" />
)}
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前证书</p>
<div className="mt-1">
<WebsiteStatusBadge
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
tone={currentCertificate ? 'success' : 'warning'}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">启用网站</p>
<p className="text-xs text-muted-foreground">
停用后该网站不会参与自动匹配,但记录会保留。
</p>
</div>
<Switch
checked={watchedEnabled}
onCheckedChange={(checked) =>
form.setValue('enabled', checked, {shouldDirty: true, shouldValidate: true})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remark">备注</Label>
<Input
id="remark"
placeholder="例如:主站 / 泛域名 / 生产"
{...form.register('remark')}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
取消
</Button>
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
保存中...
</>
) : initialDomain ? (
'保存网站'
) : (
'创建网站'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,119 @@
import type {
ManagedDomainItem,
ManagedDomainMutationPayload,
ProxyRouteItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import type {FileImportFormValues, ManagedDomainFormValues, ManualImportFormValues,} from './schemas';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getMatchTypeMeta(domain: string): {label: string; tone: StatusTone} {
return domain.startsWith('*.')
? {label: '通配符', tone: 'warning'}
: {label: '精确匹配', tone: 'info'};
}
export function getCertificateStatus(certificate: TlsCertificateItem): {
label: string;
tone: StatusTone;
} {
const expiresAt = new Date(certificate.not_after).getTime();
const diffMs = expiresAt - Date.now();
const days = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (Number.isNaN(expiresAt)) {
return {label: '有效期未知', tone: 'warning'};
}
if (days < 0) {
return {label: '已过期', tone: 'danger'};
}
if (days <= 30) {
return {label: `${days} 天内到期`, tone: 'warning'};
}
return {label: '有效', tone: 'success'};
}
export function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
: certificate.name;
}
export function toManagedDomainPayload(
values: ManagedDomainFormValues,
): ManagedDomainMutationPayload {
return {
domain: values.domain.trim().toLowerCase(),
cert_id: values.cert_id ? Number(values.cert_id) : null,
enabled: values.enabled,
remark: values.remark.trim(),
};
}
export function toManagedDomainFormValues(
domain: ManagedDomainItem,
): ManagedDomainFormValues {
return {
domain: domain.domain,
cert_id: domain.cert_id ? String(domain.cert_id) : '',
enabled: domain.enabled,
remark: domain.remark || '',
};
}
export function toManualPayload(
values: ManualImportFormValues,
): TlsCertificateMutationPayload {
return {
name: values.name.trim(),
cert_pem: values.cert_pem.trim(),
key_pem: values.key_pem.trim(),
remark: values.remark.trim(),
};
}
export function toFilePayload(
values: FileImportFormValues,
certFile: File | null,
keyFile: File | null,
): TlsCertificateFileImportPayload {
if (!certFile || !keyFile) {
throw new Error('请选择证书文件和私钥文件。');
}
return {
name: values.name.trim(),
remark: values.remark.trim(),
certFile,
keyFile,
};
}
export function isRouteRelatedToManagedDomain(
managedDomain: string,
route: ProxyRouteItem,
) {
const domains = route.domains.length > 0 ? route.domains : [route.primary_domain];
return domains.some((routeDomain) => {
if (managedDomain === routeDomain) {
return true;
}
if (!managedDomain.startsWith('*.')) {
return false;
}
const suffix = managedDomain.slice(2);
return routeDomain.endsWith(`.${suffix}`);
});
}
@@ -0,0 +1,527 @@
'use client';
import Link from 'next/link';
import {useRouter, useSearchParams} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {ArrowLeft, Globe, Plus, 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {ProxyRouteService, TlsCertificateService, WebsiteService,} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {getUpstreamSummary} from '@/app/(main)/openflare/proxy-routes/components/helpers';
import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
import {CertificateImportDialog} from '../components/certificate-import-dialog';
import {WebsiteStatusBadge} from '../components/status-badge';
import {WebsiteEditorDialog} from '../components/website-editor-dialog';
import {
buildCertificateLabel,
getCertificateStatus,
getErrorMessage,
getMatchTypeMeta,
isRouteRelatedToManagedDomain,
} from '../components/website-utils';
const domainsQueryKey = ['openflare', 'managed-domains'];
const certificatesQueryKey = ['openflare', 'tls-certificates'];
export function WebsiteDetailPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const websiteId = searchParams.get('id')?.trim() ?? '';
const [editorOpen, setEditorOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [certEditorOpen, setCertEditorOpen] = useState(false);
const [deleteWebsiteOpen, setDeleteWebsiteOpen] = useState(false);
const [deleteCertOpen, setDeleteCertOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
const [convertCertificate, setConvertCertificate] = useState<TlsCertificateItem | null>(null);
const domainsQuery = useQuery({
queryKey: domainsQueryKey,
queryFn: () => WebsiteService.list(),
});
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: () => TlsCertificateService.list(),
});
const routesQuery = useQuery({
queryKey: ['openflare', 'proxy-routes'],
queryFn: () => ProxyRouteService.list(),
});
const website = useMemo(
() =>
(domainsQuery.data ?? []).find((item) => String(item.id) === websiteId) ?? null,
[domainsQuery.data, websiteId],
);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
const certificate = website?.cert_id
? (certificateMap.get(website.cert_id) ?? null)
: null;
const relatedRoutes = useMemo(() => {
if (!website) return [];
return (routesQuery.data ?? []).filter((route) =>
isRouteRelatedToManagedDomain(website.domain, route),
);
}, [routesQuery.data, website]);
const deleteDomainMutation = useMutation({
mutationFn: (id: number) => WebsiteService.delete(id),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
router.push('/openflare/websites');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteCertificateMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.delete(id),
onSuccess: async () => {
toast.success('证书已删除');
setDeleteCertOpen(false);
await Promise.all([
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
queryClient.invalidateQueries({queryKey: domainsQueryKey}),
]);
},
onError: (error) => toast.error(getErrorMessage(error)),
});
if (!websiteId) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder
icon={Globe}
description="缺少网站 ID,请从网站列表进入详情页。"
/>
</div>
);
}
if (
domainsQuery.isLoading ||
certificatesQuery.isLoading ||
routesQuery.isLoading
) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Globe} description="加载网站详情中..." />
</div>
);
}
if (domainsQuery.isError) {
return (
<div className="py-6 px-1">
<ErrorInline
message={getErrorMessage(domainsQuery.error)}
onRetry={() => void domainsQuery.refetch()}
className="justify-center"
/>
</div>
);
}
if (!website) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/websites">
<ArrowLeft className="size-4 mr-1" />
返回网站列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Globe}
description="网站不存在,可能已被删除或 ID 无效。"
/>
</div>
);
}
const matchType = getMatchTypeMeta(website.domain);
const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
const enabledRoutesCount = relatedRoutes.filter((route) => route.enabled).length;
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">
<Globe className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{website.domain}</h1>
<p className="text-sm text-muted-foreground">网站详情</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites">
<ArrowLeft className="size-3.5 mr-1" />
返回
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setEditorOpen(true)}
>
编辑网站
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => setImportOpen(true)}>
<Plus className="size-3.5 mr-1" />
添加证书
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={() => setDeleteWebsiteOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
删除网站
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">匹配类型</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<WebsiteStatusBadge label={matchType.label} tone={matchType.tone} />
<p className="text-xs text-muted-foreground">
{website.domain.startsWith('*.')
? '当前网站会覆盖该后缀下的子域名规则。'
: '当前网站只匹配同名精确域名。'}
</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">网站状态</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<WebsiteStatusBadge
label={website.enabled ? '启用' : '停用'}
tone={website.enabled ? 'success' : 'warning'}
/>
<p className="text-xs text-muted-foreground">
更新时间:{formatDateTime(website.updated_at)}
</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">关联规则</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-xs text-muted-foreground">
<p>规则总数:{relatedRoutes.length}</p>
<p>已启用规则:{enabledRoutesCount}</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">绑定证书</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<WebsiteStatusBadge
label={certificate ? certificate.name : '未绑定证书'}
tone={certificate ? 'success' : 'warning'}
/>
<p className="text-xs text-muted-foreground">
{certificateStatus
? `证书状态:${certificateStatus.label}`
: '当前网站未设置默认证书。'}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">网站信息</CardTitle>
<CardDescription>当前网站的基础配置与托管信息。</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">域名</p>
<p className="mt-1 text-sm">{website.domain}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
创建时间
</p>
<p className="mt-1 text-sm">{formatDateTime(website.created_at)}</p>
</div>
<div className="rounded-lg border p-3 md:col-span-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
<p className="mt-1 text-sm">{website.remark || '暂无备注'}</p>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">证书信息</CardTitle>
<CardDescription>
当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。
</CardDescription>
</CardHeader>
<CardContent>
{certificate ? (
<div className="space-y-3">
{certificateStatus ? (
<WebsiteStatusBadge
label={certificateStatus.label}
tone={certificateStatus.tone}
/>
) : null}
<div className="space-y-1 text-xs text-muted-foreground">
<p>证书名称:{buildCertificateLabel(certificate)}</p>
<p>生效时间:{formatDateTime(certificate.not_before)}</p>
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setDetailOpen(true)}
>
查看
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setDeleteCertOpen(true)}
>
删除
</Button>
</div>
</div>
) : (
<EmptyStateWithBorder description="未绑定证书,点击右上角「添加证书」后可在编辑网站时直接选择。" />
)}
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">关联规则</CardTitle>
<CardDescription>命中当前网站域名的代理规则。</CardDescription>
</CardHeader>
<CardContent>
{relatedRoutes.length === 0 ? (
<EmptyStateWithBorder description="暂无关联规则。" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>规则域名</TableHead>
<TableHead>源站</TableHead>
<TableHead>HTTPS</TableHead>
<TableHead>证书</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relatedRoutes.map((route) => {
const routeCertificate = route.cert_id
? (certificateMap.get(route.cert_id) ?? null)
: null;
const matchedDomain =
route.domains.find((d) => d === website.domain) ?? route.primary_domain;
return (
<TableRow key={route.id}>
<TableCell>
<div className="space-y-1">
<p className="text-sm">{matchedDomain}</p>
<WebsiteStatusBadge
label={
matchedDomain === website.domain
? '直接关联'
: '被网站覆盖'
}
tone={matchedDomain === website.domain ? 'info' : 'warning'}
/>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-56 break-all">
{getUpstreamSummary(route)}
</TableCell>
<TableCell>
<WebsiteStatusBadge
label={route.enable_https ? '启用 HTTPS' : 'HTTP'}
tone={route.enable_https ? 'success' : 'warning'}
/>
</TableCell>
<TableCell className="text-xs">
{routeCertificate ? routeCertificate.name : '未绑定证书'}
</TableCell>
<TableCell>
<WebsiteStatusBadge
label={route.enabled ? '启用' : '停用'}
tone={route.enabled ? 'success' : 'warning'}
/>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{route.remark || '暂无备注'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<WebsiteEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
initialDomain={website}
certificates={certificates}
certificatesLoading={certificatesQuery.isLoading}
preferredCertificateId={preferredCertificateId}
onRequestImportCertificate={() => setImportOpen(true)}
onSaved={() => toast.success('网站已更新')}
/>
<CertificateImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={(imported) => {
setPreferredCertificateId(imported.id);
toast.success(`证书 ${imported.name} 已导入,可在编辑网站时直接应用`);
setEditorOpen(true);
}}
/>
<CertificateDetailDialog
certificateId={certificate?.id ?? null}
open={detailOpen}
onOpenChange={setDetailOpen}
onEdit={() => {
setDetailOpen(false);
setCertEditorOpen(true);
}}
onDelete={() => {
setDetailOpen(false);
setDeleteCertOpen(true);
}}
deleting={deleteCertificateMutation.isPending}
/>
<CertificateEditorDialog
certificateId={certificate?.id ?? null}
open={certEditorOpen}
onOpenChange={setCertEditorOpen}
onSaved={(updated) => toast.success(`证书 ${updated.name} 已更新`)}
onConvert={(manualCertificate) => {
setCertEditorOpen(false);
setConvertCertificate(manualCertificate);
}}
/>
{convertCertificate ? (
<CertificateApplyDialog
open
onOpenChange={(open) => !open && setConvertCertificate(null)}
mode="convert-upload"
certificate={convertCertificate}
onApplied={(converted) => {
setConvertCertificate(null);
toast.success(`证书 ${converted.name} 转换申请已提交`);
}}
/>
) : null}
<AlertDialog open={deleteWebsiteOpen} onOpenChange={setDeleteWebsiteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
<AlertDialogDescription>
确认删除网站 {website.domain} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteDomainMutation.mutate(website.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={deleteCertOpen} onOpenChange={setDeleteCertOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
<AlertDialogDescription>
确认删除证书 {certificate?.name} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => certificate && deleteCertificateMutation.mutate(certificate.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,23 @@
import {Suspense} from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import {WebsiteDetailPageClient} from './page-client';
function WebsiteDetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export default function WebsiteDetailPage() {
return (
<Suspense fallback={<WebsiteDetailPageFallback />}>
<WebsiteDetailPageClient />
</Suspense>
);
}
@@ -0,0 +1,165 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {Cloud, Plus, 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {DnsAccountItem} from '@/lib/services/openflare';
import {DnsAccountService} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {DnsAccountCreateDialog} from '../components/dns-account-create-dialog';
import {getErrorMessage} from '../components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
export default function DnsAccountsPage() {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DnsAccountItem | null>(null);
const dnsAccountsQuery = useQuery({
queryKey: dnsAccountsQueryKey,
queryFn: () => DnsAccountService.list(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => DnsAccountService.delete(id),
onSuccess: async () => {
toast.success('DNS 账号已删除');
setDeleteTarget(null);
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const accounts = useMemo(
() => dnsAccountsQuery.data ?? [],
[dnsAccountsQuery.data],
);
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Cloud className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">DNS 账号</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites">返回网站</Link>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites/certificates">证书</Link>
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => setCreateOpen(true)}>
<Plus className="size-3.5 mr-1" />
添加账号
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">DNS 账号列表</CardTitle>
<CardDescription>
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
</CardDescription>
</CardHeader>
<CardContent>
{dnsAccountsQuery.isLoading ? (
<LoadingStateWithBorder icon={Cloud} description="加载 DNS 账号中..." />
) : dnsAccountsQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(dnsAccountsQuery.error)}
onRetry={() => void dnsAccountsQuery.refetch()}
className="justify-center"
/>
</div>
) : accounts.length === 0 ? (
<EmptyStateWithBorder
icon={Cloud}
description="暂无 DNS 账号,点击右上角「添加账号」开始录入。"
/>
) : (
<div className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-start justify-between gap-3 rounded-lg border bg-card px-4 py-3"
>
<div className="space-y-1">
<p className="text-sm font-semibold">
{account.name}{' '}
<span className="text-xs font-normal text-muted-foreground">
({account.type})
</span>
</p>
<p className="text-xs text-muted-foreground">
创建于:{formatDateTime(account.created_at)}
</p>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setDeleteTarget(account)}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<DnsAccountCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => toast.success('DNS 账号已添加')}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 DNS 账号</AlertDialogTitle>
<AlertDialogDescription>
确认删除 DNS 账号 {deleteTarget?.name} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,7 +1,217 @@
import {Globe} from 'lucide-react';
'use client';
import {PlaceholderPage} from '../components/placeholder-page';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {Globe, Plus, 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, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {ManagedDomainItem} from '@/lib/services/openflare';
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
import {CertificateImportDialog} from './components/certificate-import-dialog';
import {WebsiteStatusBadge} from './components/status-badge';
import {WebsiteEditorDialog} from './components/website-editor-dialog';
import {buildCertificateLabel, getErrorMessage, getMatchTypeMeta,} from './components/website-utils';
const domainsQueryKey = ['openflare', 'managed-domains'];
const certificatesQueryKey = ['openflare', 'tls-certificates'];
export default function WebsitesPage() {
return <PlaceholderPage title="网站" icon={Globe} />;
}
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedDomainItem | null>(null);
const domainsQuery = useQuery({
queryKey: domainsQueryKey,
queryFn: () => WebsiteService.list(),
});
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: () => TlsCertificateService.list(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WebsiteService.delete(id),
onSuccess: async () => {
toast.success('网站已删除');
setDeleteTarget(null);
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const domains = useMemo(() => domainsQuery.data ?? [], [domainsQuery.data]);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Globe className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites/certificates">证书列表</Link>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/websites/dns-accounts">DNS 账号</Link>
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增网站
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">网站列表</CardTitle>
<CardDescription>查看网站绑定的证书、启用状态和更新时间。</CardDescription>
</CardHeader>
<CardContent>
{domainsQuery.isLoading ? (
<LoadingStateWithBorder icon={Globe} description="加载网站列表中..." />
) : domainsQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(domainsQuery.error)}
onRetry={() => void domainsQuery.refetch()}
className="justify-center"
/>
</div>
) : domains.length === 0 ? (
<EmptyStateWithBorder
icon={Globe}
description="暂无网站,点击右上角「新增网站」开始录入。"
/>
) : (
<div className="grid gap-3 lg:grid-cols-2">
{domains.map((domain) => {
const certificate = domain.cert_id
? (certificateMap.get(domain.cert_id) ?? null)
: null;
const matchType = getMatchTypeMeta(domain.domain);
return (
<div
key={domain.id}
className="rounded-lg border bg-card p-4 space-y-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-2 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold truncate">{domain.domain}</h2>
<WebsiteStatusBadge label={matchType.label} tone={matchType.tone} />
<WebsiteStatusBadge
label={domain.enabled ? '启用' : '停用'}
tone={domain.enabled ? 'success' : 'warning'}
/>
</div>
<p className="text-xs text-muted-foreground">
{domain.remark || '暂无备注'}
</p>
<p className="text-xs text-muted-foreground">
绑定证书:
{certificate
? buildCertificateLabel(certificate)
: '未绑定证书'}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/openflare/websites/detail?id=${domain.id}`}>
详情
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setDeleteTarget(domain)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<WebsiteEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
certificates={certificates}
certificatesLoading={certificatesQuery.isLoading}
preferredCertificateId={preferredCertificateId}
onRequestImportCertificate={() => setImportOpen(true)}
onSaved={(_, mode) => {
setPreferredCertificateId(null);
toast.success(mode === 'create' ? '网站已创建' : '网站已更新');
}}
/>
<CertificateImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={(certificate) => {
setPreferredCertificateId(certificate.id);
toast.success(`证书 ${certificate.name} 已导入,可直接用于当前网站`);
}}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
<AlertDialogDescription>
确认删除网站 {deleteTarget?.domain} 吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -17,19 +17,37 @@ export interface OpenFlareNavItem {
title: string;
url: string;
icon: LucideIcon;
/** 子页面在侧栏中仍高亮父级菜单项 */
childUrls?: string[];
}
/** OpenFlare 业务控制台侧栏导航 */
/** OpenFlare 业务控制台侧栏导航(子页面通过父级入口或页内链接访问) */
export const openflareNavItems: OpenFlareNavItem[] = [
{title: '总览', url: '/openflare', icon: LayoutDashboard},
{title: '节点', url: '/openflare/nodes', icon: Server},
{title: '规则', url: '/openflare/proxy-routes', icon: Route},
{title: 'Pages', url: '/openflare/pages', icon: FileText},
{title: '网站', url: '/openflare/websites', icon: Globe},
{title: 'WAF', url: '/openflare/waf', icon: Shield},
{title: '源站', url: '/openflare/origins', icon: MapPin},
{title: '节点', url: '/openflare/nodes', icon: Server, childUrls: ['/openflare/nodes/detail']},
{title: '规则', url: '/openflare/proxy-routes', icon: Route, childUrls: ['/openflare/proxy-routes/detail']},
{title: 'Pages', url: '/openflare/pages', icon: FileText, childUrls: ['/openflare/pages/detail']},
{
title: '网站',
url: '/openflare/websites',
icon: Globe,
childUrls: [
'/openflare/websites/detail',
'/openflare/websites/certificates',
'/openflare/websites/dns-accounts',
],
},
{title: 'WAF', url: '/openflare/waf', icon: Shield, childUrls: ['/openflare/waf/ip-groups']},
{title: '源站', url: '/openflare/origins', icon: MapPin, childUrls: ['/openflare/origins/detail']},
{title: '发布', url: '/openflare/config-versions', icon: GitBranch},
{title: '访问日志', url: '/openflare/access-logs', icon: ScrollText},
{title: '应用日志', url: '/openflare/apply-logs', icon: ClipboardList},
{title: '性能', url: '/openflare/performance', icon: Gauge},
];
];
/** 网站模块页内二级导航 */
export const openflareWebsiteSubNav = [
{title: '网站列表', url: '/openflare/websites'},
{title: '证书', url: '/openflare/websites/certificates'},
{title: 'DNS 账号', url: '/openflare/websites/dns-accounts'},
] as const;
+59 -1
View File
@@ -26,7 +26,24 @@ import {DbManageService} from './db-manage';
import {PushService} from './push';
import {AdminUploadService, UploadService} from './upload';
import {UserService} from './user';
import {ApplyLogService, ConfigVersionService, NodeService, ProxyRouteService,} from './openflare';
import {
AccessLogService,
ApplyLogService,
ConfigVersionService,
DashboardService,
DnsAccountService,
NodeService,
OptionService,
OriginService,
PagesService,
ProxyRouteService,
StatusService,
TlsCertificateService,
UpdateService,
UptimeKumaService,
WafService,
WebsiteService,
} from './openflare';
const services = {
auth: AuthService,
@@ -48,6 +65,18 @@ const services = {
openflareProxyRoute: ProxyRouteService,
openflareConfigVersion: ConfigVersionService,
openflareApplyLog: ApplyLogService,
openflareDashboard: DashboardService,
openflareWaf: WafService,
openflareWebsite: WebsiteService,
openflareTls: TlsCertificateService,
openflareDns: DnsAccountService,
openflarePages: PagesService,
openflareOrigin: OriginService,
openflareAccessLog: AccessLogService,
openflareOption: OptionService,
openflareUptimeKuma: UptimeKumaService,
openflareUpdate: UpdateService,
openflareStatus: StatusService,
} as const;
export default services;
@@ -157,6 +186,18 @@ export {
ProxyRouteService,
ConfigVersionService,
ApplyLogService,
DashboardService,
WafService,
WebsiteService,
TlsCertificateService,
DnsAccountService,
PagesService,
OriginService,
AccessLogService,
OptionService,
StatusService,
UpdateService,
UptimeKumaService,
} from './openflare';
export type {
@@ -169,4 +210,21 @@ export type {
ConfigDiffResult,
ConfigPreviewResult,
ApplyLogList,
DashboardOverview,
WAFIPGroup,
WAFRuleGroup,
WAFSiteRuleGroups,
ManagedDomainItem,
TlsCertificateItem,
DnsAccountItem,
PagesProject,
PagesDeployment,
OriginItem,
OriginDetail,
AccessLogList,
OptionItem,
GeoIPLookupResult,
DatabaseCleanupResult,
LatestReleaseInfo,
OpenFlarePublicStatus,
} from './openflare';
@@ -0,0 +1,76 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogList,
FoldedAccessLogFilters,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
FoldedAccessLogList,
} from './types';
function buildSearchParams(filters: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
Object.entries(filters as Record<string, string | number | undefined>).forEach(
([key, value]) => {
if (value === undefined || value === null || value === '') return;
params[key] = value;
},
);
return params;
}
export class AccessLogService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/access-logs';
static list(filters: AccessLogFilters = {}): Promise<AccessLogList> {
return this.legacyGet<AccessLogList>('/', buildSearchParams(filters));
}
static listFolds(
filters: FoldedAccessLogFilters,
): Promise<FoldedAccessLogList> {
return this.legacyGet<FoldedAccessLogList>(
'/folds',
buildSearchParams(filters),
);
}
static listFoldIPs(
filters: FoldedAccessLogIPFilters,
): Promise<FoldedAccessLogIPList> {
return this.legacyGet<FoldedAccessLogIPList>(
'/folds/ip-summary',
buildSearchParams(filters),
);
}
static listIPSummaries(
filters: AccessLogIPSummaryFilters = {},
): Promise<AccessLogIPSummaryList> {
return this.legacyGet<AccessLogIPSummaryList>(
'/ip-summary',
buildSearchParams(filters),
);
}
static getIPTrend(
filters: AccessLogIPTrendFilters,
): Promise<AccessLogIPTrend> {
return this.legacyGet<AccessLogIPTrend>(
'/ip-summary/trend',
buildSearchParams(filters),
);
}
static cleanup(
payload: AccessLogCleanupPayload,
): Promise<AccessLogCleanupResult> {
return this.legacyPost<AccessLogCleanupResult>('/cleanup', payload);
}
}
@@ -0,0 +1,242 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
CompactDiskIOTrendPoint,
CompactDistributionItem,
CompactNetworkTrendPoint,
CompactTrafficTrendPoint,
DashboardCapacity,
DashboardNodeHealth,
DashboardOverview,
DashboardOverviewCompact,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from './types';
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function isCompactDistributionItem(
value: DistributionItem | CompactDistributionItem,
): value is CompactDistributionItem {
return Array.isArray(value);
}
function isCompactTrafficTrendPoint(
value:
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint {
return Array.isArray(value);
}
function isCompactCapacityTrendPoint(
value:
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint,
): value is CompactCapacityTrendPoint {
return Array.isArray(value);
}
function isCompactNetworkTrendPoint(
value:
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint,
): value is CompactNetworkTrendPoint {
return Array.isArray(value);
}
function isCompactDiskIOTrendPoint(
value:
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint,
): value is CompactDiskIOTrendPoint {
return Array.isArray(value);
}
function isCompactDashboardNode(
value: DashboardNodeHealth | CompactDashboardNodeHealth,
): value is CompactDashboardNodeHealth {
return Array.isArray(value);
}
function normalizeDistributionItems(
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
): DistributionItem[] {
return arrayOrEmpty(items).map((item) =>
isCompactDistributionItem(item)
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
: item,
);
}
function normalizeTrafficTrendPoints(
items:
| Array<
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactTrafficTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
request_count: Number(item[1] ?? 0),
error_count: Number(item[2] ?? 0),
unique_visitor_count: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeCapacityTrendPoints(
items:
| Array<
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactCapacityTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
average_cpu_usage_percent: Number(item[1] ?? 0),
average_memory_usage_percent: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeNetworkTrendPoints(
items:
| Array<
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactNetworkTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
network_rx_bytes: Number(item[1] ?? 0),
network_tx_bytes: Number(item[2] ?? 0),
openresty_rx_bytes: Number(item[3] ?? 0),
openresty_tx_bytes: Number(item[4] ?? 0),
reported_nodes: Number(item[5] ?? 0),
}
: item,
);
}
function normalizeDiskIOTrendPoints(
items:
| Array<
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactDiskIOTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
disk_read_bytes: Number(item[1] ?? 0),
disk_write_bytes: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeDashboardNodes(
items:
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
| null
| undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
? {
id: Number(item[0] ?? 0),
node_id: String(item[1] ?? ''),
name: String(item[2] ?? ''),
geo_name: String(item[3] ?? ''),
geo_latitude:
item[4] === null || item[4] === undefined ? null : Number(item[4]),
geo_longitude:
item[5] === null || item[5] === undefined ? null : Number(item[5]),
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
openresty_status: (item[7] ??
'unknown') as DashboardNodeHealth['openresty_status'],
current_version: String(item[8] ?? ''),
last_seen_at: String(item[9] ?? ''),
active_event_count: Number(item[10] ?? 0),
cpu_usage_percent: Number(item[11] ?? 0),
memory_usage_percent: Number(item[12] ?? 0),
storage_usage_percent: Number(item[13] ?? 0),
request_count: Number(item[14] ?? 0),
error_count: Number(item[15] ?? 0),
unique_visitor_count: Number(item[16] ?? 0),
}
: item,
);
}
function normalizeDashboardOverview(
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
const summary = (overview.summary ?? {}) as DashboardSummary;
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
return {
generated_at: String(overview.generated_at ?? ''),
summary,
traffic,
capacity,
nodes: normalizeDashboardNodes(overview.nodes),
distributions: {
source_countries: normalizeDistributionItems(
overview.distributions?.source_countries,
),
status_codes: normalizeDistributionItems(
overview.distributions?.status_codes,
),
top_domains: normalizeDistributionItems(
overview.distributions?.top_domains,
),
},
trends: {
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
},
};
}
export class DashboardService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/dashboard';
static async getOverview(): Promise<DashboardOverview | null> {
const overview = await this.legacyGet<
DashboardOverview | DashboardOverviewCompact
>('/overview');
return normalizeDashboardOverview(overview);
}
}
@@ -0,0 +1,27 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {DnsAccountItem, DnsAccountMutationPayload} from './types';
export class DnsAccountService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/dns-accounts';
static async list(): Promise<DnsAccountItem[]> {
return this.legacyGet<DnsAccountItem[]>('/');
}
static async create(
payload: DnsAccountMutationPayload,
): Promise<DnsAccountItem> {
return this.legacyPost<DnsAccountItem>('/', payload);
}
static async update(
id: number,
payload: DnsAccountMutationPayload,
): Promise<DnsAccountItem> {
return this.legacyPost<DnsAccountItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
}
@@ -5,6 +5,18 @@ export { NodeService } from './node.service';
export { ProxyRouteService } from './proxy-route.service';
export { ConfigVersionService } from './config-version.service';
export { ApplyLogService } from './apply-log.service';
export { DashboardService } from './dashboard.service';
export { WafService } from './waf.service';
export { WebsiteService } from './website.service';
export { TlsCertificateService } from './tls-certificate.service';
export { DnsAccountService } from './dns-account.service';
export { PagesService } from './pages.service';
export { OriginService } from './origin.service';
export { AccessLogService } from './access-log.service';
export { OptionService } from './option.service';
export { UptimeKumaService } from './uptimekuma.service';
export { UpdateService } from './update.service';
export { StatusService } from './status.service';
export type {
ApplyLogCleanupPayload,
@@ -36,16 +48,98 @@ export type {
ProxyRoutePoWConfig,
ReleaseChannel,
SupportFile,
DashboardOverview,
DashboardNodeHealth,
DashboardSummary,
DashboardTraffic,
DashboardCapacity,
DistributionItem,
TrafficTrendPoint,
CapacityTrendPoint,
WAFIPGroup,
WAFIPGroupAutoTestPayload,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
WAFRuleGroup,
WAFRuleGroupPayload,
WAFSiteRuleGroups,
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogItem,
AccessLogList,
FoldedAccessLogFilters,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
FoldedAccessLogList,
OptionItem,
GeoIPLookupResult,
DatabaseCleanupPayload,
DatabaseCleanupResult,
DatabaseCleanupTarget,
LatestReleaseInfo,
OpenFlarePublicStatus,
OriginDetail,
OriginItem,
OriginMutationPayload,
PagesDeployment,
PagesDeploymentFile,
PagesProject,
PagesProjectPayload,
AcmeAccountItem,
DnsAccountItem,
DnsAccountMutationPayload,
ManagedDomainItem,
ManagedDomainMatchResult,
ManagedDomainMutationPayload,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from './types';
import {AccessLogService} from './access-log.service';
import {ApplyLogService} from './apply-log.service';
import {ConfigVersionService} from './config-version.service';
import {DashboardService} from './dashboard.service';
import {DnsAccountService} from './dns-account.service';
import {NodeService} from './node.service';
import {OptionService} from './option.service';
import {StatusService} from './status.service';
import {UpdateService} from './update.service';
import {UptimeKumaService} from './uptimekuma.service';
import {OriginService} from './origin.service';
import {PagesService} from './pages.service';
import {ProxyRouteService} from './proxy-route.service';
import {TlsCertificateService} from './tls-certificate.service';
import {WafService} from './waf.service';
import {WebsiteService} from './website.service';
export const openflareServices = {
node: NodeService,
proxyRoute: ProxyRouteService,
configVersion: ConfigVersionService,
applyLog: ApplyLogService,
dashboard: DashboardService,
waf: WafService,
website: WebsiteService,
tlsCertificate: TlsCertificateService,
dnsAccount: DnsAccountService,
pages: PagesService,
origin: OriginService,
accessLog: AccessLogService,
option: OptionService,
uptimeKuma: UptimeKumaService,
update: UpdateService,
status: StatusService,
} as const;
@@ -67,4 +67,12 @@ export class NodeService extends LegacyOpenFlareBaseService {
limit: options?.limit,
});
}
static async cleanupHealthEvents(
id: number,
): Promise<{ node_id: string; deleted_count: number }> {
return this.legacyPost<{ node_id: string; deleted_count: number }>(
`/${id}/observability/cleanup`,
);
}
}
@@ -0,0 +1,35 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
DatabaseCleanupPayload,
DatabaseCleanupResult,
GeoIPLookupResult,
OptionBatchPayload,
OptionItem,
} from './types';
export class OptionService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/option';
static list(): Promise<OptionItem[]> {
return this.legacyGet<OptionItem[]>('/');
}
static update(key: string, value: string): Promise<void> {
return this.legacyPost<void>('/update', { key, value });
}
static updateBatch(options: OptionItem[]): Promise<void> {
const payload: OptionBatchPayload = { options };
return this.legacyPost<void>('/update-batch', payload);
}
static lookupGeoIP(provider: string, ip: string): Promise<GeoIPLookupResult> {
return this.legacyPost<GeoIPLookupResult>('/geoip/lookup', { provider, ip });
}
static cleanupDatabase(
payload: DatabaseCleanupPayload,
): Promise<DatabaseCleanupResult> {
return this.legacyPost<DatabaseCleanupResult>('/database/cleanup', payload);
}
}
@@ -0,0 +1,29 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {OriginDetail, OriginItem, OriginMutationPayload,} from './types';
export class OriginService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/origins';
static list(): Promise<OriginItem[]> {
return this.legacyGet<OriginItem[]>('/');
}
static get(id: number): Promise<OriginDetail> {
return this.legacyGet<OriginDetail>(`/${id}`);
}
static create(payload: OriginMutationPayload): Promise<OriginItem> {
return this.legacyPost<OriginItem>('/', payload);
}
static update(
id: number,
payload: OriginMutationPayload,
): Promise<OriginItem> {
return this.legacyPost<OriginItem>(`/${id}/update`, payload);
}
static delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
}
@@ -0,0 +1,113 @@
import type {AxiosProgressEvent, InternalAxiosRequestConfig} from 'axios';
import apiClient from '@/lib/services/core/api-client';
import {ApiErrorBase} from '@/lib/services/core/errors';
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
LegacyApiResponse,
PagesDeployment,
PagesDeploymentFile,
PagesDeploymentUploadPayload,
PagesProject,
PagesProjectPayload,
} from './types';
export class PagesService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/pages';
static listProjects(): Promise<PagesProject[]> {
return this.legacyGet<PagesProject[]>('/');
}
static getProject(id: number): Promise<PagesProject> {
return this.legacyGet<PagesProject>(`/${id}`);
}
static createProject(payload: PagesProjectPayload): Promise<PagesProject> {
return this.legacyPost<PagesProject>('/', payload);
}
static updateProject(
id: number,
payload: PagesProjectPayload,
): Promise<PagesProject> {
return this.legacyPost<PagesProject>(`/${id}/update`, payload);
}
static deleteProject(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
static listDeployments(projectId: number): Promise<PagesDeployment[]> {
return this.legacyGet<PagesDeployment[]>(`/${projectId}/deployments`);
}
static listDeploymentFiles(
projectId: number,
deploymentId: number,
): Promise<PagesDeploymentFile[]> {
return this.legacyGet<PagesDeploymentFile[]>(
`/${projectId}/deployments/${deploymentId}/files`,
);
}
static uploadDeployment(
projectId: number,
payload: PagesDeploymentUploadPayload,
): Promise<PagesDeployment> {
const formData = new FormData();
formData.append('package', payload.file);
formData.append('root_dir', payload.rootDir ?? '');
formData.append('entry_file', payload.entryFile ?? 'index.html');
return this.postFormData<PagesDeployment>(
`/${projectId}/deployments/upload`,
formData,
payload.onProgress,
);
}
static activateDeployment(
projectId: number,
deploymentId: number,
): Promise<PagesProject> {
return this.legacyPost<PagesProject>(
`/${projectId}/deployments/${deploymentId}/activate`,
);
}
static deleteDeployment(
projectId: number,
deploymentId: number,
): Promise<void> {
return this.legacyPost<void>(
`/${projectId}/deployments/${deploymentId}/delete`,
);
}
private static async postFormData<T>(
path: string,
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const response = await apiClient.post<LegacyApiResponse<T>>(
this.getFullPath(path),
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event: AxiosProgressEvent) => {
if (!onProgress || !event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
},
} as InternalAxiosRequestConfig,
);
if (!response.data.success) {
throw new ApiErrorBase(response.data.message || '请求失败');
}
return response.data.data;
}
}
@@ -0,0 +1,10 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {OpenFlarePublicStatus} from './types';
export class StatusService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api';
static getPublicStatus(): Promise<OpenFlarePublicStatus> {
return this.legacyGet<OpenFlarePublicStatus>('/status');
}
}
@@ -0,0 +1,91 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
AcmeAccountItem,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from './types';
class AcmeAccountApi extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/acme-accounts';
static getDefault(): Promise<AcmeAccountItem> {
return this.legacyGet<AcmeAccountItem>('/default');
}
}
export class TlsCertificateService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/tls-certificates';
static async list(): Promise<TlsCertificateItem[]> {
return this.legacyGet<TlsCertificateItem[]>('/');
}
static async getById(id: number): Promise<TlsCertificateDetailItem> {
return this.legacyGet<TlsCertificateDetailItem>(`/${id}`);
}
static async getContent(id: number): Promise<TlsCertificateContentItem> {
return this.legacyGet<TlsCertificateContentItem>(`/${id}/content`);
}
static async create(
payload: TlsCertificateMutationPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>('/', payload);
}
static async update(
id: number,
payload: TlsCertificateMutationPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
static async apply(
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>('/apply', payload);
}
static async renew(id: number): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/renew`);
}
static async updateAcme(
id: number,
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/update-acme`, payload);
}
static async convertToAcme(
id: number,
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/convert-acme`, payload);
}
static async importFile(
payload: TlsCertificateFileImportPayload,
): Promise<TlsCertificateItem> {
const formData = new FormData();
formData.append('name', payload.name);
formData.append('remark', payload.remark);
formData.append('cert_file', payload.certFile);
formData.append('key_file', payload.keyFile);
return this.legacyPost<TlsCertificateItem>('/import-file', formData);
}
static getDefaultAcmeAccount(): Promise<AcmeAccountItem> {
return AcmeAccountApi.getDefault();
}
}
@@ -364,4 +364,699 @@ export interface ApplyLogCleanupResult {
retention_days: number;
deleted_count: number;
cutoff?: string;
}
// ==================== Pages ====================
export interface PagesDeployment {
id: number;
project_id: number;
deployment_number: number;
checksum: string;
status: 'uploaded' | 'active';
file_count: number;
total_size: number;
root_dir?: string;
entry_file: string;
created_by: string;
created_at: string;
activated_at?: string | null;
}
export interface PagesDeploymentFile {
id: number;
deployment_id: number;
path: string;
size: number;
checksum: string;
created_at: string;
}
export interface PagesProject {
id: number;
name: string;
slug: string;
description: string;
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
api_proxy_enabled: boolean;
api_proxy_path: string;
api_proxy_pass: string;
api_proxy_rewrite: string;
root_dir?: string;
entry_file: string;
active_deployment_id?: number | null;
active_deployment?: PagesDeployment | null;
deployment_count: number;
created_at: string;
updated_at: string;
}
export interface PagesProjectPayload {
name: string;
slug: string;
description: string;
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
api_proxy_enabled: boolean;
api_proxy_path: string;
api_proxy_pass: string;
api_proxy_rewrite: string;
root_dir?: string;
entry_file: string;
}
export interface PagesDeploymentUploadPayload {
file: File;
rootDir?: string;
entryFile?: string;
onProgress?: (percent: number) => void;
}
// ==================== Origins ====================
export interface OriginItem {
id: number;
name: string;
address: string;
remark: string;
route_count: number;
created_at: string;
updated_at: string;
}
export interface OriginRouteSummary {
id: number;
domain: string;
origin_url: string;
enabled: boolean;
updated_at: string;
}
export interface OriginDetail extends OriginItem {
routes: OriginRouteSummary[];
}
export interface OriginMutationPayload {
name: string;
address: string;
remark: string;
}
// ==================== Access Logs ====================
export interface AccessLogFilters {
node_id?: string;
remote_addr?: string;
host?: string;
path?: string;
p?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AccessLogItem {
id: number;
node_id: string;
node_name: string;
logged_at: string;
remote_addr: string;
region: string;
host: string;
path: string;
status_code: number;
}
export interface AccessLogList {
items: AccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
total_record: number;
total_ip: number;
}
export interface FoldedAccessLogFilters extends AccessLogFilters {
fold_minutes: 3 | 5;
}
export interface FoldedAccessLogItem {
bucket_started_at: string;
request_count: number;
unique_ip_count: number;
unique_host_count: number;
success_count: number;
client_error_count: number;
server_error_count: number;
}
export interface FoldedAccessLogList {
items: FoldedAccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
total_bucket: number;
total_record: number;
total_ip: number;
fold_minutes: number;
}
export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters {
bucket_started_at: string;
}
export interface FoldedAccessLogIPItem {
remote_addr: string;
request_count: number;
success_count: number;
client_error_count: number;
server_error_count: number;
last_seen_at: string;
}
export interface FoldedAccessLogIPList {
items: FoldedAccessLogIPItem[];
page: number;
page_size: number;
has_more: boolean;
total_ip: number;
bucket_started_at: string;
fold_minutes: number;
sort_by: string;
sort_order: 'asc' | 'desc';
}
export interface AccessLogIPSummaryFilters {
node_id?: string;
remote_addr?: string;
host?: string;
p?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AccessLogIPSummaryItem {
remote_addr: string;
total_requests: number;
recent_requests: number;
last_seen_at: string;
}
export interface AccessLogIPSummaryList {
items: AccessLogIPSummaryItem[];
page: number;
page_size: number;
has_more: boolean;
total_ip: number;
sort_by: string;
sort_order: 'asc' | 'desc';
}
export interface AccessLogIPTrendFilters {
node_id?: string;
remote_addr: string;
host?: string;
hours?: number;
bucket_minutes?: number;
}
export interface AccessLogIPTrendPoint {
bucket_started_at: string;
request_count: number;
}
export interface AccessLogIPTrend {
remote_addr: string;
hours: number;
bucket_minutes: number;
points: AccessLogIPTrendPoint[];
}
export interface AccessLogCleanupPayload {
retention_days: number;
}
export interface AccessLogCleanupResult {
retention_days: number;
deleted_count: number;
cutoff: string;
}
// ==================== Options (Performance) ====================
export interface OptionItem {
key: string;
value: string;
}
export interface OptionBatchPayload {
options: OptionItem[];
}
export interface GeoIPLookupResult {
provider: string;
ip: string;
iso_code: string;
name: string;
latitude?: number | null;
longitude?: number | null;
}
export type DatabaseCleanupTarget =
| 'node_access_logs'
| 'node_metric_snapshots'
| 'node_request_reports';
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;
server_address: string;
system_name: string;
}
export interface UpgradeLogItem {
level: string;
message: string;
created_at: string;
}
export interface LatestReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
channel: ReleaseChannel;
prerelease: boolean;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
in_progress: boolean;
upgrade_status: string;
upgrade_logs: UpgradeLogItem[];
}
export interface WAFRuleGroup {
id: number;
name: string;
enabled: boolean;
is_global: boolean;
block_status_code: number;
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
region_blacklist: string[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
applied_site_ids: number[];
applied_site_count: number;
created_at: string;
updated_at: string;
}
export interface WAFRuleGroupPayload {
name: string;
enabled: boolean;
block_status_code: number;
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
region_blacklist: string[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
}
export interface WAFSiteRuleGroups {
route_id: number;
global_rule_group: WAFRuleGroup | null;
rule_groups: WAFRuleGroup[];
applied_rule_groups: WAFRuleGroup[];
applied_ids: number[];
}
export type WAFIPGroupType = 'manual' | 'automatic' | 'subscription';
export type WAFIPGroupSubscriptionFormat = 'text' | 'json';
export interface WAFIPGroupExtIP {
ip: string;
captured_at: string;
}
export interface WAFIPGroup {
id: number;
name: string;
type: WAFIPGroupType;
enabled: boolean;
ip_list: string[];
auto_config: Record<string, unknown>;
ext_ips?: WAFIPGroupExtIP[];
subscription_url: string;
subscription_format: WAFIPGroupSubscriptionFormat;
subscription_mapping_rule: string;
sync_interval_minutes: number;
last_synced_at?: string;
next_sync_at?: string;
last_sync_status: string;
last_sync_message: string;
remark: string;
referenced_by_rule_count: number;
created_at: string;
updated_at: string;
}
export interface WAFIPGroupPayload {
name: string;
type: WAFIPGroupType;
enabled: boolean;
ip_list: string[];
auto_config: Record<string, unknown>;
subscription_url: string;
subscription_format: WAFIPGroupSubscriptionFormat;
subscription_mapping_rule: string;
sync_interval_minutes: number;
remark: string;
}
export interface WAFIPGroupSyncResult {
group: WAFIPGroup;
ip_count: number;
synced_at: string;
next_sync_at: string;
status: string;
message: string;
}
export interface WAFIPGroupAutoTestPayload {
auto_config: Record<string, unknown>;
}
export interface WAFIPGroupAutoTestResult {
matched_ips: string[];
matched_count: number;
lookback_minutes: number;
rule_count: number;
tested_at: string;
}
export interface DashboardSummary {
total_nodes: number;
online_nodes: number;
offline_nodes: number;
pending_nodes: number;
unhealthy_nodes: number;
}
export interface DashboardTraffic {
request_count: number;
unique_visitors: number;
error_count: number;
estimated_qps: number;
reported_nodes: number;
}
export interface DashboardCapacity {
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
high_cpu_nodes: number;
high_memory_nodes: number;
high_storage_nodes: number;
}
export interface DistributionItem {
key: string;
value: number;
}
export type CompactDistributionItem = [string, number];
export interface TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface CapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface DiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface TrafficDistributions {
status_codes: DistributionItem[];
top_domains: DistributionItem[];
source_countries: DistributionItem[];
}
export interface DashboardTrends {
traffic_24h: TrafficTrendPoint[];
capacity_24h: CapacityTrendPoint[];
network_24h: NetworkTrendPoint[];
disk_io_24h: DiskIOTrendPoint[];
}
export interface DashboardNodeHealth {
id: number;
node_id: string;
name: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
status: NodeStatus;
openresty_status: OpenrestyStatus;
current_version: string;
last_seen_at: string;
active_event_count: number;
cpu_usage_percent: number;
memory_usage_percent: number;
storage_usage_percent: number;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface DashboardOverview {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: TrafficDistributions;
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
}
export type CompactTrafficTrendPoint = [string, number, number, number];
export type CompactCapacityTrendPoint = [string, number, number, number];
export type CompactNetworkTrendPoint = [
string,
number,
number,
number,
number,
number,
];
export type CompactDiskIOTrendPoint = [string, number, number, number];
export type CompactDashboardNodeHealth = [
number,
string,
string,
string,
number | null,
number | null,
DashboardNodeHealth['status'],
DashboardNodeHealth['openresty_status'],
string,
string,
number,
number,
number,
number,
number,
number,
number,
];
export interface DashboardOverviewCompact {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: {
status_codes: CompactDistributionItem[];
top_domains: CompactDistributionItem[];
source_countries: CompactDistributionItem[];
};
trends: {
traffic_24h: CompactTrafficTrendPoint[];
capacity_24h: CompactCapacityTrendPoint[];
network_24h: CompactNetworkTrendPoint[];
disk_io_24h: CompactDiskIOTrendPoint[];
};
nodes: CompactDashboardNodeHealth[];
}
// ==================== Websites / TLS / DNS ====================
export interface ManagedDomainItem {
id: number;
domain: string;
cert_id: number | null;
enabled: boolean;
remark: string;
created_at: string;
updated_at: string;
}
export interface ManagedDomainMutationPayload {
domain: string;
cert_id: number | null;
enabled: boolean;
remark: string;
}
export interface ManagedDomainMatchCandidate {
managed_domain_id: number;
domain: string;
match_type: 'exact' | 'wildcard' | string;
certificate_id: number;
certificate_name: string;
}
export interface ManagedDomainMatchResult {
domain: string;
matched: boolean;
candidate?: ManagedDomainMatchCandidate;
candidates: ManagedDomainMatchCandidate[];
}
export interface TlsCertificateItem {
id: number;
name: string;
cert_pem?: string;
key_pem?: string;
provider: string;
acme_account_id: number;
dns_account_id: number;
key_algorithm: string;
auto_renew: boolean;
primary_domain: string;
other_domains: string;
disable_cname: boolean;
skip_dns: boolean;
dns1: string;
dns2: string;
apply_status: string;
apply_message: string;
not_before: string;
not_after: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface TlsCertificateDetailItem extends TlsCertificateItem {
cert_pem?: never;
key_pem?: never;
}
export interface TlsCertificateContentItem extends TlsCertificateItem {
cert_pem: string;
key_pem: string;
}
export interface TlsCertificateMutationPayload {
name: string;
cert_pem: string;
key_pem: string;
remark: string;
}
export interface TlsCertificateApplyPayload {
name: string;
remark: string;
acme_account_id: number;
dns_account_id: number;
key_algorithm: string;
auto_renew: boolean;
primary_domain: string;
other_domains: string;
disable_cname: boolean;
skip_dns: boolean;
dns1: string;
dns2: string;
}
export interface TlsCertificateFileImportPayload {
name: string;
remark: string;
certFile: File;
keyFile: File;
}
export interface AcmeAccountItem {
id: number;
email: string;
url: string;
created_at: string;
updated_at: string;
}
export interface DnsAccountItem {
id: number;
name: string;
type: string;
created_at: string;
updated_at: string;
}
export interface DnsAccountMutationPayload {
name: string;
type: string;
authorization: string;
}
@@ -0,0 +1,16 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {LatestReleaseInfo, ReleaseChannel} from './types';
/**
* OpenFlare 服务端升级 API(遗留 `/api/update/*`)。
*
* 可选集成:在顶栏或 Admin 升级入口调用 `getLatestRelease()` 展示版本与更新状态;
* 完整升级流程(upgrade / manual-upload / logs WebSocket)可对接 Wavelet `admin/updater`。
*/
export class UpdateService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/update';
static getLatestRelease(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
return this.legacyGet<LatestReleaseInfo>('/latest-release', { channel });
}
}
@@ -0,0 +1,9 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
export class UptimeKumaService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/uptimekuma';
static sync(): Promise<void> {
return this.legacyPost<void>('/sync');
}
}
@@ -0,0 +1,85 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
WAFIPGroup,
WAFIPGroupAutoTestPayload,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFRuleGroup,
WAFRuleGroupPayload,
WAFSiteRuleGroups,
} from './types';
export class WafService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/waf';
static async listRuleGroups(): Promise<WAFRuleGroup[]> {
return this.legacyGet<WAFRuleGroup[]>('/rule-groups');
}
static async getRuleGroup(id: number): Promise<WAFRuleGroup> {
return this.legacyGet<WAFRuleGroup>(`/rule-groups/${id}`);
}
static async createRuleGroup(payload: WAFRuleGroupPayload): Promise<WAFRuleGroup> {
return this.legacyPost<WAFRuleGroup>('/rule-groups', payload);
}
static async updateRuleGroup(
id: number,
payload: WAFRuleGroupPayload,
): Promise<WAFRuleGroup> {
return this.legacyPost<WAFRuleGroup>(`/rule-groups/${id}/update`, payload);
}
static async deleteRuleGroup(id: number): Promise<void> {
return this.legacyPost<void>(`/rule-groups/${id}/delete`);
}
static async updateRuleGroupSites(id: number, ids: number[]): Promise<WAFRuleGroup> {
return this.legacyPost<WAFRuleGroup>(`/rule-groups/${id}/sites`, { ids });
}
static async listSiteRuleGroups(routeId: number): Promise<WAFSiteRuleGroups> {
return this.legacyGet<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`);
}
static async updateSiteRuleGroups(
routeId: number,
ids: number[],
): Promise<WAFSiteRuleGroups> {
return this.legacyPost<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`, {
ids,
});
}
static async listIPGroups(): Promise<WAFIPGroup[]> {
return this.legacyGet<WAFIPGroup[]>('/ip-groups');
}
static async getIPGroup(id: number): Promise<WAFIPGroup> {
return this.legacyGet<WAFIPGroup>(`/ip-groups/${id}`);
}
static async createIPGroup(payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
return this.legacyPost<WAFIPGroup>('/ip-groups', payload);
}
static async updateIPGroup(id: number, payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
return this.legacyPost<WAFIPGroup>(`/ip-groups/${id}/update`, payload);
}
static async deleteIPGroup(id: number): Promise<void> {
return this.legacyPost<void>(`/ip-groups/${id}/delete`);
}
static async testIPGroup(
payload: WAFIPGroupAutoTestPayload,
): Promise<WAFIPGroupAutoTestResult> {
return this.legacyPost<WAFIPGroupAutoTestResult>('/ip-groups/test', payload);
}
static async syncIPGroup(id: number): Promise<WAFIPGroupSyncResult> {
return this.legacyPost<WAFIPGroupSyncResult>(`/ip-groups/${id}/sync`);
}
}
@@ -0,0 +1,31 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {ManagedDomainItem, ManagedDomainMatchResult, ManagedDomainMutationPayload,} from './types';
export class WebsiteService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/managed-domains';
static async list(): Promise<ManagedDomainItem[]> {
return this.legacyGet<ManagedDomainItem[]>('/');
}
static async create(
payload: ManagedDomainMutationPayload,
): Promise<ManagedDomainItem> {
return this.legacyPost<ManagedDomainItem>('/', payload);
}
static async update(
id: number,
payload: ManagedDomainMutationPayload,
): Promise<ManagedDomainItem> {
return this.legacyPost<ManagedDomainItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
static async match(domain: string): Promise<ManagedDomainMatchResult> {
return this.legacyGet<ManagedDomainMatchResult>('/match', {domain});
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": {
"predev": "node scripts/generate-themes.js",
"prebuild": "node scripts/generate-themes.js",
"dev": "next dev --turbopack",
"dev": "next dev --turbopack -p 3010",
"build": "next build",
"build:embed": "NEXT_STANDALONE_EXPORT=true next build",
"start": "next start -p 3010",
+3
View File
@@ -38,6 +38,9 @@ sidebar: false
- 实装数据库可观测性手动/自动清理、WAF IP 组订阅/自动同步与测试接口、UptimeKuma 监控同步、TLS ACME 申请/续期(lego DNS-01)。
- 修复 Wavelet Agent WebSocket 未处理 `status` 消息导致 WS 模式下 `last_seen_at` 停止更新、节点超时显示离线的问题;列表「最近心跳」恢复显示「WS 已连接」。
- 修复节点「强制同步」仍为 stub 导致始终返回「节点不在线或通过 WebSocket 发送同步指令失败」的问题。
- 将 OpenFlare 管理控制台从 `openflare-server/web` 迁移至 `Wavelet/frontend/app/(main)/openflare/`,复用 Wavelet shadcn 组件与 Session 鉴权;新增 `LegacyOpenFlareBaseService` 对接 `/api/*` 业务 API。
- 实装节点、代理规则(6 Section)、配置发布、WAF、网站/证书/DNS、Pages、源站、访问/应用日志、仪表盘、性能调优等业务页面;Admin 设置新增 OpenFlare 运维扩展 Tab。
- 修复静态导出构建中 `useSearchParams` 未包裹 Suspense 的问题;`pnpm build:embed` 全量 46 页通过。
## [v2.3.4] - 2026-06-17
@@ -2,7 +2,7 @@
> **文档类型**:实现计划(Implementation Plan)
> **创建日期**:2026-06-18
> **状态**:实施中(阶段 0 基建 + 阶段 2 核心链路并行)
> **状态**:基本完成(FC-1~19 已实装;验证见 `docs/plan/verify-frontend-*.md`)
> **前置阅读**:[`Wavelet/AGENTS.md`](../../Wavelet/AGENTS.md)、[`docs/plan/20260618-openflare-wavelet-backend-migration.md`](./20260618-openflare-wavelet-backend-migration.md)
> **依赖**:后端阶段一(旧路径 `/api/*` 兼容)完成后启动;阶段二可与后端阶段五并行
@@ -1,110 +1,50 @@
# OpenFlare 前端迁移 — 任务拆分与 AI 委派
> **状态**:进行中(阶段 0 基建 + 阶段 2 核心链路并行)
> **状态**:基本完成(验证通过,少量 P1 打磨待办)
> **目标前端**:`Wavelet/frontend/`(Next.js 16 + shadcn/ui + Session 鉴权)
> **业务 API**:阶段一仍调用 `/api/*` legacy 兼容层(响应 `{success,message,data}`)
> **平台 API**:认证/用户/Admin 使用 `/api/v1/*`(响应 `{error_msg,data}`)
## 任务隔离规则
| 规则 | 说明 |
|---|---|
| 文件所有权 | 每个任务 **仅修改** 自己的 `app/(main)/openflare/<module>/`、`lib/services/openflare/<module>.service.ts` |
| 共享文件 | `F0` 独占:`lib/navigation/`、`lib/services/openflare/legacy-base.service.ts`、`lib/services/openflare/index.ts`(注册入口)、`components/layout/sidebar.tsx`(仅新增 OpenFlare 导航组)、`app/(main)/openflare/layout.tsx` |
| 禁止修改 | Wavelet 框架核心:`app/(auth)/*` 逻辑、`lib/services/core/*`、`components/ui/*` 源码 |
| UI 规范 | 标题 `h1 text-2xl font-semibold tracking-tight`;单文件 ≤600 行;参考 `/admin/demo`、`/admin/database` 拆分模式 |
| 组件 | 仅用 `@/components/ui/*`(shadcn),**禁止**复制旧 `openflare-server/web/components/ui` |
| 服务层 | 业务 API 继承 `LegacyOpenFlareBaseService`;平台 API 继续用 `BaseService` |
| 鉴权 | Session Cookie(`withCredentials`),**禁止** `OpenFlare-Token` localStorage |
| 质量门禁 | `cd Wavelet/frontend && pnpm typecheck && pnpm lint` |
> **验证报告**:[`verify-frontend-routes.md`](./verify-frontend-routes.md)、[`verify-frontend-services.md`](./verify-frontend-services.md)、[`verify-frontend-ui.md`](./verify-frontend-ui.md)、[`verify-frontend-build.md`](./verify-frontend-build.md)
## 任务队列
| ID | 板块 | 状态 | 负责目录/文件 |
|---|---|---|---|
| F0 | 基建:Legacy 服务基类 + 导航 + 侧栏 + 路由骨架 | ✅ 完成 | `legacy-base.service.ts`、`openflare-nav.ts`、`sidebar.tsx`、`/home` → `/openflare` |
| F-NODE | 节点列表 + Edge 详情 | ✅ 完成(Relay/Tunnel 详情待补) | `node.service.ts`、`openflare/nodes/` |
| F-PROXY | 代理规则列表 + 详情骨架 | ✅ 骨架完成(6 Tab 待实装) | `proxy-route.service.ts`、`openflare/proxy-routes/` |
| F-CFG | 配置发布 + 应用日志 | ✅ 完成 | `config-version.service.ts`、`apply-log.service.ts` |
| F-DASH | 总览仪表盘 | ⏳ | `dashboard.service.ts`、`openflare/page.tsx` |
| F-WAF | WAF 规则组 + IP 组 | ⏳ | `waf.service.ts`、`openflare/waf/` |
| F-WEB | 网站/证书/DNS | ⏳ | `website.service.ts`、`tls-certificate.service.ts`、`openflare/websites/` |
| F-PAGES | Pages 托管 | ⏳ | `pages.service.ts`、`openflare/pages/` |
| F-ORIGIN | 源站 | ⏳ | `origin.service.ts`、`openflare/origins/` |
| F-LOGS | 访问日志 | ⏳ | `access-log.service.ts`、`openflare/access-logs/` |
| F-ADMIN | 运维设置扩展 Admin | ⏳ | `option.service.ts`、`admin/settings` 扩展块 |
| F-AUTH | 认证复用验证(Wavelet 原生) | ⏳ | 仅导航/回调 URL,不改登录页 |
| ID | 板块 | 状态 |
|---|---|---|
| F0 | 基建 | ✅ |
| F-NODE | 节点(含 Relay/Tunnel 详情) | ✅ |
| F-PROXY | 代理规则(含 6 Section 实装) | ✅ |
| F-CFG | 配置发布 + 应用日志 | ✅ |
| F-DASH | 总览仪表盘 | ✅(缺世界地图) |
| F-WAF | WAF 规则组 + IP 组 | ✅ |
| F-WEB | 网站/证书/DNS | ✅ |
| F-PAGES | Pages 托管 | ✅ |
| F-ORIGIN | 源站 | ✅ |
| F-LOGS | 访问日志 | ✅ |
| F-PERF | 性能调优 | ✅ |
| F-ADMIN | Admin 运维设置扩展 | ✅ |
| F-AUTH | Wavelet 登录/用户复用 | ✅(原生,未改登录页) |
## Legacy API 适配要点
## 验证结果摘要
旧 OpenFlare 业务 API 响应:
```json
{ "success": true, "message": "", "data": { ... } }
```
Wavelet `BaseService` 期望 `{ "error_msg": "", "data": ... }`。业务服务必须使用 `LegacyOpenFlareBaseService`:
```typescript
// lib/services/openflare/legacy-base.service.ts
protected static async legacyGet<T>(path: string, params?: Record<string, unknown>): Promise<T>
protected static async legacyPost<T>(path: string, data?: unknown): Promise<T>
// 解析 success/message/data;success=false 时 throw ApiErrorBase(message)
```
阶段一 `basePath` 示例:`/api/nodes`、`/api/proxy-routes`(无 `/v1` 前缀)。
## 页面规范速查
```tsx
// 标准页面外壳(参考 admin/demo)
<div className="py-6 px-1 space-y-6">
<div className="flex items-center gap-2">
<Icon className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">页面标题</h1>
</div>
{/* 内容 */}
</div>
```
| 旧组件 | Wavelet 替代 |
| 门禁 | 结果 |
|---|---|
| `app-modal` | `Dialog` / `Sheet` |
| `app-table` | `Table` + `@tanstack/react-table` 或简易 Table |
| `status-badge` | `Badge` variant |
| `primary-button` | `Button` |
| ECharts 趋势图 | Recharts(世界地图可暂保留 ECharts) |
| `tsc --noEmit` | ✅ |
| `pnpm lint` | ✅ |
| `pnpm build:embed` | ✅(46 静态页;已修 Suspense) |
| 路由覆盖 FC-1~19 | ✅ 18 完整 + 1 部分 |
| Service 覆盖 | ⚠️ `update` 升级流程待补 |
## 源代码参考
## 剩余 P1 待办
| 类型 | 路径 |
|---|---|
| 旧前端业务 | `openflare-server/web/features/<module>/` |
| 新前端标杆 | `Wavelet/frontend/app/(main)/admin/database/`、`admin/demo/` |
| 服务层范例 | `Wavelet/frontend/lib/services/admin/user.service.ts` |
| 迁移计划 | `docs/plan/20260618-openflare-wavelet-frontend-migration.md` |
| 后端 API | `docs/plan/20260618-openflare-wavelet-backend-migration.md` §12 |
1. **FC-20** About 页或链到 `/docs`
2. **UpdateService** 补全 upgrade/manual-upload/WS 日志
3. **UI 打磨**:部分 Dialog 补 Zod;access-logs/proxy-detail error 态
4. **FC-1** 世界地图(可选,当前用 geo 列表)
## 验收命令
```bash
cd Wavelet/frontend
pnpm typecheck
pnpm exec tsc --noEmit
pnpm lint
pnpm dev # 联调 wavelet api :3000
```
## 阶段验收标准
| 阶段 | 验收 |
|---|---|
| F0 完成 | 登录后侧栏可见 OpenFlare 导航;`/home` → `/openflare`;各路由有占位或骨架页 |
| F-NODE + F-PROXY + F-CFG | 节点 CRUD、规则 CRUD、配置发布/激活、应用日志列表可联调 |
| 全量 | 旧前端 18 个控制台路由功能对等;E2E 核心路径通过 |
## 下一步(F0 完成后)
1. 启动 **F-DASH**(仪表盘,依赖 dashboard API)
2. 启动 **F-WAF** + **F-WEB**(阶段 3 并行)
3. **F-ADMIN** 合并运维设置到 `admin/settings`
4. 更新 `docs/changelog/index.md`(代码变更时)
pnpm build:embed
pnpm dev
```
+2 -1
View File
@@ -16,7 +16,8 @@
| [OpenFlare → Wavelet 后端迁移计划](./20260618-openflare-wavelet-backend-migration.md) | 将 `openflare-server` 后端迁移至 Wavelet 框架,保留 `/api/*` 路径,复用用户/认证等平台能力 |
| [OpenFlare 后端迁移 — AI 接手](./handover-openflare-backend-migration.md) | 后端迁移当前进度、任务队列、定时任务、goose 版本与下一步行动(阶段 5 收尾) |
| [OpenFlare → Wavelet 前端迁移计划](./20260618-openflare-wavelet-frontend-migration.md) | 将 `openflare-server/web` 业务 UI 按 Wavelet 设计风格重写,复用框架组件与 Admin 基建 |
| [OpenFlare 前端迁移 — AI 委派](./handover-openflare-frontend-migration.md) | 前端迁移任务队列、Legacy API 适配规范、子智能体文件所有权与验收标准 |
| [OpenFlare 前端迁移 — AI 委派](./handover-openflare-frontend-migration.md) | 前端迁移任务队列与验收状态 |
| [前端路由验证](./verify-frontend-routes.md) · [Service 验证](./verify-frontend-services.md) · [UI 验证](./verify-frontend-ui.md) · [构建验证](./verify-frontend-build.md) | 多角度迁移验收报告 |
## 使用建议

Some files were not shown because too many files have changed in this diff Show More