Files
Denozordec fe32c9313a
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
feat(ui): integrate KpiStatGrid for enhanced statistics display
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
2026-09-06 17:58:05 +07:00

798 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 { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
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">
<KpiStatGrid
aria-label="Сводка серверов"
items={[
{
id: "all",
label: "Всего серверов",
value: counts.all,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "online",
label: "Онлайн",
value: counts.online,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "jh-en",
label: "JH + Exit Node",
value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* 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>
)
}