Init 2
This commit is contained in:
+228
-36
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { greTunnels, grePools, servers } from "@/lib/data"
|
||||
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
|
||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
DatabaseIcon,
|
||||
AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
@@ -42,12 +46,9 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
|
||||
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
|
||||
|
||||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||||
|
||||
function generateRosCommands(t: GreTunnel): string {
|
||||
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
|
||||
const lines: string[] = []
|
||||
const srv = serverById[t.serverId]
|
||||
|
||||
@@ -188,6 +189,74 @@ function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
interface GreTunnelsApiResponse {
|
||||
tunnels: GreTunnel[]
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!finalRes.ok) {
|
||||
const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string }
|
||||
throw new Error(err.error ?? finalRes.statusText)
|
||||
}
|
||||
return finalRes.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendToServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site,
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function derivePoolsFromTunnels(tunnels: GreTunnel[]): GrePool[] {
|
||||
const ids = [...new Set(tunnels.map((t) => t.poolId).filter(Boolean))]
|
||||
return ids.map((id) => {
|
||||
const list = tunnels.filter((t) => t.poolId === id)
|
||||
const allocated = list.length
|
||||
return {
|
||||
id,
|
||||
name: id === "live" ? "С устройств (опрос)" : id,
|
||||
cidr: "—",
|
||||
allocated,
|
||||
total: Math.max(allocated, 1),
|
||||
comment: "По poolId из данных GRE",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── default form states ─────────────────────────────────────────────────────
|
||||
|
||||
const defaultTunnelForm = {
|
||||
@@ -211,6 +280,17 @@ type PageTab = "tunnels" | "pools"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function GrePage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||||
const [syncJhMessage, setSyncJhMessage] = useState<string | null>(null)
|
||||
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
@@ -226,8 +306,94 @@ export default function GrePage() {
|
||||
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
|
||||
setTForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
setDataLoading(true)
|
||||
setDataError(null)
|
||||
setSyncJhMessage(null)
|
||||
try {
|
||||
const [backendServers, greRes] = await Promise.all([
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
|
||||
])
|
||||
setLiveServers(backendServers.map(mapBackendToServer))
|
||||
setLiveTunnels(greRes.tunnels)
|
||||
} catch (e) {
|
||||
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
setLiveServers([])
|
||||
setLiveTunnels([])
|
||||
} finally {
|
||||
setDataLoading(false)
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveServers([])
|
||||
setLiveTunnels([])
|
||||
setDataError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadLive()
|
||||
})
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const displayTunnels = isLive ? liveTunnels : mockGreTunnels
|
||||
const displayServers = isLive ? liveServers : mockServers
|
||||
const displayPools = useMemo(
|
||||
() => (isLive ? derivePoolsFromTunnels(displayTunnels) : mockGrePools),
|
||||
[isLive, displayTunnels],
|
||||
)
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
)
|
||||
const poolById = useMemo(
|
||||
() => Object.fromEntries(displayPools.map((p) => [p.id, p])),
|
||||
[displayPools],
|
||||
)
|
||||
|
||||
const syncJhToDb = useCallback(async () => {
|
||||
if (!isLive || syncJhBusy) return
|
||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||||
if (jh.length === 0) {
|
||||
setSyncJhMessage("Нет включённых Jump Host в списке серверов")
|
||||
return
|
||||
}
|
||||
setSyncJhBusy(true)
|
||||
setSyncJhMessage(null)
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const s of jh) {
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: s.id }),
|
||||
})
|
||||
} catch (e) {
|
||||
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
|
||||
}
|
||||
}
|
||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||||
setLiveTunnels(fresh.tunnels)
|
||||
if (errors.length) {
|
||||
setSyncJhMessage(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
||||
} else {
|
||||
setSyncJhMessage(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||
}
|
||||
} catch (e) {
|
||||
setSyncJhMessage(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||
} finally {
|
||||
setSyncJhBusy(false)
|
||||
}
|
||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return greTunnels.filter((t) => {
|
||||
return displayTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
@@ -240,16 +406,16 @@ export default function GrePage() {
|
||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [tabFilter, search])
|
||||
}, [tabFilter, search, displayTunnels, serverById])
|
||||
|
||||
const upCount = greTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
|
||||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||||
|
||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "Все", count: greTunnels.length },
|
||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||
]
|
||||
|
||||
function handleCopy(code: string) {
|
||||
@@ -265,7 +431,26 @@ export default function GrePage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
@@ -276,6 +461,13 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{(dataError || syncJhMessage) && (
|
||||
<div className={`flex items-start gap-3 rounded-lg border px-4 py-3 text-sm ${dataError ? "bg-destructive/5 border-destructive/30 text-destructive" : "bg-muted/40 border-border text-muted-foreground"}`}>
|
||||
<AlertCircleIcon className="size-5 shrink-0 mt-0.5" />
|
||||
<div>{dataError ?? syncJhMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
@@ -293,10 +485,10 @@ export default function GrePage() {
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: greTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: grePools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
@@ -358,11 +550,11 @@ export default function GrePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((t) => {
|
||||
{filtered.map((t, index) => {
|
||||
const srv = serverById[t.serverId]
|
||||
const pool = poolById[t.poolId]
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
|
||||
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
@@ -456,7 +648,7 @@ export default function GrePage() {
|
||||
{pageTab === "pools" && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
|
||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пул
|
||||
</Button>
|
||||
@@ -475,8 +667,8 @@ export default function GrePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{grePools.map((pool) => {
|
||||
const pct = Math.round((pool.allocated / pool.total) * 100)
|
||||
{displayPools.map((pool) => {
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||||
@@ -516,14 +708,14 @@ export default function GrePage() {
|
||||
<div className="border-t px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{grePools.map((pool) => {
|
||||
const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id)
|
||||
{displayPools.map((pool) => {
|
||||
const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id)
|
||||
return (
|
||||
<div key={pool.id}>
|
||||
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{poolTunnels.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
{poolTunnels.map((t, tunnelIndex) => (
|
||||
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
|
||||
<span className="font-mono font-medium">{t.name}</span>
|
||||
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||||
@@ -574,7 +766,7 @@ export default function GrePage() {
|
||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||||
{codePreviewTunnel && (() => {
|
||||
const code = generateRosCommands(codePreviewTunnel)
|
||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
@@ -664,9 +856,9 @@ export default function GrePage() {
|
||||
</Field>
|
||||
<Field label="Сервер (MikroTik)" required>
|
||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать сервер…</option>
|
||||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
@@ -692,9 +884,9 @@ export default function GrePage() {
|
||||
<SectionTitle>Внутренний IP</SectionTitle>
|
||||
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать пул…</option>
|
||||
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -736,20 +928,20 @@ export default function GrePage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Шифрование">
|
||||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Хеш-алгоритм">
|
||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
@@ -788,7 +980,7 @@ export default function GrePage() {
|
||||
</div>
|
||||
<Field label="DSCP">
|
||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="inherit">inherit</option>
|
||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user