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
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.
798 lines
35 KiB
TypeScript
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>
|
|
)
|
|
}
|