Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages.
1096 lines
54 KiB
TypeScript
1096 lines
54 KiB
TypeScript
"use client"
|
|
|
|
import { Fragment, useEffect, useMemo, useState } from "react"
|
|
import { PageHeader } from "@/components/page-header"
|
|
import { StatusBadge } from "@/components/status-badge"
|
|
import { servers as initialServers } from "@/lib/data"
|
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
|
import type { ServerCreate, ServerUpdate } from "@/packages/contracts/src/servers"
|
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
|
import {
|
|
createServer,
|
|
deleteServer,
|
|
getServer,
|
|
listServers,
|
|
pollServer,
|
|
testServerConnection,
|
|
updateServer,
|
|
} from "@/shared/api/servers"
|
|
|
|
// ─── Backend integration ──────────────────────────────────────────────────────
|
|
|
|
import { useDataSource } from "@/lib/data-source"
|
|
import { Flag } from "@/components/flag"
|
|
import { cn } from "@/lib/utils"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
|
SheetDescription, SheetFooter, SheetClose,
|
|
} from "@/components/ui/sheet"
|
|
import {
|
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import {
|
|
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
|
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
|
ChevronRightIcon, ChevronDownIcon,
|
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
|
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
|
HomeIcon, ServerIcon, NetworkIcon,
|
|
} from "lucide-react"
|
|
|
|
// ─── RouterOS version utilities ───────────────────────────────────────────────
|
|
|
|
/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */
|
|
function rosVer(os: string): number {
|
|
const m = os.match(/(\d+)\.(\d+)/)
|
|
if (!m) return 0
|
|
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
|
}
|
|
|
|
interface RosFeature { name: string; minVer: number; label: string; desc: string }
|
|
|
|
const ROS_FEATURES: RosFeature[] = [
|
|
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
|
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
|
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
|
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
|
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
|
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
|
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
|
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
|
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
|
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
|
]
|
|
|
|
function RosBadge({ os }: { os: string }) {
|
|
const v = rosVer(os)
|
|
const cls = v >= 715
|
|
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
|
: v >= 710
|
|
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
|
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
|
return (
|
|
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
|
|
{os}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── Countries ───────────────────────────────────────────────────────────────
|
|
|
|
const COUNTRIES = [
|
|
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
|
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
|
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
|
|
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
|
|
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
|
|
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
|
|
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
|
|
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
|
|
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
|
|
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
|
|
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
|
|
{ code: "NO", label: "Норвегия" },
|
|
]
|
|
|
|
// ─── Type config ─────────────────────────────────────────────────────────────
|
|
|
|
const TYPE_LABELS: Record<ServerType, string> = {
|
|
"jump-host": "JumpHost",
|
|
"exit-node": "Exit Node",
|
|
"home-router": "Home Router",
|
|
}
|
|
|
|
const TYPE_STYLES: Record<ServerType, string> = {
|
|
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
|
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
|
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
|
}
|
|
|
|
const TYPE_ICONS: Record<ServerType, React.ReactNode> = {
|
|
"jump-host": <ServerIcon className="size-3 mr-1" />,
|
|
"exit-node": <NetworkIcon className="size-3 mr-1" />,
|
|
"home-router": <HomeIcon className="size-3 mr-1" />,
|
|
}
|
|
|
|
function TypeBadge({ type }: { type: ServerType }) {
|
|
return (
|
|
<span className={cn(
|
|
"inline-flex items-center text-xs font-medium border rounded px-2 py-0.5",
|
|
TYPE_STYLES[type],
|
|
)}>
|
|
{TYPE_ICONS[type]}{TYPE_LABELS[type]}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── Shared small components ──────────────────────────────────────────────────
|
|
|
|
function Field({ label, hint, required, children }: {
|
|
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-sm font-medium">
|
|
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
</label>
|
|
{children}
|
|
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<button type="button" role="switch" aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
|
checked ? "bg-primary" : "bg-input")}>
|
|
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
checked ? "translate-x-4" : "translate-x-0")} />
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-2 py-0.5">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
|
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
{options.map((o) => (
|
|
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
|
className={cn("px-3 py-1 text-sm rounded transition-colors",
|
|
value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Country field ────────────────────────────────────────────────────────────
|
|
|
|
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
const [query, setQuery] = useState("")
|
|
const q = query.trim().toUpperCase()
|
|
const visible = q.length === 0
|
|
? COUNTRIES
|
|
: COUNTRIES.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-sm font-medium">Страна <span className="text-destructive">*</span></label>
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Input placeholder="Поиск или код (RU, DE…)" value={query}
|
|
onChange={e => {
|
|
const v = e.target.value.toUpperCase().slice(0, 3)
|
|
setQuery(v)
|
|
if (v.length === 2) {
|
|
const match = COUNTRIES.find(c => c.code === v)
|
|
if (match) onChange(match.code)
|
|
}
|
|
}}
|
|
className="font-mono pr-10 h-8 text-sm" />
|
|
{value && (
|
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
|
<Flag code={value} size={20} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
{value && (
|
|
<span className="text-sm font-mono text-muted-foreground shrink-0">
|
|
{COUNTRIES.find(c => c.code === value)?.label ?? value}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-1.5 max-h-52 overflow-y-auto pr-0.5">
|
|
{visible.map(c => (
|
|
<button key={c.code} type="button"
|
|
onClick={() => { onChange(c.code); setQuery("") }}
|
|
title={`${c.code} · ${c.label}`}
|
|
className={cn(
|
|
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
|
|
value === c.code
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
|
|
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
|
|
)}>
|
|
<Flag code={c.code} size={24} />
|
|
<span className="font-mono leading-none">{c.code}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── WAN uplinks editor ───────────────────────────────────────────────────────
|
|
|
|
function newWan(): WanUplink {
|
|
return { id: `w${Date.now()}`, name: "", isp: "", iface: "ether1", ip: "", maxDl: 100, maxUl: 100 }
|
|
}
|
|
|
|
function WanUplinkEditor({ wans, onChange }: {
|
|
wans: WanUplink[]
|
|
onChange: (wans: WanUplink[]) => void
|
|
}) {
|
|
function updateWan(id: string, patch: Partial<WanUplink>) {
|
|
onChange(wans.map(w => w.id === id ? { ...w, ...patch } : w))
|
|
}
|
|
function removeWan(id: string) { onChange(wans.filter(w => w.id !== id)) }
|
|
function addWan() { onChange([...wans, newWan()]) }
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{wans.map((wan, idx) => (
|
|
<div key={wan.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
WAN {idx + 1}
|
|
</span>
|
|
<button type="button" onClick={() => removeWan(wan.id)}
|
|
className="text-muted-foreground hover:text-destructive transition-colors">
|
|
<TrashIcon className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Field label="Имя" required>
|
|
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
|
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
|
</Field>
|
|
<Field label="Интерфейс">
|
|
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
|
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
|
</Field>
|
|
<Field label="Провайдер (ISP)">
|
|
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
|
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
|
</Field>
|
|
<Field label="Внешний IP">
|
|
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
|
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
|
</Field>
|
|
<Field label="↓ Макс. Мбит">
|
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
|
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
|
</Field>
|
|
<Field label="↑ Макс. Мбит">
|
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
|
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={addWan}>
|
|
<PlusIcon className="size-3.5" />Добавить WAN-аплинк
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Form state ───────────────────────────────────────────────────────────────
|
|
|
|
const defaultForm = {
|
|
name: "", type: "exit-node" as ServerType, site: "", country: "RU", asn: "", enabled: true,
|
|
host: "", port: "443", proto: "https" as "http" | "https",
|
|
verifySsl: true, timeout: 10,
|
|
apiPath: "/rest", username: "", password: "", showPassword: false,
|
|
showAdvanced: false, sshPort: 22, winboxPort: 8291, comment: "",
|
|
// home-router extras
|
|
lanSubnet: "",
|
|
wanUplinks: [] as WanUplink[],
|
|
}
|
|
|
|
type FormState = typeof defaultForm
|
|
type TestState = "idle" | "testing" | "ok" | "error"
|
|
type TypeFilter = "all" | ServerType
|
|
type SheetMode = "add" | "edit"
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
export default function ServersPage() {
|
|
const { mode, backendUrl, backendStatus } = useDataSource()
|
|
const isLive = mode === "live" && backendStatus === true
|
|
|
|
const [serverList, setServerList] = useState<Server[]>(initialServers)
|
|
const [_backendOk, setBackendOk] = useState(false)
|
|
const [search, setSearch] = useState("")
|
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
|
const [open, setOpen] = useState(false)
|
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [form, setForm] = useState<FormState>(defaultForm)
|
|
const [testState, setTestState] = useState<TestState>("idle")
|
|
const [testMsg, setTestMsg] = useState("")
|
|
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
|
const [pollAllBusy, setPollAllBusy] = useState(false)
|
|
|
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
|
setForm((f) => ({ ...f, [k]: v }))
|
|
|
|
// ── load from backend when mode/url changes ───────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (mode !== "live") {
|
|
queueMicrotask(() => {
|
|
setServerList(initialServers)
|
|
setBackendOk(false)
|
|
})
|
|
return
|
|
}
|
|
listServers(backendUrl)
|
|
.then(data => {
|
|
setBackendOk(true)
|
|
setServerList(data.map(toFrontendServer))
|
|
})
|
|
.catch(() => {
|
|
setBackendOk(false)
|
|
setServerList(initialServers)
|
|
})
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mode, backendUrl])
|
|
|
|
// ── actions ──────────────────────────────────────────────────────────────
|
|
|
|
function openAdd() {
|
|
setSheetMode("add"); setEditingId(null)
|
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
|
setOpen(true)
|
|
}
|
|
|
|
async function openEdit(s: Server) {
|
|
setSheetMode("edit"); setEditingId(s.id)
|
|
setForm({
|
|
...defaultForm,
|
|
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
|
|
asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host,
|
|
comment: s.comment ?? "",
|
|
lanSubnet: s.lanSubnet ?? "",
|
|
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
|
})
|
|
setTestState("idle"); setTestMsg(""); setOpen(true)
|
|
|
|
// Fetch full server details (including credentials) from backend
|
|
if (isLive) {
|
|
try {
|
|
const full = await getServer(backendUrl, s.id)
|
|
setForm(prev => ({
|
|
...prev,
|
|
username: full.username ?? "",
|
|
password: full.password ?? "",
|
|
port: String(full.port ?? 443),
|
|
proto: full.useSsl ? "https" : "http",
|
|
verifySsl: full.verifySsl ?? false,
|
|
lanSubnet: full.lanSubnet ?? prev.lanSubnet,
|
|
wanUplinks:
|
|
Array.isArray(full.wanUplinks) && full.wanUplinks.length
|
|
? JSON.parse(JSON.stringify(full.wanUplinks))
|
|
: prev.wanUplinks,
|
|
}))
|
|
} catch {
|
|
// ignore — user can fill in manually
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
const payload: ServerCreate = {
|
|
host: form.host,
|
|
port: Number(form.port) || (form.proto === "https" ? 443 : 80),
|
|
username: form.username,
|
|
password: form.password,
|
|
useSsl: form.proto === "https",
|
|
verifySsl: form.verifySsl,
|
|
name: form.name,
|
|
type: form.type,
|
|
site: form.site,
|
|
country: form.country,
|
|
asn: form.asn,
|
|
comment: form.comment,
|
|
enabled: form.enabled,
|
|
lanSubnet: form.lanSubnet.trim(),
|
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
|
}
|
|
|
|
if (isLive) {
|
|
try {
|
|
if (sheetMode === "edit" && editingId) {
|
|
const updatedPayload: ServerUpdate = payload
|
|
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
|
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
|
} else {
|
|
const created = await createServer(backendUrl, payload)
|
|
setServerList(list => [...list, toFrontendServer(created)])
|
|
}
|
|
} catch (e) {
|
|
console.error("Ошибка сохранения:", e)
|
|
}
|
|
} else {
|
|
// offline mode — update local state only
|
|
if (sheetMode === "edit" && editingId) {
|
|
setServerList(list => list.map(s =>
|
|
s.id === editingId
|
|
? {
|
|
...s,
|
|
name: form.name,
|
|
type: form.type,
|
|
site: form.site,
|
|
country: form.country,
|
|
host: form.host,
|
|
asn: form.asn,
|
|
comment: form.comment,
|
|
lanSubnet: form.lanSubnet || undefined,
|
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
|
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
|
}
|
|
: s
|
|
))
|
|
} else {
|
|
setServerList(list => [...list, {
|
|
id: `srv${Date.now()}`, name: form.name || form.host,
|
|
host: form.host, type: form.type, site: form.site,
|
|
country: form.country, asn: form.asn, model: "Unknown", os: "—",
|
|
status: form.enabled ? "online" : "offline",
|
|
enabled: form.enabled, latency: null, sessions: 0,
|
|
comment: form.comment || undefined,
|
|
lanSubnet: form.lanSubnet || undefined,
|
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
|
}])
|
|
}
|
|
}
|
|
|
|
setOpen(false)
|
|
}
|
|
|
|
function handleToggleStatus(id: string) {
|
|
setServerList(list => list.map(s =>
|
|
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s
|
|
))
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
if (isLive) {
|
|
try {
|
|
await deleteServer(backendUrl, id)
|
|
} catch (e) {
|
|
console.error("Ошибка удаления:", e)
|
|
}
|
|
}
|
|
setServerList(list => list.filter(s => s.id !== id))
|
|
}
|
|
|
|
async function handlePoll(id: string) {
|
|
if (!isLive) return
|
|
setPollingIds(p => new Set(p).add(id))
|
|
try {
|
|
await pollServer(backendUrl, id)
|
|
// Reload full list to get updated status/version
|
|
const data = await listServers(backendUrl)
|
|
setServerList(data.map(toFrontendServer))
|
|
} catch (e) {
|
|
console.error("Poll error:", e)
|
|
} finally {
|
|
setPollingIds(p => { const n = new Set(p); n.delete(id); return n })
|
|
}
|
|
}
|
|
|
|
async function handlePollAll() {
|
|
if (!isLive) return
|
|
setPollAllBusy(true)
|
|
try {
|
|
const ids = serverList.map(s => s.id)
|
|
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {})))
|
|
const data = await listServers(backendUrl)
|
|
setServerList(data.map(toFrontendServer))
|
|
} finally {
|
|
setPollAllBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleTest() {
|
|
if (!form.host || !form.username) {
|
|
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
|
}
|
|
setTestState("testing"); setTestMsg("")
|
|
try {
|
|
const data = await testServerConnection(backendUrl, {
|
|
host: form.host,
|
|
port: Number(form.port) || (form.proto === "https" ? 443 : 80),
|
|
useSsl: form.proto === "https",
|
|
verifySsl: form.verifySsl,
|
|
apiPath: form.apiPath || "/rest",
|
|
username: form.username,
|
|
password: form.password,
|
|
})
|
|
if (data.success) {
|
|
setTestState("ok"); setTestMsg(data.message)
|
|
} else {
|
|
setTestState("error"); setTestMsg(data.message)
|
|
}
|
|
} catch {
|
|
setTestState("error")
|
|
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`)
|
|
}
|
|
}
|
|
|
|
// ── derived ──────────────────────────────────────────────────────────────
|
|
|
|
const filtered = useMemo(() => {
|
|
return serverList.filter(sv => {
|
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
|
if (!search) return true
|
|
const q = search.toLowerCase()
|
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
|
})
|
|
}, [serverList, search, typeFilter])
|
|
|
|
const counts = useMemo(() => ({
|
|
all: serverList.length,
|
|
"jump-host": serverList.filter(s => s.type === "jump-host").length,
|
|
"exit-node": serverList.filter(s => s.type === "exit-node").length,
|
|
"home-router":serverList.filter(s => s.type === "home-router").length,
|
|
online: serverList.filter(s => s.status === "online").length,
|
|
}), [serverList])
|
|
|
|
const tabs: { value: TypeFilter; label: string }[] = [
|
|
{ value: "all", label: "Все" },
|
|
{ value: "jump-host", label: "JumpHost" },
|
|
{ value: "exit-node", label: "Exit Node" },
|
|
{ value: "home-router", label: "Home Router" },
|
|
]
|
|
|
|
const isHomeRouter = form.type === "home-router"
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<PageHeader
|
|
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
|
actions={
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}>
|
|
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все
|
|
</Button>
|
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
|
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="flex flex-col gap-5">
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
{[
|
|
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4 text-muted-foreground" /> },
|
|
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> },
|
|
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
|
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4 text-emerald-400" /> },
|
|
].map(s => (
|
|
<Card key={s.label}>
|
|
<CardContent className="px-5 py-4 flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{s.label}</p>
|
|
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
|
</div>
|
|
<div className="mt-0.5">{s.icon}</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<Card>
|
|
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
|
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
|
{tabs.map(tab => (
|
|
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
|
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
|
)}>
|
|
{tab.label}
|
|
<span className="text-xs tabular-nums opacity-60">
|
|
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
|
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
|
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
|
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
|
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
|
|
<th className="text-left font-medium px-4 py-3">Тип</th>
|
|
<th className="text-left font-medium px-4 py-3">Модель</th>
|
|
<th className="text-left font-medium px-4 py-3">RouterOS</th>
|
|
<th className="text-left font-medium px-4 py-3">Площадка</th>
|
|
<th className="text-left font-medium px-4 py-3">WAN / LAN</th>
|
|
<th className="text-right font-medium px-4 py-3">Задержка</th>
|
|
<th className="text-left font-medium px-4 py-3">Статус</th>
|
|
<th className="w-10 px-3 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{filtered.map(s => {
|
|
const isExpanded = expandedId === s.id
|
|
const ver = rosVer(s.os)
|
|
return (
|
|
<Fragment key={s.id}>
|
|
<tr
|
|
className={cn(
|
|
"hover:bg-muted/40 transition-colors cursor-pointer",
|
|
isExpanded && "bg-muted/30",
|
|
)}
|
|
onClick={() => setExpandedId(prev => prev === s.id ? null : s.id)}
|
|
>
|
|
{/* Expand chevron + name */}
|
|
<td className="px-5 py-3">
|
|
<div className="flex items-start gap-2">
|
|
{isExpanded
|
|
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
|
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
|
|
<div className="min-w-0">
|
|
<p className="font-medium truncate">{s.name}</p>
|
|
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
|
|
{s.ipv6Address && (
|
|
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
|
|
{s.ipv6Address}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3"><TypeBadge type={s.type} /></td>
|
|
<td className="px-4 py-3 text-muted-foreground text-xs">{s.model}</td>
|
|
<td className="px-4 py-3"><RosBadge os={s.os} /></td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<Flag code={s.country} />
|
|
<span className="font-medium">{s.site}</span>
|
|
</div>
|
|
</td>
|
|
{/* WAN / LAN column */}
|
|
<td className="px-4 py-3">
|
|
{s.type === "home-router" && s.wanUplinks?.length ? (
|
|
<div className="flex flex-col gap-0.5">
|
|
{s.wanUplinks.map(w => (
|
|
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
|
<WifiIcon className="size-3 text-sky-400 shrink-0" />
|
|
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
|
|
<span className="text-muted-foreground">{w.isp}</span>
|
|
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
|
|
</div>
|
|
))}
|
|
{s.lanSubnet && (
|
|
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
|
|
LAN {s.lanSubnet}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5">
|
|
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
|
|
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
|
|
<ShieldIcon className="size-3" />
|
|
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
|
|
</div>
|
|
)}
|
|
{s.rpkiEnabled && (
|
|
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
|
|
)}
|
|
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className={cn("px-4 py-3 font-mono text-right text-sm",
|
|
s.latency == null ? "text-muted-foreground"
|
|
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
|
|
{s.latency == null ? "—" : `${s.latency} мс`}
|
|
</td>
|
|
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
|
|
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger render={
|
|
<Button variant="ghost" size="icon" className="size-7">
|
|
<MoreHorizontalIcon className="size-4" />
|
|
</Button>
|
|
} />
|
|
<DropdownMenuContent side="bottom" align="end">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{s.name}</DropdownMenuLabel>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => window.open(`https://${s.host}`, "_blank")}>
|
|
<ExternalLinkIcon className="size-3.5" />Открыть WebFig
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => openEdit(s)}>
|
|
<PencilIcon className="size-3.5" />Редактировать
|
|
</DropdownMenuItem>
|
|
{isLive && (
|
|
<DropdownMenuItem onClick={() => handlePoll(s.id)} disabled={pollingIds.has(s.id)}>
|
|
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
|
|
{pollingIds.has(s.id) ? "Опрос…" : "Опросить"}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={() => handleToggleStatus(s.id)}>
|
|
<PowerIcon className="size-3.5" />
|
|
{s.status === "offline" ? "Включить" : "Отключить"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(s.id)}>
|
|
<Trash2Icon className="size-3.5" />Удалить
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* ── Expandable detail row ── */}
|
|
{isExpanded && (
|
|
<tr className="bg-muted/20">
|
|
<td colSpan={9} className="px-8 py-5 border-b border-border/50">
|
|
<div className="flex flex-col gap-5">
|
|
|
|
{/* Snapshot / live data */}
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
|
{s.model && s.model !== "—" && (
|
|
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
|
|
)}
|
|
{s.uptime && (
|
|
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
|
|
)}
|
|
{s.cpuLoad != null && (
|
|
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
|
|
)}
|
|
{s.asn && (
|
|
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
|
|
)}
|
|
{s.ipv6Address && (
|
|
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
|
|
)}
|
|
{s.vrfNames?.map(v => (
|
|
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
|
|
))}
|
|
{s.comment && (
|
|
<span className="text-muted-foreground italic">{s.comment}</span>
|
|
)}
|
|
{s.polledAt && (
|
|
<span className="text-muted-foreground/50 text-[11px]">
|
|
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
|
|
</span>
|
|
)}
|
|
{!s.polledAt && (
|
|
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
|
|
)}
|
|
</div>
|
|
|
|
{isLive && (
|
|
<Button
|
|
variant="outline" size="sm"
|
|
className="h-7 gap-1.5 text-xs shrink-0"
|
|
disabled={pollingIds.has(s.id)}
|
|
onClick={e => { e.stopPropagation(); handlePoll(s.id) }}
|
|
>
|
|
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
|
|
{pollingIds.has(s.id) ? "Опрос…" : "Опросить сейчас"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Feature matrix */}
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
|
Возможности RouterOS
|
|
</p>
|
|
<RosBadge os={s.os} />
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{ver >= 715
|
|
? "✓ Актуальная версия — все ключевые фичи доступны"
|
|
: ver >= 710
|
|
? "⚠ Рекомендуется обновление до 7.15+"
|
|
: s.os !== "—"
|
|
? "✗ Устаревшая версия — требуется обновление"
|
|
: "Нет данных — нажмите «Опросить сейчас»"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
|
{ROS_FEATURES.map(f => {
|
|
const ok = ver >= f.minVer
|
|
return (
|
|
<div key={f.name} className={cn(
|
|
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
|
|
ok
|
|
? "border-emerald-500/25 bg-emerald-500/5"
|
|
: "border-border/40 bg-background/40 opacity-60",
|
|
)}>
|
|
{ok
|
|
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
|
|
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
|
|
<div className="min-w-0">
|
|
<p className={cn(
|
|
"text-xs font-medium leading-tight truncate",
|
|
ok ? "text-foreground" : "text-muted-foreground",
|
|
)}>
|
|
{f.name}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
|
{f.label} · {f.desc}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
|
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
|
|
|
{/* 1. Основные */}
|
|
<div className="flex flex-col gap-4">
|
|
<SectionTitle>Основные</SectionTitle>
|
|
|
|
<Field label="Имя сервера" required hint="Например home-msk-01">
|
|
<Input className="font-mono" placeholder="home-msk-01"
|
|
value={form.name} onChange={e => set("name", e.target.value)} />
|
|
</Field>
|
|
|
|
<Field label="Тип узла" required>
|
|
<SegmentedControl
|
|
value={form.type}
|
|
onChange={v => set("type", v)}
|
|
options={[
|
|
{ value: "home-router", label: "Home Router" },
|
|
{ value: "jump-host", label: "JumpHost" },
|
|
{ value: "exit-node", label: "Exit Node" },
|
|
]}
|
|
/>
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Площадка" required hint="MSK, SPB, FRA…">
|
|
<Input className="font-mono uppercase" placeholder="MSK"
|
|
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
|
</Field>
|
|
{!isHomeRouter && (
|
|
<Field label="ASN" hint="Например AS65001">
|
|
<Input className="font-mono" placeholder="AS65001"
|
|
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
|
</Field>
|
|
)}
|
|
{isHomeRouter && (
|
|
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
|
<Input className="font-mono" placeholder="192.168.10.0/24"
|
|
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
|
</Field>
|
|
)}
|
|
</div>
|
|
|
|
<CountryField value={form.country} onChange={v => set("country", v)} />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Включён</span>
|
|
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2. WAN-аплинки (только для home-router) */}
|
|
{isHomeRouter && (
|
|
<div className="flex flex-col gap-4">
|
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
|
<WanUplinkEditor
|
|
wans={form.wanUplinks}
|
|
onChange={wans => set("wanUplinks", wans)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 3. Подключение (API) */}
|
|
<div className="flex flex-col gap-4">
|
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
|
|
|
<Field label="Хост / IP-адрес" required
|
|
hint={isHomeRouter
|
|
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
|
: "Внешний или управляющий IP-адрес роутера"}>
|
|
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
|
value={form.host} onChange={e => set("host", e.target.value)} />
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Протокол">
|
|
<SegmentedControl
|
|
value={form.proto}
|
|
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
|
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
|
/>
|
|
</Field>
|
|
<Field label="Порт" hint="443 / 80">
|
|
<Input className="font-mono" placeholder="443"
|
|
value={form.port} onChange={e => set("port", e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
|
<ShieldIcon className="size-3.5 text-muted-foreground" />
|
|
Проверять SSL-сертификат
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
|
</div>
|
|
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
|
</div>
|
|
|
|
<Field label="Путь API">
|
|
<Input className="font-mono" placeholder="/rest"
|
|
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
|
</Field>
|
|
|
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
|
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
|
<p className="font-mono text-foreground">
|
|
{form.proto}://{form.host || "host"}:{form.port}{form.apiPath}
|
|
</p>
|
|
{isHomeRouter && (
|
|
<p className="mt-1 text-amber-600 dark:text-amber-400">
|
|
⚠ Home Router доступен только из LAN / через VPN-туннель
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
|
<Input className="font-mono" placeholder="api-user"
|
|
value={form.username} onChange={e => set("username", e.target.value)} />
|
|
</Field>
|
|
|
|
<Field label="Пароль" required>
|
|
<div className="relative">
|
|
<Input type={form.showPassword ? "text" : "password"}
|
|
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
|
value={form.password} onChange={e => set("password", e.target.value)} />
|
|
<button type="button"
|
|
onClick={() => set("showPassword", !form.showPassword)}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
|
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
|
</button>
|
|
</div>
|
|
</Field>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
|
disabled={testState === "testing"} onClick={handleTest}>
|
|
{testState === "testing"
|
|
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
|
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
|
</Button>
|
|
{testState === "ok" && (
|
|
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
|
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
|
<CheckCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
|
</div>
|
|
)}
|
|
{testState === "error" && (
|
|
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
|
style={{ background: "var(--status-offline-bg)", color: "var(--status-offline-fg)" }}>
|
|
<XCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. Дополнительно */}
|
|
<div className="flex flex-col gap-4">
|
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
|
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
|
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
|
Дополнительно
|
|
<div className="flex-1 h-px bg-border" />
|
|
</button>
|
|
{form.showAdvanced && (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="SSH-порт">
|
|
<Input type="number" className="font-mono" value={form.sshPort}
|
|
onChange={e => set("sshPort", Number(e.target.value))} />
|
|
</Field>
|
|
<Field label="Winbox-порт">
|
|
<Input type="number" className="font-mono" value={form.winboxPort}
|
|
onChange={e => set("winboxPort", Number(e.target.value))} />
|
|
</Field>
|
|
</div>
|
|
<Field label="Таймаут соединения, с">
|
|
<Input type="number" className="font-mono" value={form.timeout}
|
|
onChange={e => set("timeout", Number(e.target.value))} />
|
|
</Field>
|
|
<Field label="Комментарий">
|
|
<Input placeholder="Описание или заметка" value={form.comment}
|
|
onChange={e => set("comment", e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
|
<Button className="flex-1" onClick={handleSave}>
|
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
)
|
|
}
|