mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
migrate frontend
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+323
-17
@@ -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>
|
||||
);
|
||||
}
|
||||
+187
-15
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+262
@@ -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>
|
||||
);
|
||||
}
|
||||
+226
-15
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+139
-15
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+427
-14
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+167
-32
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
@@ -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>
|
||||
);
|
||||
}
|
||||
+138
-13
@@ -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;
|
||||
}
|
||||
+40
@@ -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>
|
||||
);
|
||||
}
|
||||
+314
@@ -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'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>
|
||||
);
|
||||
}
|
||||
+189
@@ -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>
|
||||
);
|
||||
}
|
||||
+182
@@ -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>
|
||||
);
|
||||
}
|
||||
+264
@@ -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>
|
||||
);
|
||||
}
|
||||
+148
@@ -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;
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user