Files
MikrotikManager/app/(main)/servers/page.tsx
T
Denozordec d3a2d38b37
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
2026-06-30 22:22:51 +07:00

784 lines
35 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/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 { DataPageCard } from "@/components/data-page-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 {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import {
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react"
// ─── 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: "Норвегия" },
]
// ─── Shared small components ──────────────────────────────────────────────────
// ─── 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">
<FormField 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 })} />
</FormField>
<FormField label="Интерфейс">
<Input className="h-8 font-mono text-xs" placeholder="ether1"
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
</FormField>
<FormField label="Провайдер (ISP)">
<Input className="h-8 text-xs" placeholder="Rostelecom"
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
</FormField>
<FormField 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 })} />
</FormField>
<FormField 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) })} />
</FormField>
<FormField 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) })} />
</FormField>
</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 } = useDataSource()
const isLive = mode === "live"
const [serverList, setServerList] = useState<Server[]>([])
const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null)
const [sheetStep, setSheetStep] = useState(1)
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([])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, backendUrl])
// ── actions ──────────────────────────────────────────────────────────────
function openAdd() {
setSheetMode("add"); setEditingId(null)
setForm(defaultForm); setTestState("idle"); setTestMsg("")
setSheetStep(1)
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(""); setSheetStep(1); 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(() => {
const base = 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)
})
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters])
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 */}
<DataPageCard>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</DataPageCard>
</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>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Имя сервера" required hint="Например home-msk-01">
<Input className="font-mono" placeholder="home-msk-01"
value={form.name} onChange={e => set("name", e.target.value)} />
</FormField>
<FormField 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" },
]}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
<Input className="font-mono uppercase" placeholder="MSK"
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
</FormField>
{!isHomeRouter && (
<FormField label="ASN" hint="Например AS65001">
<Input className="font-mono" placeholder="AS65001"
value={form.asn} onChange={e => set("asn", e.target.value)} />
</FormField>
)}
{isHomeRouter && (
<FormField 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)} />
</FormField>
)}
</div>
<CountryField value={form.country} onChange={v => set("country", v)} />
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
</div>
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor
wans={form.wanUplinks}
onChange={wans => set("wanUplinks", wans)}
/>
)}
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
<FormField 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)} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField 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" }]}
/>
</FormField>
<FormField label="Порт" hint="443 / 80">
<Input className="font-mono" placeholder="443"
value={form.port} onChange={e => set("port", e.target.value)} />
</FormField>
</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>
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
</div>
<FormField label="Путь API">
<Input className="font-mono" placeholder="/rest"
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
</FormField>
<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>
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<Input className="font-mono" placeholder="api-user"
value={form.username} onChange={e => set("username", e.target.value)} />
</FormField>
<FormField 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>
</FormField>
<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>
</StepperContent>
<StepperContent value={4} 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">
<FormField label="SSH-порт">
<Input type="number" className="font-mono" value={form.sshPort}
onChange={e => set("sshPort", Number(e.target.value))} />
</FormField>
<FormField label="Winbox-порт">
<Input type="number" className="font-mono" value={form.winboxPort}
onChange={e => set("winboxPort", Number(e.target.value))} />
</FormField>
</div>
<FormField label="Таймаут соединения, с">
<Input type="number" className="font-mono" value={form.timeout}
onChange={e => set("timeout", Number(e.target.value))} />
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание или заметка" value={form.comment}
onChange={e => set("comment", e.target.value)} />
</FormField>
</div>
)}
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}