fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
This commit is contained in:
+155
-468
@@ -1,8 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
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"
|
||||
@@ -30,56 +35,25 @@ import {
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
import {
|
||||
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
||||
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
EyeIcon, EyeOffIcon,
|
||||
ChevronRightIcon, ChevronDownIcon,
|
||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
||||
ShieldIcon, WifiIcon,
|
||||
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 = [
|
||||
@@ -97,90 +71,8 @@ const COUNTRIES = [
|
||||
{ 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 }) {
|
||||
@@ -267,30 +159,30 @@ function WanUplinkEditor({ wans, onChange }: {
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Имя" required>
|
||||
<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 })} />
|
||||
</Field>
|
||||
<Field label="Интерфейс">
|
||||
</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 })} />
|
||||
</Field>
|
||||
<Field label="Провайдер (ISP)">
|
||||
</FormField>
|
||||
<FormField 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">
|
||||
</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 })} />
|
||||
</Field>
|
||||
<Field label="↓ Макс. Мбит">
|
||||
</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) })} />
|
||||
</Field>
|
||||
<Field label="↑ Макс. Мбит">
|
||||
</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) })} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -328,10 +220,11 @@ export default function ServersPage() {
|
||||
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 [expandedId, setExpandedId] = 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("")
|
||||
@@ -368,6 +261,7 @@ export default function ServersPage() {
|
||||
function openAdd() {
|
||||
setSheetMode("add"); setEditingId(null)
|
||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||
setSheetStep(1)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
@@ -381,7 +275,7 @@ export default function ServersPage() {
|
||||
lanSubnet: s.lanSubnet ?? "",
|
||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||
})
|
||||
setTestState("idle"); setTestMsg(""); setOpen(true)
|
||||
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
|
||||
|
||||
// Fetch full server details (including credentials) from backend
|
||||
if (isLive) {
|
||||
@@ -549,13 +443,14 @@ export default function ServersPage() {
|
||||
// ── derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return serverList.filter(sv => {
|
||||
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)
|
||||
})
|
||||
}, [serverList, search, typeFilter])
|
||||
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||
}, [serverList, search, typeFilter, advancedFilters])
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: serverList.length,
|
||||
@@ -615,272 +510,34 @@ export default function ServersPage() {
|
||||
</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 className="overflow-hidden py-0 gap-0">
|
||||
<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}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -893,18 +550,45 @@ export default function ServersPage() {
|
||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<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">
|
||||
|
||||
{/* 1. Основные */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
|
||||
<Field label="Имя сервера" required hint="Например home-msk-01">
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Тип узла" required>
|
||||
<FormField label="Тип узла" required>
|
||||
<SegmentedControl
|
||||
value={form.type}
|
||||
onChange={v => set("type", v)}
|
||||
@@ -914,24 +598,24 @@ export default function ServersPage() {
|
||||
{ value: "exit-node", label: "Exit Node" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Площадка" required hint="MSK, SPB, FRA…">
|
||||
<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())} />
|
||||
</Field>
|
||||
</FormField>
|
||||
{!isHomeRouter && (
|
||||
<Field label="ASN" hint="Например AS65001">
|
||||
<FormField label="ASN" hint="Например AS65001">
|
||||
<Input className="font-mono" placeholder="AS65001"
|
||||
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
{isHomeRouter && (
|
||||
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -939,45 +623,43 @@ export default function ServersPage() {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Включён</span>
|
||||
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
||||
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. WAN-аплинки (только для home-router) */}
|
||||
{isHomeRouter && (
|
||||
<div className="flex flex-col gap-4">
|
||||
</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)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Подключение (API) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
)}
|
||||
</StepperContent>
|
||||
<StepperContent value={3} className="flex flex-col gap-4">
|
||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
||||
|
||||
<Field label="Хост / IP-адрес" required
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Протокол">
|
||||
<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" }]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Порт" hint="443 / 80">
|
||||
</FormField>
|
||||
<FormField label="Порт" hint="443 / 80">
|
||||
<Input className="font-mono" placeholder="443"
|
||||
value={form.port} onChange={e => set("port", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -988,13 +670,13 @@ export default function ServersPage() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
||||
</div>
|
||||
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||
</div>
|
||||
|
||||
<Field label="Путь API">
|
||||
<FormField label="Путь API">
|
||||
<Input className="font-mono" placeholder="/rest"
|
||||
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
||||
</Field>
|
||||
</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>
|
||||
@@ -1008,12 +690,12 @@ export default function ServersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||
<Input className="font-mono" placeholder="api-user"
|
||||
value={form.username} onChange={e => set("username", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Пароль" required>
|
||||
<FormField label="Пароль" required>
|
||||
<div className="relative">
|
||||
<Input type={form.showPassword ? "text" : "password"}
|
||||
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
||||
@@ -1024,7 +706,7 @@ export default function ServersPage() {
|
||||
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||
@@ -1046,10 +728,8 @@ export default function ServersPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Дополнительно */}
|
||||
<div className="flex flex-col gap-4">
|
||||
</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" />}
|
||||
@@ -1059,34 +739,41 @@ export default function ServersPage() {
|
||||
{form.showAdvanced && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="SSH-порт">
|
||||
<FormField label="SSH-порт">
|
||||
<Input type="number" className="font-mono" value={form.sshPort}
|
||||
onChange={e => set("sshPort", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Winbox-порт">
|
||||
</FormField>
|
||||
<FormField label="Winbox-порт">
|
||||
<Input type="number" className="font-mono" value={form.winboxPort}
|
||||
onChange={e => set("winboxPort", Number(e.target.value))} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="Таймаут соединения, с">
|
||||
<FormField label="Таймаут соединения, с">
|
||||
<Input type="number" className="font-mono" value={form.timeout}
|
||||
onChange={e => set("timeout", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
</FormField>
|
||||
<FormField label="Комментарий">
|
||||
<Input placeholder="Описание или заметка" value={form.comment}
|
||||
onChange={e => set("comment", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user