diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index d7c0adf..68c2e37 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { StatusBadge } from "@/components/status-badge" import type { Backup, Server } from "@/lib/data" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { Button } from "@/components/ui/button" @@ -343,27 +342,39 @@ export default function BackupsPage() {
{s.label}
-{s.value}
-{s.label}
-{s.value}
-{s.label}
-{s.value}
-{s.label}
-{s.value}
-{label}
-{value}
-{s.label}
-{s.value}
-{label}
-- {delta} -
- ) : null} -{s.label}
-{s.value}
-{s.sub}
-
- {filteredRules.length !== currentRules.length
- ? `${filteredRules.length} из ${currentRules.length} правил`
- : `${currentRules.length} правил`
+ <>
+
+ {filteredRules.length !== currentRules.length + ? `${filteredRules.length} из ${currentRules.length} правил` + : `${currentRules.length} правил` + } +
+RouterOS CLI preview
{[
- `/ip firewall filter add \\`,
+ `${familyPath} ${tableName} add \\`,
` chain=${form.chain} \\`,
` action=${form.action}`,
form.proto !== "all" ? ` protocol=${form.proto} \\` : null,
- form.srcAddrList ? ` src-address-list=${form.srcAddrList} \\` : null,
- form.dstAddrList ? ` dst-address-list=${form.dstAddrList} \\` : null,
+ form.srcAddrList
+ ? looksLikeAddress(form.srcAddrList)
+ ? ` src-address=${form.srcAddrList} \\`
+ : ` src-address-list=${form.srcAddrList} \\`
+ : null,
+ form.dstAddrList
+ ? looksLikeAddress(form.dstAddrList)
+ ? ` dst-address=${form.dstAddrList} \\`
+ : ` dst-address-list=${form.dstAddrList} \\`
+ : null,
form.dstPort ? ` dst-port=${form.dstPort} \\` : null,
form.inIface ? ` in-interface=${form.inIface} \\` : null,
form.outIface ? ` out-interface=${form.outIface} \\` : null,
@@ -589,8 +667,12 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
}>Отмена
-
@@ -630,9 +712,22 @@ function ExportSheet({ open, onClose, rules }: {
// ─── Address List tab ─────────────────────────────────────────────────────────
-function AddressListsTab({ entries, onAdd }: {
+function AddressListsTab({
+ entries,
+ onAdd,
+ onAddToList,
+ onToggle,
+ onEdit,
+ onDelete,
+ showServer = false,
+}: {
entries: AddressListEntry[]
onAdd: () => void
+ onAddToList: (list: string) => void
+ onToggle: (entry: AddressListEntry) => void
+ onEdit: (entry: AddressListEntry) => void
+ onDelete: (entry: AddressListEntry) => void
+ showServer?: boolean
}) {
const [search, setSearch] = useState("")
const [listFilter, setListFilter] = useState("all")
@@ -642,16 +737,20 @@ function AddressListsTab({ entries, onAdd }: {
return ["all", ...Array.from(s)]
}, [entries])
+ useEffect(() => {
+ if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
+ }, [lists, listFilter])
+
const filtered = useMemo(() => {
return entries.filter((e) => {
if (listFilter !== "all" && e.list !== listFilter) return false
if (!search) return true
const q = search.toLowerCase()
return e.address.includes(q) || e.list.includes(q) || e.comment.toLowerCase().includes(q)
+ || (e.serverName ?? "").toLowerCase().includes(q)
})
}, [entries, search, listFilter])
- // Group by list name for display
const grouped = useMemo(() => {
if (listFilter !== "all") return { [listFilter]: filtered }
const g: Record = {}
@@ -664,7 +763,6 @@ function AddressListsTab({ entries, onAdd }: {
return (
- {/* toolbar */}
@@ -685,7 +783,6 @@ function AddressListsTab({ entries, onAdd }: {
Добавить
- {/* groups */}
{Object.entries(grouped).map(([listName, listEntries]) => (
@@ -696,7 +793,7 @@ function AddressListsTab({ entries, onAdd }: {
{listName}
({listEntries.length})
-
+ onAddToList(listName)}>
Адрес
@@ -709,11 +806,15 @@ function AddressListsTab({ entries, onAdd }: {
{e.address}
{e.comment}
+ {showServer && e.serverName && (
+ {e.serverName}
+ )}
{e.timeout && (
⏱ {e.timeout}
)}
+ onToggle(e)} />
@@ -721,9 +822,14 @@ function AddressListsTab({ entries, onAdd }: {
} />
- Редактировать
+ onEdit(e)}> Редактировать
+ onToggle(e)}>
+ {e.disabled ? "Включить" : "Отключить"}
+
- Удалить
+ onDelete(e)}>
+ Удалить
+
@@ -737,6 +843,93 @@ function AddressListsTab({ entries, onAdd }: {
)
}
+const defaultAddrForm = {
+ list: "",
+ address: "",
+ comment: "",
+ timeout: "",
+ enabled: true,
+}
+
+function AddressListSheet({
+ open, onClose, initial, family, onSave, saving = false,
+}: {
+ open: boolean
+ onClose: () => void
+ initial: Partial | null
+ family: IpFamily
+ onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void
+ saving?: boolean
+}) {
+ const isNew = !initial?.id
+ const [form, setForm] = useState(defaultAddrForm)
+
+ useEffect(() => {
+ if (!open) return
+ setForm({
+ list: initial?.list ?? "",
+ address: initial?.address ?? "",
+ comment: initial?.comment ?? "",
+ timeout: initial?.timeout ?? "",
+ enabled: !(initial?.disabled ?? false),
+ })
+ }, [open, initial])
+
+ return (
+ { if (!v) onClose() }}>
+
+
+ {isNew ? "Запись address-list" : "Редактировать запись"}
+
+ {family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]}
+
+
+
+
+ setForm((f) => ({ ...f, list: e.target.value }))} />
+
+
+ setForm((f) => ({ ...f, address: e.target.value }))} />
+
+
+ setForm((f) => ({ ...f, comment: e.target.value }))} />
+
+
+ setForm((f) => ({ ...f, timeout: e.target.value }))} />
+
+
+
+ Запись включена
+ Отключённые адреса не матчятся
+
+ setForm((f) => ({ ...f, enabled: v }))} />
+
+
+
+ }>Отмена
+ onSave({
+ list: form.list.trim(),
+ address: form.address.trim(),
+ comment: form.comment.trim() || undefined,
+ timeout: form.timeout.trim() || undefined,
+ disabled: !form.enabled,
+ })}
+ >
+ {saving ? "Сохранение…" : isNew ? "Добавить" : "Сохранить"}
+
+
+
+
+ )
+}
+
// ─── Verdict helpers ──────────────────────────────────────────────────────────
function getVerdictIcon(action: string) {
@@ -1588,44 +1781,277 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)
}
+// ─── Live API ─────────────────────────────────────────────────────────────────
+
+interface BackendServer {
+ id: number
+ name: string
+ host: string
+ type: "jump-host" | "exit-node" | "home-router"
+ site: string
+ country: string
+ asn: string
+ enabled: boolean
+ status: "online" | "offline" | null
+ latency: number | null
+}
+
+interface FirewallAllResponse {
+ rules: FirewallRule[]
+ addressLists: AddressListEntry[]
+}
+
+function makeApiFetch(backendUrl: string) {
+ return async function apiFetch(path: string, init?: RequestInit): Promise {
+ return requestJson(backendUrl, path, init)
+ }
+}
+
+function mapBackendToServer(s: BackendServer): Server {
+ return {
+ id: String(s.id),
+ name: s.name || s.host,
+ host: s.host,
+ model: "—",
+ os: "—",
+ site: s.site,
+ country: s.country || "UN",
+ asn: s.asn,
+ type: s.type,
+ enabled: s.enabled,
+ status: (s.status ?? "offline") as Server["status"],
+ latency: s.latency != null ? Math.round(s.latency) : null,
+ sessions: 0,
+ }
+}
+
+function ruleFamily(r: Pick): IpFamily {
+ return r.family === "ip6" ? "ip6" : "ip"
+}
+
+function addressFamily(e: Pick): IpFamily {
+ return e.family === "ip6" ? "ip6" : "ip"
+}
+
+function ruleKey(r: FirewallRule) {
+ if (!r.serverId || !r.rosId || !r.table) return null
+ return { serverId: r.serverId, family: ruleFamily(r), table: r.table, rosId: r.rosId }
+}
+
+function sameRuleGroup(a: FirewallRule, b: FirewallRule): boolean {
+ return (
+ a.serverId === b.serverId
+ && ruleFamily(a) === ruleFamily(b)
+ && (a.table ?? "filter") === (b.table ?? "filter")
+ )
+}
+
+function moveInArray(arr: T[], from: number, to: number): T[] {
+ if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr
+ const copy = [...arr]
+ const [moved] = copy.splice(from, 1)
+ if (moved === undefined) return arr
+ copy.splice(to, 0, moved)
+ return copy
+}
+
+function reorderWithinGroup(list: FirewallRule[], activeId: string, overId: string): FirewallRule[] | null {
+ const active = list.find((r) => r.id === activeId)
+ const over = list.find((r) => r.id === overId)
+ if (!active || !over || !sameRuleGroup(active, over)) return null
+ const same = (r: FirewallRule) => sameRuleGroup(r, active)
+ const group = list.filter(same)
+ const from = group.findIndex((r) => r.id === activeId)
+ const to = group.findIndex((r) => r.id === overId)
+ if (from < 0 || to < 0 || from === to) return null
+ const nextGroup = moveInArray(group, from, to)
+ let i = 0
+ return list.map((r) => (same(r) ? nextGroup[i++]! : r))
+}
+
+/** RouterOS move: insert-before destination. Omit dest = конец таблицы. */
+function rosMoveDestination(list: FirewallRule[], activeId: string, overId: string): { dest?: string } | null {
+ const active = list.find((r) => r.id === activeId)
+ const over = list.find((r) => r.id === overId)
+ if (!active || !over || !sameRuleGroup(active, over)) return null
+ const group = list.filter((r) => sameRuleGroup(r, active))
+ const from = group.findIndex((r) => r.id === activeId)
+ const to = group.findIndex((r) => r.id === overId)
+ if (from < 0 || to < 0 || from === to) return null
+ if (from < to) {
+ const dest = group[to + 1]?.rosId
+ return dest ? { dest } : {}
+ }
+ const dest = group[to]?.rosId
+ return dest ? { dest } : {}
+}
+
+function addressKey(e: AddressListEntry) {
+ if (!e.serverId || !e.rosId) return null
+ return { serverId: e.serverId, family: addressFamily(e), rosId: e.rosId }
+}
+
+function payloadToMockRule(
+ payload: RuleWritePayload,
+ opts: { id: string; table: FirewallTable; family: IpFamily; serverId: string; serverName: string; rosId: string },
+): FirewallRule {
+ return {
+ id: opts.id,
+ table: opts.table,
+ family: opts.family,
+ serverId: opts.serverId,
+ serverName: opts.serverName,
+ rosId: opts.rosId,
+ chain: payload.chain,
+ action: payload.action,
+ proto: payload.protocol ?? "all",
+ src: payload.srcAddress ?? payload.srcAddressList ?? "—",
+ dst: payload.dstAddress ?? payload.dstAddressList ?? "—",
+ port: payload.dstPort ?? payload.srcPort ?? "—",
+ iface: payload.inInterface ?? payload.outInterface ?? "—",
+ comment: payload.comment ?? "",
+ enabled: !payload.disabled,
+ hits: 0,
+ log: payload.log,
+ logPrefix: payload.logPrefix,
+ }
+}
+
// ════════════════════════════════════════════════════════════════════════════
-export default function FirewallPage() {
- const [rules, setRules] = useState(firewallRules)
- const [addrLists] = useState(INIT_ADDRESS_LISTS)
+function FirewallPageInner() {
+ const { mode, backendUrl } = useDataSource()
+ const isLive = mode === "live"
+ const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
+ const searchParams = useSearchParams()
+ const serverFromQuery = searchParams.get("server")
+
+ const [liveServers, setLiveServers] = useState([])
+ const [liveRules, setLiveRules] = useState([])
+ const [liveAddrLists, setLiveAddrLists] = useState([])
+ const [mockRules, setMockRules] = useState(firewallRules)
+ const [mockAddrLists, setMockAddrLists] = useState(INIT_ADDRESS_LISTS)
+ const [dataLoading, setDataLoading] = useState(false)
+ const [dataError, setDataError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
+ const appliedQueryRef = useRef(false)
const [chainGroup, setChainGroup] = useState("filter")
const [chainFilter, setChainFilter] = useState("all")
- const [ipFamily, setIpFamily] = useState("all")
+ const [ipFamily, setIpFamily] = useState("ip")
const [search, setSearch] = useState("")
const [editingRule, setEditingRule] = useState | null>(null)
const [ruleSheetOpen, setRuleSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
+ const [editingAddr, setEditingAddr] = useState | null>(null)
+ const [addrSheetOpen, setAddrSheetOpen] = useState(false)
- // ── derived ──────────────────────────────────────────────────────────────
+ const loadLive = useCallback(async () => {
+ if (!isLive) return
+ setDataLoading(true)
+ setDataError(null)
+ try {
+ const [backendServers, fw] = await Promise.all([
+ apiFetch("/api/servers"),
+ apiFetch("/api/firewall/all"),
+ ])
+ setLiveServers(backendServers.map(mapBackendToServer))
+ setLiveRules(fw.rules)
+ setLiveAddrLists(fw.addressLists)
+ } catch (e) {
+ setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
+ setLiveServers([])
+ setLiveRules([])
+ setLiveAddrLists([])
+ } finally {
+ setDataLoading(false)
+ }
+ }, [isLive, apiFetch])
+
+ useEffect(() => {
+ if (!isLive) {
+ queueMicrotask(() => {
+ setLiveServers([])
+ setLiveRules([])
+ setLiveAddrLists([])
+ setDataError(null)
+ })
+ return
+ }
+ queueMicrotask(() => {
+ void loadLive()
+ })
+ }, [isLive, loadLive])
+
+ useEffect(() => {
+ if (dataError) toast.error(dataError)
+ }, [dataError])
+
+ const displayRules = isLive ? liveRules : mockRules
+ const displayAddrLists = isLive ? liveAddrLists : mockAddrLists
+ const displayServers = isLive ? liveServers : mockServers
+
+ useEffect(() => {
+ if (appliedQueryRef.current || !serverFromQuery) return
+ if (displayServers.length === 0) return
+ if (displayServers.some((s) => s.id === serverFromQuery)) {
+ setSelectedServerId(serverFromQuery)
+ }
+ appliedQueryRef.current = true
+ }, [displayServers, serverFromQuery])
+
+ const scopedRules = useMemo(() => {
+ if (selectedServerId === ALL_SERVERS_ID) return displayRules
+ return displayRules.filter((r) => r.serverId === selectedServerId)
+ }, [displayRules, selectedServerId])
+
+ const scopedAddrLists = useMemo(() => {
+ if (selectedServerId === ALL_SERVERS_ID) return displayAddrLists
+ return displayAddrLists.filter((e) => e.serverId === selectedServerId)
+ }, [displayAddrLists, selectedServerId])
+
+ const familyRules = useMemo(
+ () => scopedRules.filter((r) => ruleFamily(r) === ipFamily),
+ [scopedRules, ipFamily],
+ )
+ const familyAddrLists = useMemo(
+ () => scopedAddrLists.filter((e) => addressFamily(e) === ipFamily),
+ [scopedAddrLists, ipFamily],
+ )
+
+ const firewallRailItems = useMemo(() => (
+ displayServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status,
+ type: s.type,
+ enabled: s.enabled,
+ meta: String(displayRules.filter((r) => r.serverId === s.id).length),
+ }))
+ ), [displayServers, displayRules])
+
+ const writeServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
+ const writeServer = displayServers.find((s) => s.id === writeServerId)
const chainsInGroup = useMemo(() => {
- if (chainGroup === "address-lists") return []
- const grp = CHAIN_GROUPS.find((g) => g.id === chainGroup)
- if (!grp) return []
- const v4 = grp.chains
- const v6 = grp.ip6chains ?? []
- if (ipFamily === "ip") return v4
- if (ipFamily === "ip6") return v6
- return [...v4, ...v6]
- }, [chainGroup, ipFamily])
+ if (chainGroup === "address-lists" || chainGroup === "simulator") return []
+ return CHAIN_GROUPS.find((g) => g.id === chainGroup)?.chains ?? []
+ }, [chainGroup])
const groupRules = useMemo(() => {
- if (chainGroup === "address-lists") return []
- return rules.filter((r) => chainsInGroup.includes(r.chain))
- }, [rules, chainGroup, chainsInGroup])
+ const table = tableOfGroup(chainGroup)
+ if (!table) return []
+ return familyRules.filter((r) => r.table === table)
+ }, [familyRules, chainGroup])
const filteredRules = useMemo(() => {
return groupRules.filter((r) => {
if (chainFilter !== "all" && r.chain !== chainFilter) return false
- // ipFamily filtering also respects rules with explicit family field
- if (ipFamily === "ip" && r.family === "ip6") return false
- if (ipFamily === "ip6" && r.family === "ip") return false
if (!search) return true
const q = search.toLowerCase()
return (
@@ -1633,10 +2059,11 @@ export default function FirewallPage() {
r.action.toLowerCase().includes(q) ||
r.src.toLowerCase().includes(q) ||
r.dst.toLowerCase().includes(q) ||
- r.comment.toLowerCase().includes(q)
+ r.comment.toLowerCase().includes(q) ||
+ (r.serverName ?? "").toLowerCase().includes(q)
)
})
- }, [groupRules, chainFilter, ipFamily, search])
+ }, [groupRules, chainFilter, search])
const chainCounts = useMemo(() => {
const counts: Record = { all: groupRules.length }
@@ -1646,19 +2073,101 @@ export default function FirewallPage() {
return counts
}, [groupRules])
- // ── stats ────────────────────────────────────────────────────────────────
+ const totalEnabled = familyRules.filter((r) => r.enabled).length
+ const totalHits = familyRules.reduce((s, r) => s + r.hits, 0)
+ const dropRules = familyRules.filter((r) => r.action === "drop" || r.action === "reject").length
+ const showServerCol = selectedServerId === ALL_SERVERS_ID
- const totalEnabled = rules.filter((r) => r.enabled).length
- const totalHits = rules.reduce((s, r) => s + r.hits, 0)
- const dropRules = rules.filter((r) => r.action === "drop" || r.action === "reject").length
+ function requireWriteServer(): string | null {
+ if (writeServerId) return writeServerId
+ toast.info("Выберите сервер в панели слева")
+ return null
+ }
- // ── handlers ─────────────────────────────────────────────────────────────
+ async function toggleRule(id: string) {
+ const r = displayRules.find((x) => x.id === id)
+ if (!r) return
+ if (isLive) {
+ const key = ruleKey(r)
+ if (!key) return
+ try {
+ await apiFetch("/api/firewall/rules", {
+ method: "PATCH",
+ body: JSON.stringify({ ...key, disabled: r.enabled }),
+ })
+ await loadLive()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Не удалось переключить правило")
+ }
+ return
+ }
+ setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))
+ }
- function toggleRule(id: string) {
- setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r))
+ async function deleteRule(r: FirewallRule) {
+ if (isLive) {
+ const key = ruleKey(r)
+ if (!key) return
+ try {
+ await apiFetch("/api/firewall/rules", { method: "DELETE", body: JSON.stringify(key) })
+ toast.success("Правило удалено")
+ await loadLive()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Не удалось удалить правило")
+ }
+ return
+ }
+ setMockRules((rs) => rs.filter((x) => x.id !== r.id))
+ }
+
+ const canReorder = useMemo(() => {
+ if (filteredRules.length < 2) return false
+ const servers = new Set(filteredRules.map((r) => r.serverId).filter(Boolean))
+ return servers.size === 1
+ }, [filteredRules])
+
+ async function reorderRules(activeId: string, overId: string) {
+ const source = isLive ? liveRules : mockRules
+ const next = reorderWithinGroup(source, activeId, overId)
+ if (!next) {
+ toast.info("Правила можно переставлять только в пределах одного сервера и таблицы")
+ return
+ }
+ if (!isLive) {
+ setMockRules(next)
+ return
+ }
+ const active = source.find((r) => r.id === activeId)
+ const key = active ? ruleKey(active) : null
+ if (!key) {
+ toast.error("У правила нет идентификатора RouterOS")
+ return
+ }
+ const dest = rosMoveDestination(source, activeId, overId)
+ if (!dest) return
+ const prev = source
+ setLiveRules(next)
+ try {
+ await apiFetch("/api/firewall/rules/move", {
+ method: "POST",
+ body: JSON.stringify({
+ ...key,
+ ...(dest.dest ? { destinationRosId: dest.dest } : {}),
+ }),
+ })
+ await loadLive()
+ } catch (e) {
+ setLiveRules(prev)
+ toast.error(e instanceof Error ? e.message : "Не удалось переместить правило")
+ }
}
function openAdd() {
+ if (!requireWriteServer()) return
+ const table = tableOfGroup(chainGroup)
+ if (!table) {
+ setChainGroup("filter")
+ }
setEditingRule(null)
setRuleSheetOpen(true)
}
@@ -1668,50 +2177,268 @@ export default function FirewallPage() {
setRuleSheetOpen(true)
}
- // ─────────────────────────────────────────────────────────────────────────
+ async function saveRule(payload: RuleWritePayload) {
+ const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable
+ const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily
+ const serverId = editingRule?.serverId ?? requireWriteServer()
+ if (!serverId) return
+ const serverName = editingRule?.serverName
+ ?? displayServers.find((s) => s.id === serverId)?.name
+ ?? writeServer?.name
+ ?? serverId
+
+ if (isLive) {
+ setBusy(true)
+ try {
+ if (editingRule?.rosId) {
+ await apiFetch("/api/firewall/rules", {
+ method: "PUT",
+ body: JSON.stringify({ serverId, family, table, rosId: editingRule.rosId, ...payload }),
+ })
+ toast.success("Правило сохранено")
+ } else {
+ await apiFetch("/api/firewall/rules", {
+ method: "POST",
+ body: JSON.stringify({ serverId, family, table, ...payload }),
+ })
+ toast.success("Правило создано")
+ }
+ setRuleSheetOpen(false)
+ await loadLive()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Не удалось сохранить правило")
+ } finally {
+ setBusy(false)
+ }
+ return
+ }
+
+ if (editingRule?.id) {
+ setMockRules((rs) => rs.map((x) => x.id === editingRule.id
+ ? { ...x, ...payloadToMockRule(payload, {
+ id: x.id,
+ table,
+ family,
+ serverId,
+ serverName,
+ rosId: x.rosId ?? "*0",
+ }) }
+ : x))
+ } else {
+ const id = `fw-${Date.now()}`
+ setMockRules((rs) => [...rs, payloadToMockRule(payload, {
+ id,
+ table,
+ family,
+ serverId,
+ serverName,
+ rosId: `*${Date.now()}`,
+ })])
+ }
+ setRuleSheetOpen(false)
+ }
+
+ function openAddAddr(list?: string) {
+ if (!requireWriteServer()) return
+ setEditingAddr(list ? { list } : null)
+ setAddrSheetOpen(true)
+ }
+
+ function openEditAddr(e: AddressListEntry) {
+ setEditingAddr(e)
+ setAddrSheetOpen(true)
+ }
+
+ async function toggleAddr(e: AddressListEntry) {
+ if (isLive) {
+ const key = addressKey(e)
+ if (!key) return
+ try {
+ await apiFetch("/api/firewall/address-lists", {
+ method: "PATCH",
+ body: JSON.stringify({ ...key, disabled: !e.disabled }),
+ })
+ await loadLive()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Не удалось переключить запись")
+ }
+ return
+ }
+ setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x))
+ }
+
+ async function deleteAddr(e: AddressListEntry) {
+ if (isLive) {
+ const key = addressKey(e)
+ if (!key) return
+ try {
+ await apiFetch("/api/firewall/address-lists", { method: "DELETE", body: JSON.stringify(key) })
+ toast.success("Запись удалена")
+ await loadLive()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Не удалось удалить запись")
+ }
+ return
+ }
+ setMockAddrLists((rows) => rows.filter((x) => x.id !== e.id))
+ }
+
+ async function saveAddr(payload: {
+ list: string
+ address: string
+ comment?: string
+ timeout?: string
+ disabled?: boolean
+ }) {
+ const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily
+ const serverId = editingAddr?.serverId ?? requireWriteServer()
+ if (!serverId) return
+ const serverName = editingAddr?.serverName
+ ?? displayServers.find((s) => s.id === serverId)?.name
+ ?? writeServer?.name
+ ?? serverId
+
+ if (isLive) {
+ setBusy(true)
+ try {
+ if (editingAddr?.rosId) {
+ await apiFetch("/api/firewall/address-lists", {
+ method: "PUT",
+ body: JSON.stringify({ serverId, family, rosId: editingAddr.rosId, ...payload }),
+ })
+ toast.success("Запись сохранена")
+ } else {
+ await apiFetch("/api/firewall/address-lists", {
+ method: "POST",
+ body: JSON.stringify({ serverId, family, ...payload }),
+ })
+ toast.success("Запись создана")
+ }
+ setAddrSheetOpen(false)
+ await loadLive()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Не удалось сохранить запись")
+ } finally {
+ setBusy(false)
+ }
+ return
+ }
+
+ if (editingAddr?.id) {
+ setMockAddrLists((rows) => rows.map((x) => x.id === editingAddr.id
+ ? { ...x, ...payload, family, serverId, serverName }
+ : x))
+ } else {
+ setMockAddrLists((rows) => [...rows, {
+ id: `al-${Date.now()}`,
+ list: payload.list,
+ address: payload.address,
+ comment: payload.comment ?? "",
+ timeout: payload.timeout,
+ disabled: payload.disabled ?? false,
+ family,
+ serverId,
+ serverName,
+ rosId: `*${Date.now()}`,
+ }])
+ }
+ setAddrSheetOpen(false)
+ }
+
+ const sheetChainGroup: ChainGroup =
+ chainGroup === "address-lists" || chainGroup === "simulator" ? "filter" : chainGroup
return (
-
-
- setExportOpen(true)}>
- Экспорт .rsc
-
-
- Новое правило
-
- >
- }
- />
-
-
+ <>
+
+
+ { void loadLive() }}
+ disabled={!isLive || dataLoading}
+ title={!isLive ? "Включите Live в источнике данных" : "Обновить правила с устройств"}
+ >
+
+ Обновить
+
+ setExportOpen(true)}>
+ Экспорт .rsc
+
+
+ Новое правило
+
+ >
+ }
+ />
+ }
+ >
+ {dataError && isLive && (
+
+ {dataError}
+
+ )}
- {/* Stats */}
-
- {[
- { label: "Всего правил", value: rules.length, icon: },
- { label: "Активных", value: totalEnabled, icon: },
- { label: "Блокирующих", value: dropRules, icon: },
- { label: "Срабатываний", value: fmtHits(totalHits), icon: },
- ].map((s) => (
-
-
-
-
- {s.label}
- {s.value}
-
-
-
- ))}
+
+ { setIpFamily(v); setChainFilter("all") }}
+ options={(["ip", "ip6"] as IpFamily[]).map((f) => ({
+ value: f,
+ label: IP_FAMILY_LABELS[f],
+ }))}
+ />
+
+ {ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"}
+
- {/* Table group tabs */}
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "enabled",
+ label: "Активных",
+ value: totalEnabled,
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "drop",
+ label: "Блокирующих",
+ value: dropRules,
+ icon: ,
+ iconClassName: "text-destructive",
+ },
+ {
+ id: "hits",
+ label: "Срабатываний",
+ value: fmtHits(totalHits),
+ icon: ,
+ iconClassName: "text-info",
+ },
+ ]}
+ />
+
{CHAIN_GROUPS.map((g) => (
- {rules.filter((r) => g.chains.includes(r.chain)).length}
+ {familyRules.filter((r) => r.table === g.id).length}
)}
{g.id === "address-lists" && (
- {addrLists.length}
+ {familyAddrLists.length}
)}
))}
{chainGroup === "address-lists" ? (
- {}} />
+ openAddAddr()}
+ onAddToList={(list) => openAddAddr(list)}
+ onToggle={(e) => { void toggleAddr(e) }}
+ onEdit={openEditAddr}
+ onDelete={(e) => { void deleteAddr(e) }}
+ showServer={showServerCol}
+ />
) : chainGroup === "simulator" ? (
-
+
) : (
- ({
- value: f,
- label: IP_FAMILY_LABELS[f],
- }))}
- />
-
+ { void toggleRule(id) }}
+ onEdit={openEdit}
+ onDelete={(r) => { void deleteRule(r) }}
+ onReorder={canReorder ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
+ showServer={showServerCol}
+ />
)}
- {/* RouterOS reference */}
{[
@@ -1792,7 +2525,7 @@ export default function FirewallPage() {
},
{
title: "/ipv6 firewall filter",
- lines: ["ip6-input", "ip6-forward", "ip6-output"],
+ lines: ["input / forward / output", "без префикса ip6-", "семейство — отдельный сегмент"],
},
{
title: "Новые матчеры 7.x",
@@ -1827,22 +2560,40 @@ export default function FirewallPage() {
-
+
- {/* Sheet: Add/Edit Rule */}
setRuleSheetOpen(false)}
initialRule={editingRule}
- chainGroup={chainGroup === "address-lists" ? "filter" : chainGroup}
+ chainGroup={sheetChainGroup}
+ family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
+ onSave={(payload) => { void saveRule(payload) }}
+ saving={busy}
+ />
+
+ setAddrSheetOpen(false)}
+ initial={editingAddr}
+ family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
+ onSave={(payload) => { void saveAddr(payload) }}
+ saving={busy}
/>
- {/* Sheet: Export */}
setExportOpen(false)}
- rules={rules}
+ rules={familyRules}
/>
-
+ >
+ )
+}
+
+export default function FirewallPage() {
+ return (
+
+
+
)
}
diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx
index f7bc9a3..d706bbe 100644
--- a/app/(main)/gre/page.tsx
+++ b/app/(main)/gre/page.tsx
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Frame, FramePanel } from "@/components/reui/frame"
-import { IconTile } from "@/components/reui/icon-tile"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -26,7 +26,6 @@ import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
-import { Flag } from "@/components/flag"
import {
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
@@ -35,6 +34,8 @@ import {
DatabaseIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── label maps ─────────────────────────────────────────────────────────────
@@ -241,6 +242,7 @@ export default function GrePage() {
const [pageTab, setPageTab] = useState("tunnels")
const [tabFilter, setTabFilter] = useState("all")
const [search, setSearch] = useState("")
+ const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false)
@@ -293,6 +295,25 @@ export default function GrePage() {
[isLive, displayTunnels],
)
+ const greRailItems = useMemo(() => (
+ displayServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status,
+ type: s.type,
+ enabled: s.enabled,
+ meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
+ }))
+ ), [displayServers, displayTunnels])
+
+ const scopedTunnels = useMemo(() => {
+ if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
+ return displayTunnels.filter((t) => t.serverId === selectedServerId)
+ }, [displayTunnels, selectedServerId])
+
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
@@ -341,7 +362,7 @@ export default function GrePage() {
}, [dataError])
const filtered = useMemo(() => {
- return displayTunnels.filter((t) => {
+ return scopedTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false
@@ -354,16 +375,18 @@ export default function GrePage() {
serverById[t.serverId]?.name.toLowerCase().includes(q)
)
})
- }, [tabFilter, search, displayTunnels, serverById])
+ }, [tabFilter, search, scopedTunnels, serverById])
const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
+ const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
+ const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
- { value: "all", label: "Все", count: displayTunnels.length },
- { value: "up", label: "Активные", count: upCount },
- { value: "ipsec", label: "С IPsec", count: ipsecCount },
- { value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
+ { value: "all", label: "Все", count: scopedTunnels.length },
+ { value: "up", label: "Активные", count: scopedUpCount },
+ { value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
+ { value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
]
const greExportCode = useMemo(
@@ -372,39 +395,48 @@ export default function GrePage() {
)
return (
-
-
- { void loadLive() }}
- disabled={!isLive || dataLoading}
- title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
- >
-
- Обновить
-
- { void syncJhToDb() }}
- disabled={!isLive || syncJhBusy || dataLoading}
- title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
- >
-
- JH → БД
-
- { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
- Добавить туннель
-
- >
- }
- />
-
-
+ <>
+
+
+ { void loadLive() }}
+ disabled={!isLive || dataLoading}
+ title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
+ >
+
+ Обновить
+
+ { void syncJhToDb() }}
+ disabled={!isLive || syncJhBusy || dataLoading}
+ title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
+ >
+
+ JH → БД
+
+ { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
+ Добавить туннель
+
+ >
+ }
+ />
+ }
+ >
{/* Legacy banner */}
@@ -421,27 +453,39 @@ export default function GrePage() {
- {/* Stats */}
-
- {[
- { label: "Всего туннелей", value: displayTunnels.length, icon: },
- { label: "Активно", value: upCount, icon: },
- { label: "Защищены IPsec", value: ipsecCount, icon: },
- { label: "IP-пулов", value: displayPools.length, icon: },
- ].map((s) => (
-
-
-
-
- {s.label}
- {s.value}
-
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "up",
+ label: "Активно",
+ value: upCount,
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "ipsec",
+ label: "Защищены IPsec",
+ value: ipsecCount,
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "pools",
+ label: "IP-пулов",
+ value: displayPools.length,
+ icon: ,
+ iconClassName: "text-primary",
+ },
+ ]}
+ />
{/* Page tabs */}
@@ -545,7 +589,7 @@ export default function GrePage() {
-
+
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
-
+ >
)
}
diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx
index a06f5f5..3ce15c2 100644
--- a/app/(main)/ospf/page.tsx
+++ b/app/(main)/ospf/page.tsx
@@ -7,17 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
import {
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
+ LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
-import { Flag } from "@/components/flag"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -707,7 +710,7 @@ function InterfacesTab({
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
- const canOptimizeLive = isLive && filterServerId !== "all"
+ const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record = {}
const byRouter: Record = {}
@@ -921,20 +924,33 @@ function NeighborsTab({
return (
-
- {[
- { label: "Всего соседей", value: neighbors.length, color: "" },
- { label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
- { label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
- ].map(s => (
-
-
- {s.label}
- {s.value}
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "full",
+ label: "Full",
+ value: fullCount,
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "not-full",
+ label: "Не Full",
+ value: neighbors.length - fullCount,
+ icon: ,
+ iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
+ variant: neighbors.length - fullCount > 0 ? "warning" : "default",
+ },
+ ]}
+ />
{graphNodes.length > 0 && (
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
return (
-
- {[
- { label: "Сессий BFD", value: sessions.length, color: "" },
- { label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
- { label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
- { label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
- ].map(s => (
-
-
- {s.label}
- {s.value}
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "up",
+ label: "Up",
+ value: upCount,
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "down",
+ label: "Down / Admin",
+ value: downCount,
+ icon: ,
+ iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
+ variant: downCount > 0 ? "destructive" : "default",
+ },
+ {
+ id: "init",
+ label: "Init / другие",
+ value: initCount,
+ icon: ,
+ iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
+ variant: initCount > 0 ? "warning" : "default",
+ },
+ ]}
+ />
{sessions.length === 0 && (
@@ -1096,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
export default function OspfPage() {
const [activeTab, setActiveTab] = useState("interfaces")
- const [filterServerId, setFilterServerId] = useState("all")
+ const [filterServerId, setFilterServerId] = useState(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
@@ -1218,9 +1254,26 @@ export default function OspfPage() {
}, [items, neighbors, bfdSessions])
// ── filtered display data ─────────────────────────────────────────────────────
- const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
- const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
- const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
+ const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
+ const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
+ const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
+
+ const ospfRailItems = useMemo(() => (
+ ospfServers.map((s) => {
+ const counts = serverCounts[s.id]
+ const host = s.label.replace(/^mt-/, "")
+ return {
+ id: s.id,
+ name: host,
+ host,
+ site: s.site,
+ country: s.country || undefined,
+ meta: counts
+ ? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
+ : undefined,
+ }
+ })
+ ), [ospfServers, serverCounts])
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
@@ -1229,91 +1282,45 @@ export default function OspfPage() {
const totalAreas = new Set(displayItems.map(i => i.area)).size
return (
-
- setFetchTick(t => t + 1)}>
-
- Обновить
-
- }
- />
-
-
-
- {TABS.map(t => (
- setActiveTab(t.id)}
- className={cn(
- "flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
- activeTab === t.id
- ? "border-primary text-foreground"
- : "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
- )}>
- {t.icon}{t.label}
-
- ))}
-
-
-
- {/* ── server filter chips (same pattern as Filters page) ─────────────── */}
- {ospfServers.length > 0 && (
-
- {/* "All" chip */}
- setFilterServerId("all")}
- className={cn(
- "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
- filterServerId === "all"
- ? "bg-foreground text-background border-foreground"
- : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
- )}>
- Все серверы
- {items.length}
-
-
-
-
- {ospfServers.map(s => {
- const counts = serverCounts[s.id]
- const active = filterServerId === s.id
- return (
- setFilterServerId(s.id)}
+
+
+ setFetchTick(t => t + 1)}>
+
+ Обновить
+
+ >
+ }
+ />
+ }
+ banner={
+
+
+ {TABS.map(t => (
+ setActiveTab(t.id)}
className={cn(
- "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
- active
- ? "bg-foreground text-background border-foreground"
- : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
+ "flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
+ activeTab === t.id
+ ? "border-primary text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
- {s.country ? : null}
- {s.site && (
- {s.site}
- )}
- {s.label.replace(/^mt-/, "")}
- {counts && (
-
- {counts.neighbors}n · {counts.ifaces}i
- {counts.bfd > 0 && ` · ${counts.bfd}b`}
-
- )}
+ {t.icon}{t.label}
- )
- })}
+ ))}
+
- )}
-
-
+ }
+ >
{/* data source banner */}
@@ -1346,21 +1353,32 @@ export default function OspfPage() {
)}
- {/* KPI strip */}
-
- {[
- { label: "Роутеров", value: totalRouters },
- { label: "Интерфейсов", value: totalInterfaces },
- { label: "Зон (Area)", value: totalAreas },
- ].map(s => (
-
-
- {s.label}
- {s.value}
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "ifaces",
+ label: "Интерфейсов",
+ value: totalInterfaces,
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "areas",
+ label: "Зон (Area)",
+ value: totalAreas,
+ icon: ,
+ iconClassName: "text-primary",
+ },
+ ]}
+ />
{activeTab === "interfaces" && (
}
-
-
+
)
}
diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx
index d80f7e8..bbdbf70 100644
--- a/app/(main)/probes/page.tsx
+++ b/app/(main)/probes/page.tsx
@@ -17,6 +17,8 @@ import {
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
@@ -480,19 +482,25 @@ function ScheduleTab({
setRules,
serverOptions,
tunnelsForServer,
+ defaultSrc,
}: {
rules: SchedRule[]
setRules: React.Dispatch>
serverOptions: Server[]
tunnelsForServer: (serverId: string) => GreTunnel[]
+ defaultSrc?: string
}) {
const [showAdd, setShowAdd] = useState(false)
- const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
+ const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("")
const [addType, setAddType] = useState("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
+ useEffect(() => {
+ if (defaultSrc) setAddSrc(defaultSrc)
+ }, [defaultSrc])
+
useEffect(() => {
const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) {
@@ -630,6 +638,20 @@ export default function ProbesPage() {
return liveServers
}, [isLive, liveServers])
+ const probeRailItems = useMemo(() => (
+ allServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status,
+ type: s.type,
+ enabled: s.enabled,
+ selectable: s.enabled,
+ }))
+ ), [allServers])
+
useEffect(() => {
if (!isLive) {
setRosSrcV4(undefined)
@@ -887,24 +909,33 @@ export default function ProbesPage() {
}
return (
-
- 0
- ? {
- liveProbeRunRef.current?.ctrl.abort()
- setTests(p => p.map(t => (t.status === "running"
- ? { ...t, status: "done" as const, totalLines: t.lines.length }
- : t)))
- }}>
- Остановить все
-
- : undefined
- }
- />
-
-
+
+
+ {running.length > 0
+ ? {
+ liveProbeRunRef.current?.ctrl.abort()
+ setTests(p => p.map(t => (t.status === "running"
+ ? { ...t, status: "done" as const, totalLines: t.lines.length }
+ : t)))
+ }}>
+ Остановить все
+
+ : null}
+ >
+ }
+ />
+ }
+ >
{isLive && liveLoad === "error" && (
@@ -916,7 +947,7 @@ export default function ProbesPage() {
{isLive && (
- Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
+ Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
)}
@@ -945,16 +976,6 @@ export default function ProbesPage() {
{/* main config row */}
- {/* source server */}
-
- Источник
-
- {allServers.filter(s => s.enabled).map(s => (
-
- ))}
-
-
-
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
setRules={setRules}
serverOptions={allServers}
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
+ defaultSrc={srcId}
/>
)
)}
-
-
+
)
}
diff --git a/app/(main)/recursive-routes/page.tsx b/app/(main)/recursive-routes/page.tsx
index c6c98c9..8550cd5 100644
--- a/app/(main)/recursive-routes/page.tsx
+++ b/app/(main)/recursive-routes/page.tsx
@@ -20,6 +20,8 @@ import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer {
id: number
@@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() {
}
const currentServer = servers.find(s => s.id === selectedServerId)
+ const rrRailItems = useMemo(() => (
+ servers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status,
+ type: s.type,
+ enabled: s.enabled,
+ }))
+ ), [servers])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
@@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() {
}, [filteredRows])
return (
-
-
-
- {busy === "from" ? "Синхронизация..." : "Router => DB"}
-
-
- {busy === "to" ? "Применение..." : "DB => Router"}
-
-
- Сохранить в БД
-
-
- Добавить
-
- >
- }
- />
-
-
-
- Всего маршрутов
- {rows.length}
-
-
- {servers.map((s) => {
- const count = s.id === selectedServerId ? rows.length : 0
- const active = selectedServerId === s.id
- return (
- setSelectedServerId(s.id)}
- className={cn(
- "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
- active
- ? "bg-foreground text-background border-foreground"
- : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
- !s.enabled && !active && "opacity-40",
- )}>
-
-
- {s.name}
-
- 0 ? "text-foreground" : "opacity-40",
- )}>{count}
-
- )
- })}
-
-
-
-
-
- setSearch(e.target.value)} />
- {search && (
- setSearch("")}
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
-
-
+ <>
+
+
+
+ {busy === "from" ? "Синхронизация..." : "Router => DB"}
+
+
+ {busy === "to" ? "Применение..." : "DB => Router"}
+
+
+ Сохранить в БД
+
+
+ Добавить
+
+ >
+ }
+ />
+ }
+ banner={
+
+
+
+ setSearch(e.target.value)} />
+ {search && (
+ setSearch("")}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
+
+
+ )}
+
+
+ Всего маршрутов
+ {rows.length}
+
+
+ {filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
+
+ {opError && (
+
+ {opError}
+
)}
-
- {filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
-
- {opError && (
-
- {opError}
-
- )}
-
-
-
+ }
+ >
{!isLive ? (
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
)}
-
+
setSheetOpen(false)}
gateways={gatewayOptions}
/>
-
+ >
)
}
diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx
index 4b80d10..c0fc51c 100644
--- a/app/(main)/route-optimizer/page.tsx
+++ b/app/(main)/route-optimizer/page.tsx
@@ -10,7 +10,7 @@ import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-op
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
-import { IconTile } from "@/components/reui/icon-tile"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
label: "Home роутеров",
value: homeCount,
sub: `${wanCount} WAN-аплинков`,
- icon: ,
+ icon: ,
},
{
label: "JumpHost",
value: jh.length,
sub: jhSub,
- icon: ,
+ icon: ,
},
{
label: "Exit Node",
value: ex.length,
sub: exSub,
- icon: ,
+ icon: ,
},
{
label: "Переключений",
value: totalSwitches,
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
- icon: (
- 0 ? "text-amber-500" : "text-muted-foreground",
- )}
- />
- ),
+ icon: ,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
)}
- {/* Stats chips */}
-
- {statsChips.map((s) => (
-
-
-
-
- {s.label}
- {s.value}
- {s.sub}
-
-
-
- ))}
-
+ ({
+ id: `ro-${i}`,
+ label: s.label,
+ value: s.value,
+ hint: s.sub,
+ icon: s.icon,
+ iconClassName: s.label === "Переключений" && totalSwitches > 0
+ ? "text-warning"
+ : s.label === "Exit Node"
+ ? "text-success"
+ : s.label === "JumpHost"
+ ? "text-primary"
+ : "text-muted-foreground",
+ variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
+ }))}
+ />
{error && (
diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx
index 40f8fe0..c1bfe75 100644
--- a/app/(main)/servers/page.tsx
+++ b/app/(main)/servers/page.tsx
@@ -27,8 +27,7 @@ import {
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
-import { Frame, FramePanel } from "@/components/reui/frame"
-import { IconTile } from "@/components/reui/icon-tile"
+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"
@@ -491,27 +490,39 @@ export default function ServersPage() {
- {/* Stats */}
-
- {[
- { label: "Всего серверов", value: counts.all, icon: , iconClass: "text-muted-foreground" },
- { label: "Онлайн", value: counts.online, icon: , iconClass: "text-[var(--status-online-fg)]" },
- { label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: , iconClass: "text-muted-foreground" },
- { label: "Home Router", value: counts["home-router"],icon: , iconClass: "text-muted-foreground" },
- ].map(s => (
-
-
-
-
- {s.label}
- {s.value}
-
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "online",
+ label: "Онлайн",
+ value: counts.online,
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "jh-en",
+ label: "JH + Exit Node",
+ value: counts["jump-host"] + counts["exit-node"],
+ icon: ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "home",
+ label: "Home Router",
+ value: counts["home-router"],
+ icon: ,
+ iconClassName: "text-muted-foreground",
+ },
+ ]}
+ />
{/* Table */}
diff --git a/app/(main)/terminal/page.tsx b/app/(main)/terminal/page.tsx
index ef0a43b..b350b23 100644
--- a/app/(main)/terminal/page.tsx
+++ b/app/(main)/terminal/page.tsx
@@ -16,18 +16,12 @@ import {
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- ServerTileRail,
type ServerTileItem,
} from "@/components/server-tile-rail"
import {
- TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon,
+ TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
import { cn } from "@/lib/utils"
@@ -456,7 +450,6 @@ export default function TerminalPage() {
const [liveServers, setLiveServers] = useState([])
const [serversLoading, setServersLoading] = useState(false)
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
- const [railOpen, setRailOpen] = useState(false)
// Load servers from backend when in live mode
useEffect(() => {
@@ -516,6 +509,7 @@ export default function TerminalPage() {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
+ host: s.host,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
@@ -524,31 +518,15 @@ export default function TerminalPage() {
}))
}, [termServers])
- function handleSelectServer(id: string) {
+ const handleSelectServer = useCallback((id: string) => {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
- setRailOpen(false)
- }
+ }, [])
- const railHeaderRight = isLive
- ? serversLoading
- ?
- : LIVE
+ const railHeaderRight = isLive && !serversLoading
+ ? LIVE
: undefined
- const rail = (
-
- )
-
function QuickCmds() {
return (
@@ -595,42 +573,39 @@ export default function TerminalPage() {
}
return (
-
-
- setRailOpen(true)}
- >
-
- {selected?.name ?? "Сервер"}
-
- {
- setRefreshKey((k) => k + 1)
- if (isLive) setSelectedUid("")
- }}
- >
-
- Переподключить
-
- >
- }
- />
-
-
-
-
-
+ }
+ contentClassName="overflow-hidden p-3 md:p-4"
+ header={
+
+
+ {
+ setRefreshKey((k) => k + 1)
+ if (isLive) setSelectedUid("")
+ }}
+ >
+
+ Переподключить
+
+ >
+ }
+ />
+ }
+ >
{selected ? (
)}
-
-
-
-
-
-
- Серверы
-
-
-
-
-
-
-
-
+
)
}
diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx
index 91b8f78..55a7767 100644
--- a/app/(main)/traffic/page.tsx
+++ b/app/(main)/traffic/page.tsx
@@ -3,7 +3,8 @@
import { useState, useMemo, useEffect, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame"
-import { IconTile } from "@/components/reui/icon-tile"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
import { Button } from "@/components/ui/button"
import { Sparkline } from "@/components/sparkline"
import { StatusDot } from "@/components/status-dot"
@@ -13,7 +14,9 @@ import {
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
+import { fmtGB, fmtRate } from "@/lib/fmt-rate"
import { useDataSource } from "@/lib/data-source"
+import { useTrafficLive } from "@/hooks/use-traffic-live"
import { requestJson } from "@/shared/api/http-client"
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -32,16 +35,6 @@ function addSeries(a: number[], b: number[]): number[] {
return a.map((v, i) => v + (b[i] ?? 0))
}
-function fmtMbps(v: number) {
- if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
- return `${v} Мбит/с`
-}
-
-function fmtGB(v: number) {
- if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
- return `${v.toFixed(1)} ГБ`
-}
-
// ─── data model ───────────────────────────────────────────────────────────────
interface GreClientTraffic {
@@ -318,63 +311,10 @@ function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; he
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
return (
- )
-}
-
-function BigChart({ rx, tx }: { rx: number[]; tx: number[] }) {
- const W = 900, H = 220
- const pad = { l: 56, r: 16, t: 14, b: 32 }
- const iw = W - pad.l - pad.r
- const ih = H - pad.t - pad.b
- const maxVal = Math.max(...rx, ...tx, 1) * 1.15
- const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
- const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
- const area = (arr: number[]) => {
- const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
- return `M ${pad.l},${pad.t + ih} L ${pts} L ${pad.l + iw},${pad.t + ih} Z`
- }
- const poly = (arr: number[]) => arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
- const gridVals = [0, 0.25, 0.5, 0.75, 1]
- return (
-
)
}
@@ -401,11 +341,11 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
- {fmtMbps(s.rxNow)}
+ {fmtRate(s.rxNow)}
- {fmtMbps(s.txNow)}
+ {fmtRate(s.txNow)}
{s.greClients.length > 0 && (
@@ -445,11 +385,11 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
- {fmtMbps(u.rxNow)}
+ {fmtRate(u.rxNow)}
- {fmtMbps(u.txNow)}
+ {fmtRate(u.txNow)}
>
@@ -483,11 +423,11 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
- {fmtMbps(c.rxNow)}
+ {fmtRate(c.rxNow)}
- {fmtMbps(c.txNow)}
+ {fmtRate(c.txNow)}
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
{/* live RX / TX */}
- {fmtMbps(c.rxNow)}
+ {fmtRate(c.rxNow)}
- {fmtMbps(c.txNow)}
+ {fmtRate(c.txNow)}
@@ -581,19 +521,6 @@ function DetailHeader({ range, setRange, children }: {
)
}
-function ChartLegend() {
- return (
-
-
- RX (входящий)
-
-
- TX (исходящий)
-
-
- )
-}
-
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
return (
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
return (
- Получено сегодня
+ Получено за период
{rxTotal.toFixed(1)} ГБ
-
+
- Отправлено сегодня
+ Отправлено за период
{txTotal.toFixed(1)} ГБ
-
+
)
@@ -630,7 +557,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
// ─── detail panels ────────────────────────────────────────────────────────────
-function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) {
+function ServerDetail({
+ sel, range, setRange, liveRx, liveTx, liveHint,
+}: {
+ sel: ServerTraffic
+ range: Range
+ setRange: (r: Range) => void
+ liveRx?: number
+ liveTx?: number
+ liveHint?: string
+}) {
+ const rxNow = liveRx ?? sel.rxNow
+ const txNow = liveTx ?? sel.txNow
return (
<>
@@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran
{sel.site}
-
- {sel.status === "offline" ? : }
-
- } label="RX сейчас" value={fmtMbps(sel.rxNow)} />
- } label="TX сейчас" value={fmtMbps(sel.txNow)} />
- } label="Пик RX" value={fmtMbps(sel.rxPeak)} />
- } label="Сессий" value={`${sel.sessions}`} />
+ {sel.status === "offline" ? : }
+
+ ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "tx-now",
+ label: "TX сейчас",
+ value: fmtRate(txNow),
+ hint: liveHint,
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "rx-peak",
+ label: "Пик RX",
+ value: fmtRate(sel.rxPeak),
+ icon: ,
+ iconClassName: "text-warning",
+ },
+ {
+ id: "tx-peak",
+ label: "Пик TX",
+ value: fmtRate(sel.txPeak),
+ icon: ,
+ iconClassName: "text-warning",
+ },
+ ]}
+ />
{sel.greClients.length > 0 && (
@@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
) : (
<>
-
-
+
- } label="RX сейчас" value={fmtMbps(sel.rxNow)} />
- } label="TX сейчас" value={fmtMbps(sel.txNow)} />
- } label="Пик RX" value={fmtMbps(sel.rxPeak)} />
+ } label="RX сейчас" value={fmtRate(sel.rxNow)} />
+ } label="TX сейчас" value={fmtRate(sel.txNow)} />
+ } label="Пик RX" value={fmtRate(sel.rxPeak)} />
} label="GRE-клиентов" value={`${sel.greClients.length}`} />
@@ -716,13 +683,12 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
{sel.serverSite}
-
- {sel.status === "offline" ? : }
+ {sel.status === "offline" ? : }
- } label="RX сейчас" value={fmtMbps(sel.rxNow)} />
- } label="TX сейчас" value={fmtMbps(sel.txNow)} />
- } label="Пик RX" value={fmtMbps(sel.rxPeak)} />
- } label="Пик TX" value={fmtMbps(sel.txPeak)} />
+ } label="RX сейчас" value={fmtRate(sel.rxNow)} />
+ } label="TX сейчас" value={fmtRate(sel.txNow)} />
+ } label="Пик RX" value={fmtRate(sel.rxPeak)} />
+ } label="Пик TX" value={fmtRate(sel.txPeak)} />
@@ -778,6 +744,12 @@ export default function TrafficPage() {
const [detailBusy, setDetailBusy] = useState(false)
const [liveDetailServer, setLiveDetailServer] = useState(null)
const effectiveMode: GroupMode = isLive ? "servers" : groupMode
+ const { sample: liveSample, error: liveStreamError } = useTrafficLive({
+ enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
+ backendUrl,
+ serverId: selectedId,
+ iface: selectedIface,
+ })
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
return {
@@ -917,6 +889,7 @@ export default function TrafficPage() {
}, [sortField, sortDir, q])
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
+ const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId])
@@ -965,27 +938,39 @@ export default function TrafficPage() {
- {/* ── summary stat cards ── */}
-
- {[
- { icon: , label: "RX сейчас", value: fmtMbps(totalRx) },
- { icon: , label: "TX сейчас", value: fmtMbps(totalTx) },
- { icon: , label: "Пик RX", value: fmtMbps(peakRx) },
- { icon: , label: "Пик TX", value: fmtMbps(peakTx) },
- ].map(({ icon, label, value }) => (
-
-
-
-
- {label}
- {value}
-
-
-
- ))}
-
+ ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "tx",
+ label: "TX сейчас",
+ value: fmtRate(totalTx),
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "peak-rx",
+ label: "Пик RX",
+ value: fmtRate(peakRx),
+ icon: ,
+ iconClassName: "text-warning",
+ },
+ {
+ id: "peak-tx",
+ label: "Пик TX",
+ value: fmtRate(peakTx),
+ icon: ,
+ iconClassName: "text-warning",
+ },
+ ]}
+ />
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
)}
- {effectiveMode === "servers" && (liveDetailServer ?? selServer) && }
+ {effectiveMode === "servers" && detailServer && (
+
+ )}
{effectiveMode === "users" && }
{effectiveMode === "gre" && }
diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx
index e91fc83..0b95e00 100644
--- a/app/(main)/uptime/page.tsx
+++ b/app/(main)/uptime/page.tsx
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
-import { IconTile } from "@/components/reui/icon-tile"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
)}
- {/* ── KPI summary ───────────────────────────────────────────────────── */}
-
- {[
- { icon: , label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
- { icon: , label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
- { icon: , label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
- { icon: , label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
- { icon: , label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
- { icon: , label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
- ].map(kpi => (
-
-
-
-
- {kpi.label}
- {kpi.sub}
-
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "cpu",
+ label: "Средний CPU",
+ value: `${avgCpu}%`,
+ icon: ,
+ iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
+ variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
+ },
+ {
+ id: "ram",
+ label: "Средний RAM",
+ value: `${avgRam}%`,
+ icon: ,
+ iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success",
+ variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default",
+ },
+ {
+ id: "high-cpu",
+ label: "CPU > 85%",
+ value: highCpu,
+ icon: ,
+ iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
+ variant: highCpu > 0 ? "destructive" : "default",
+ },
+ {
+ id: "high-ram",
+ label: "RAM > 85%",
+ value: highRam,
+ icon: ,
+ iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
+ variant: highRam > 0 ? "destructive" : "default",
+ },
+ {
+ id: "high-hdd",
+ label: "Диск > 85%",
+ value: highHdd,
+ icon: ,
+ iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
+ variant: highHdd > 0 ? "warning" : "default",
+ },
+ ]}
+ />
{/* ── Table ─────────────────────────────────────────────────────────── */}
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
return (
-
- {[
- { label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
- { label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
- { label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
- { label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
- ].map(k => (
-
-
- {k.label}
-
- {k.value}
- {k.unit && {k.unit}}
-
- {runningCnt > 0 && k.label === "Тестов выполнено" && (
-
- {runningCnt} выполняется
-
- )}
-
-
- ))}
+
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "runs",
+ label: "Тестов выполнено",
+ value: `${doneRuns.length} run`,
+ hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
+ icon: ,
+ iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
+ variant: runningCnt > 0 ? "warning" : "default",
+ },
+ {
+ id: "max-tx",
+ label: "Макс TX",
+ value: maxTx != null ? `${maxTx} Мбит/с` : "—",
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "max-rx",
+ label: "Макс RX",
+ value: maxRx != null ? `${maxRx} Мбит/с` : "—",
+ icon: ,
+ iconClassName: "text-success",
+ },
+ ]}
+ />
)
})()}
diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx
index 4842448..95e5d27 100644
--- a/app/(main)/vxlan/page.tsx
+++ b/app/(main)/vxlan/page.tsx
@@ -4,14 +4,12 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
-import { Frame, FramePanel } from "@/components/reui/frame"
-import { IconTile } from "@/components/reui/icon-tile"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button"
-import { cn } from "@/lib/utils"
import {
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
} from "lucide-react"
@@ -119,27 +117,39 @@ export default function VxlanPage() {
- {/* KPI */}
-
- {[
- { label: "Туннелей", value: vxlanTunnels.length, icon: },
- { label: "Активных", value: upCount, icon: },
- { label: "Уникальных VNI", value: vnis, icon: },
- { label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: },
- ].map((s) => (
-
-
-
-
- {s.label}
- {s.value}
-
-
-
- ))}
-
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "up",
+ label: "Активных",
+ value: upCount,
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "vni",
+ label: "Уникальных VNI",
+ value: vnis,
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "servers",
+ label: "Серверов",
+ value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
+ icon: ,
+ iconClassName: "text-primary",
+ },
+ ]}
+ />
{/* Info banner */}
diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx
index 74dd8dd..344ead2 100644
--- a/app/(main)/wireguard/page.tsx
+++ b/app/(main)/wireguard/page.tsx
@@ -23,12 +23,6 @@ import {
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
@@ -57,16 +51,16 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
ALL_SERVERS_ID,
- ServerTileRail,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { toast } from "sonner"
import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
- ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon,
+ Trash2Icon, CodeXmlIcon, AlertCircleIcon,
} from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
@@ -186,7 +180,6 @@ export default function WireGuardPage() {
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState("interfaces")
const [statusFilter, setStatusFilter] = useState("all")
- const [railOpen, setRailOpen] = useState(false)
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
@@ -338,6 +331,8 @@ export default function WireGuardPage() {
return displayServers.map((s) => ({
id: s.id,
name: s.name,
+ host: s.host,
+ site: s.site,
country: s.country,
status: s.status,
type: s.type,
@@ -347,16 +342,6 @@ export default function WireGuardPage() {
}))
}, [displayServers, displayIfaces])
- const selectedLabel =
- effectiveServerId === ALL_SERVERS_ID
- ? "Все серверы"
- : (displayServers.find((s) => s.id === effectiveServerId)?.name ?? "Сервер")
-
- function handleSelectServer(id: string) {
- setSelectedServerId(id)
- setRailOpen(false)
- }
-
async function handleCreate(form: WgCreateFormState) {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
@@ -543,59 +528,45 @@ export default function WireGuardPage() {
)
- const rail = (
-
+
- )
-
- return (
-
-
- setRailOpen(true)}
- >
-
- {selectedLabel}
-
- {isLive && (
- void loadLive()}
- >
-
- Обновить
+ loading={isLive && loading && displayServers.length === 0}
+ header={
+
+
+ {isLive && (
+ void loadLive()}
+ >
+
+ Обновить
+
+ )}
+ setImportOpen(true)}>
+
+ Импорт
- )}
- setImportOpen(true)}>
-
- Импорт
-
- setCreateOpen(true)}>
-
- Новый интерфейс
-
- >
- }
- />
-
-
-
-
-
+ setCreateOpen(true)}>
+
+ Новый интерфейс
+
+ >
+ }
+ />
+ }
+ >
-
-
-
-
-
-
- Серверы
-
-
-
-
-
-
+
-
+ >
)
}
diff --git a/backend/package.json b/backend/package.json
index 6572c0a..8849a00 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -12,7 +12,8 @@
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
- "test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
+ "test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
+ "test:traffic-rate": "tsx src/services/traffic-rate.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 35684da..b6e87e3 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -24,6 +24,7 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
+import firewallRoutes from "./routes/firewall.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
export async function buildApp(opts?: {
@@ -105,6 +106,7 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
+ await app.register(firewallRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) {
refreshScheduler()
diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts
index 9715ffd..05e53e0 100644
--- a/backend/src/lib/permissions.test.ts
+++ b/backend/src/lib/permissions.test.ts
@@ -17,6 +17,10 @@ assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
+assert.equal(
+ permissionForRequest("GET", "/api/traffic/servers/1/live"),
+ "mm:traffic:read",
+)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
@@ -29,5 +33,13 @@ assert.equal(
permissionForRequest("POST", "/api/wireguard/interfaces"),
"mm:network:write",
)
+assert.equal(
+ permissionForRequest("GET", "/api/firewall/all"),
+ "mm:network:read",
+)
+assert.equal(
+ permissionForRequest("POST", "/api/firewall/rules"),
+ "mm:network:write",
+)
console.log("permissions.test.ts: ok")
diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts
index bb53778..b1b47db 100644
--- a/backend/src/lib/permissions.ts
+++ b/backend/src/lib/permissions.ts
@@ -142,7 +142,8 @@ const RULES: Rule[] = [
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
- p.startsWith("/api/wireguard"),
+ p.startsWith("/api/wireguard") ||
+ p.startsWith("/api/firewall"),
permission: "mm:network:read",
},
{
@@ -154,7 +155,8 @@ const RULES: Rule[] = [
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
- p.startsWith("/api/wireguard"),
+ p.startsWith("/api/wireguard") ||
+ p.startsWith("/api/firewall"),
permission: "mm:network:write",
},
]
diff --git a/backend/src/routes/firewall.ts b/backend/src/routes/firewall.ts
new file mode 100644
index 0000000..7a49ba7
--- /dev/null
+++ b/backend/src/routes/firewall.ts
@@ -0,0 +1,294 @@
+import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
+import { z } from "zod"
+import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
+import { getEnabledServerById } from "../services/wireguard-live.js"
+import { listFirewallAll } from "../services/firewall-live.js"
+import type { FirewallFamily, FirewallTable } from "../types/server.js"
+
+const FamilySchema = z.enum(["ip", "ip6"])
+const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
+
+const RuleKeySchema = z.object({
+ serverId: z.string().min(1),
+ family: FamilySchema,
+ table: TableSchema,
+ rosId: z.string().min(1),
+})
+
+const RuleWriteSchema = z.object({
+ serverId: z.string().min(1),
+ family: FamilySchema,
+ table: TableSchema,
+ rosId: z.string().min(1).optional(),
+ chain: z.string().min(1),
+ action: z.string().min(1),
+ protocol: z.string().optional(),
+ srcAddress: z.string().optional(),
+ dstAddress: z.string().optional(),
+ srcAddressList: z.string().optional(),
+ dstAddressList: z.string().optional(),
+ srcPort: z.string().optional(),
+ dstPort: z.string().optional(),
+ inInterface: z.string().optional(),
+ outInterface: z.string().optional(),
+ connectionState: z.string().optional(),
+ comment: z.string().optional(),
+ disabled: z.boolean().optional(),
+ log: z.boolean().optional(),
+ logPrefix: z.string().optional(),
+ tlsHost: z.string().optional(),
+ layer7Proto: z.string().optional(),
+})
+
+const RulePatchSchema = RuleKeySchema.extend({
+ disabled: z.boolean(),
+})
+
+const RuleMoveSchema = RuleKeySchema.extend({
+ destinationRosId: z.string().min(1).optional(),
+})
+
+const AddressKeySchema = z.object({
+ serverId: z.string().min(1),
+ family: FamilySchema,
+ rosId: z.string().min(1),
+})
+
+const AddressWriteSchema = z.object({
+ serverId: z.string().min(1),
+ family: FamilySchema,
+ rosId: z.string().min(1).optional(),
+ list: z.string().min(1),
+ address: z.string().min(1),
+ comment: z.string().optional(),
+ timeout: z.string().optional(),
+ disabled: z.boolean().optional(),
+})
+
+const AddressPatchSchema = AddressKeySchema.extend({
+ disabled: z.boolean(),
+})
+
+function toRosBody(obj: Record): Record {
+ const out: Record = {}
+ for (const [k, v] of Object.entries(obj)) {
+ if (v !== undefined && v !== "") out[k] = v
+ }
+ return out
+}
+
+function ruleToRos(d: z.infer): Record {
+ return toRosBody({
+ chain: d.chain,
+ action: d.action,
+ protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
+ "src-address": d.srcAddress,
+ "dst-address": d.dstAddress,
+ "src-address-list": d.srcAddressList,
+ "dst-address-list": d.dstAddressList,
+ "src-port": d.srcPort,
+ "dst-port": d.dstPort,
+ "in-interface": d.inInterface,
+ "out-interface": d.outInterface,
+ "connection-state": d.connectionState,
+ comment: d.comment,
+ disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
+ log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
+ "log-prefix": d.logPrefix,
+ "tls-host": d.tlsHost,
+ "layer7-protocol": d.layer7Proto,
+ })
+}
+
+function addressToRos(d: z.infer): Record {
+ return toRosBody({
+ list: d.list,
+ address: d.address,
+ comment: d.comment,
+ timeout: d.timeout,
+ disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
+ })
+}
+
+function rosErr(e: unknown): string {
+ if (e instanceof MikrotikError) return e.message
+ if (e instanceof Error) return e.message
+ return String(e)
+}
+
+function requireServer(serverId: string) {
+ return getEnabledServerById(serverId)
+}
+
+const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
+ app.get("/firewall/all", async (_req, reply) => {
+ const data = await listFirewallAll()
+ return reply.send(data)
+ })
+
+ app.post("/firewall/rules", async (req, reply) => {
+ const parsed = RuleWriteSchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
+ try {
+ await client.put(path, ruleToRos(body))
+ return reply.status(201).send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.put("/firewall/rules", async (req, reply) => {
+ const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
+ try {
+ await client.patch(path, ruleToRos(body))
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.patch("/firewall/rules", async (req, reply) => {
+ const parsed = RulePatchSchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
+ try {
+ await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.delete("/firewall/rules", async (req, reply) => {
+ const parsed = RuleKeySchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
+ try {
+ await client.delete(path)
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.post("/firewall/rules/move", async (req, reply) => {
+ const parsed = RuleMoveSchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family, body.table)}/move`
+ try {
+ await client.post(path, {
+ numbers: body.rosId,
+ ...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
+ })
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.post("/firewall/address-lists", async (req, reply) => {
+ const parsed = AddressWriteSchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ try {
+ await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
+ return reply.status(201).send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.put("/firewall/address-lists", async (req, reply) => {
+ const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
+ try {
+ await client.patch(path, addressToRos(body))
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.patch("/firewall/address-lists", async (req, reply) => {
+ const parsed = AddressPatchSchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
+ try {
+ await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+
+ app.delete("/firewall/address-lists", async (req, reply) => {
+ const parsed = AddressKeySchema.safeParse(req.body ?? {})
+ if (!parsed.success) {
+ return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
+ }
+ const body = parsed.data
+ const server = requireServer(body.serverId)
+ if (!server) return reply.status(404).send({ error: "Сервер не найден" })
+ const client = MikrotikClient.fromServer(server)
+ const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
+ try {
+ await client.delete(path)
+ return reply.send({ ok: true })
+ } catch (e) {
+ return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
+ }
+ })
+}
+
+export default firewallRoutes
diff --git a/backend/src/routes/traffic.ts b/backend/src/routes/traffic.ts
index d524cc3..03902f7 100644
--- a/backend/src/routes/traffic.ts
+++ b/backend/src/routes/traffic.ts
@@ -1,3 +1,4 @@
+import { env } from "../config.js"
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
@@ -12,6 +13,15 @@ import {
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js"
import { appendEvent } from "../modules/events/service/events-service.js"
+import { MikrotikClient } from "../services/mikrotik.js"
+import { getEnabledServerById } from "../services/wireguard-live.js"
+import {
+ bpsToMbps,
+ buildTrafficFromSamples,
+ isLoopbackName,
+ parseMonitorTraffic,
+ rateBpsFromDelta,
+} from "../services/traffic-rate.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -40,6 +50,9 @@ interface TrafficInterfaceDto {
txNow: number
}
+const LIVE_TICK_MS = 1500
+const LIVE_ROS_TIMEOUT_MS = 4000
+
function latestSnapshot(serverId: number): SnapshotRow | undefined {
return db
.select()
@@ -61,14 +74,6 @@ function rangeToMinutes(range: string | undefined): number {
}
}
-function toSeries(values: number[], target = 60): number[] {
- if (values.length === 0) return Array(target).fill(0)
- if (values.length === target) return values
- if (values.length > target) return values.slice(values.length - target)
- const head = Array(target - values.length).fill(values[0] ?? 0)
- return [...head, ...values]
-}
-
function buildServerTraffic(
s: typeof servers.$inferSelect,
status: TrafficServerDto["status"],
@@ -82,88 +87,135 @@ function buildServerTraffic(
running: boolean
disabled: boolean
}>,
+ rangeStartMs: number,
+ rangeEndMs: number,
onlyInterface?: string,
): TrafficServerDto {
- const filteredRows = onlyInterface
- ? rows.filter((r) => r.interfaceName === onlyInterface)
- : rows
-
- if (filteredRows.length === 0) {
- return {
- id: String(s.id),
- name: s.name || s.host,
- site: s.site || "—",
- country: s.country || "UN",
- status,
- rxNow: 0,
- txNow: 0,
- rxPeak: 0,
- txPeak: 0,
- rxTotal: 0,
- txTotal: 0,
- sessions: 0,
- rxSeries: Array(60).fill(0),
- txSeries: Array(60).fill(0),
- }
- }
-
- const bySampleTs = new Map()
- const byIface = new Map()
- for (const r of filteredRows) {
- const ts = r.sampledAt
- const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
- cur.rx += Math.max(0, r.rxBps) / 1_000_000
- cur.tx += Math.max(0, r.txBps) / 1_000_000
- bySampleTs.set(ts, cur)
- const arr = byIface.get(r.interfaceName) ?? []
- arr.push(r)
- byIface.set(r.interfaceName, arr)
- }
-
- const seriesPoints = [...bySampleTs.entries()]
- .sort((a, b) => a[0].localeCompare(b[0]))
- .map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
- const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
- const txSeries = toSeries(seriesPoints.map((p) => p.tx))
- const rxNow = rxSeries[rxSeries.length - 1] ?? 0
- const txNow = txSeries[txSeries.length - 1] ?? 0
- const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
- const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
-
- let rxBytesDelta = 0
- let txBytesDelta = 0
- let sessions = 0
- for (const arr of byIface.values()) {
- const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
- const first = sorted[0]
- const last = sorted[sorted.length - 1]
- if (first && last) {
- const dRx = last.rxBytes - first.rxBytes
- const dTx = last.txBytes - first.txBytes
- rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
- txBytesDelta += dTx >= 0 ? dTx : last.txBytes
- if (last.running && !last.disabled) sessions += 1
- }
- }
-
+ const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
- rxNow,
- txNow,
- rxPeak,
- txPeak,
- rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
- txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
- sessions,
- rxSeries,
- txSeries,
+ rxNow: built.rxNow,
+ txNow: built.txNow,
+ rxPeak: built.rxPeak,
+ txPeak: built.txPeak,
+ rxTotal: built.rxTotalGiB,
+ txTotal: built.txTotalGiB,
+ sessions: built.sessions,
+ rxSeries: built.rxSeries,
+ txSeries: built.txSeries,
}
}
+function snapshotStatus(serverId: number): TrafficServerDto["status"] {
+ const snap = latestSnapshot(serverId)
+ return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
+}
+
+function ifaceNowMbps(
+ prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
+ last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
+): { rxNow: number; txNow: number } {
+ if (!prev) {
+ return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
+ }
+ const t0 = Date.parse(prev.sampledAt)
+ const t1 = Date.parse(last.sampledAt)
+ const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
+ const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
+ return {
+ rxNow: bpsToMbps(rxBps ?? last.rxBps),
+ txNow: bpsToMbps(txBps ?? last.txBps),
+ }
+}
+
+function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
+ raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
+}
+
+function sleep(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new Error("aborted"))
+ return
+ }
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort)
+ resolve()
+ }, ms)
+ const onAbort = () => {
+ clearTimeout(timer)
+ reject(new Error("aborted"))
+ }
+ signal.addEventListener("abort", onAbort, { once: true })
+ })
+}
+
+function flattenMonitor(raw: unknown): unknown[] {
+ if (Array.isArray(raw)) return raw
+ if (raw != null) return [raw]
+ return []
+}
+
+async function listRunningIfaceNames(client: MikrotikClient): Promise {
+ const ifaces = await client.get>(
+ "/interface",
+ LIVE_ROS_TIMEOUT_MS,
+ )
+ return ifaces
+ .filter((i) => (i.running ?? "false") === "true"
+ && (i.disabled ?? "false") !== "true"
+ && !isLoopbackName(i.name ?? ""))
+ .map((i) => i.name ?? "")
+ .filter(Boolean)
+}
+
+async function monitorTrafficOnce(
+ client: MikrotikClient,
+ onlyInterface: string | undefined,
+ cache: { names: string[]; joinedFailed: boolean },
+ signal: AbortSignal,
+): Promise {
+ if (onlyInterface) {
+ return client.post(
+ "/interface/monitor-traffic",
+ { interface: onlyInterface, once: "" },
+ LIVE_ROS_TIMEOUT_MS,
+ signal,
+ )
+ }
+ if (cache.names.length === 0) {
+ cache.names = await listRunningIfaceNames(client)
+ }
+ if (cache.names.length === 0) return []
+ if (!cache.joinedFailed) {
+ try {
+ return await client.post(
+ "/interface/monitor-traffic",
+ { interface: cache.names.join(","), once: "" },
+ LIVE_ROS_TIMEOUT_MS,
+ signal,
+ )
+ } catch {
+ cache.joinedFailed = true
+ }
+ }
+ const chunks = await Promise.all(
+ cache.names.map((name) =>
+ client.post(
+ "/interface/monitor-traffic",
+ { interface: name, once: "" },
+ LIVE_ROS_TIMEOUT_MS,
+ signal,
+ ).then(flattenMonitor).catch(() => [] as unknown[]),
+ ),
+ )
+ return chunks.flat()
+}
+
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => {
const settings = getTrafficSettings()
@@ -243,20 +295,77 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/servers", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
- const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
+ const rangeEndMs = Date.now()
+ const rangeStartMs = rangeEndMs - minutes * 60_000
+ const sinceIso = new Date(rangeStartMs).toISOString()
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const data = allServers.map((s): TrafficServerDto => {
- const snap = latestSnapshot(s.id)
- const status: TrafficServerDto["status"] =
- snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
-
const rows = readServerSamplesInRange(s.id, sinceIso)
- return buildServerTraffic(s, status, rows)
+ return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
})
return reply.send({ servers: data })
})
+ app.get("/traffic/servers/:id/live", async (req, reply) => {
+ const p = req.params as { id?: string | number }
+ const q = req.query as { iface?: string }
+ const server = getEnabledServerById(p.id ?? "")
+ if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
+
+ const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
+ const abort = new AbortController()
+ const onClose = () => abort.abort()
+ req.raw.on("close", onClose)
+
+ reply.hijack()
+ req.raw.setTimeout(0)
+ reply.raw.setTimeout(0)
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
+ const allowed = env.CORS_ORIGIN
+ const sseHeaders: Record = {
+ "Content-Type": "text/event-stream; charset=utf-8",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ if (origin && (allowed === "*" || allowed === origin)) {
+ sseHeaders["Access-Control-Allow-Origin"] = origin
+ sseHeaders["Access-Control-Allow-Credentials"] = "true"
+ sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
+ sseHeaders.Vary = "Origin"
+ }
+ reply.raw.writeHead(200, sseHeaders)
+ reply.raw.write(":\n\n")
+
+ const client = MikrotikClient.fromServer(server)
+ const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
+
+ try {
+ while (!abort.signal.aborted) {
+ try {
+ const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
+ const sample = parseMonitorTraffic(raw, { onlyInterface })
+ writeSse(reply.raw, "sample", sample)
+ } catch (error) {
+ if (abort.signal.aborted) break
+ const msg = error instanceof Error ? error.message : String(error)
+ writeSse(reply.raw, "error", { error: msg })
+ }
+ await sleep(LIVE_TICK_MS, abort.signal)
+ }
+ } catch {
+ /* abort / disconnect */
+ } finally {
+ req.raw.off("close", onClose)
+ try {
+ reply.raw.end()
+ } catch {
+ /* already closed */
+ }
+ }
+ })
+
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
const p = req.params as { id?: string | number }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
@@ -270,6 +379,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const rows = readServerSamplesInRange(serverId, sinceIso)
const byIface = new Map()
for (const r of rows) {
+ if (isLoopbackName(r.interfaceName)) continue
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
@@ -277,12 +387,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
+ const prev = sorted[sorted.length - 2]
+ if (!last) {
+ return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
+ }
+ const now = ifaceNowMbps(prev, last)
return {
name,
- running: Boolean(last?.running),
- disabled: Boolean(last?.disabled),
- rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
- txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
+ running: Boolean(last.running),
+ disabled: Boolean(last.disabled),
+ rxNow: now.rxNow,
+ txNow: now.txNow,
}
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
@@ -298,15 +413,20 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Server not found" })
const minutes = rangeToMinutes(q.range)
- const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
+ const rangeEndMs = Date.now()
+ const rangeStartMs = rangeEndMs - minutes * 60_000
+ const sinceIso = new Date(rangeStartMs).toISOString()
const rows = readServerSamplesInRange(server.id, sinceIso)
- const snap = latestSnapshot(server.id)
- const status: TrafficServerDto["status"] =
- snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
- const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
+ const data = buildServerTraffic(
+ server,
+ snapshotStatus(server.id),
+ rows,
+ rangeStartMs,
+ rangeEndMs,
+ q.iface && q.iface !== "__all__" ? q.iface : undefined,
+ )
return reply.send({ server: data })
})
}
export default trafficRoutes
-
diff --git a/backend/src/services/firewall-live.ts b/backend/src/services/firewall-live.ts
new file mode 100644
index 0000000..55c0da4
--- /dev/null
+++ b/backend/src/services/firewall-live.ts
@@ -0,0 +1,198 @@
+import { eq } from "drizzle-orm"
+import { db } from "../db/index.js"
+import { servers } from "../db/schema.js"
+import {
+ MikrotikClient,
+ firewallRestPath,
+} from "./mikrotik.js"
+import type {
+ FirewallFamily,
+ FirewallTable,
+ RosFirewallAddressList,
+ RosFirewallFilter,
+} from "../types/server.js"
+
+type ServerRow = typeof servers.$inferSelect
+
+export interface FirewallRuleDto {
+ id: string
+ rosId: string
+ serverId: string
+ serverName: string
+ family: FirewallFamily
+ table: FirewallTable
+ chain: string
+ action: string
+ proto: string
+ src: string
+ dst: string
+ port: string
+ iface: string
+ comment: string
+ enabled: boolean
+ hits: number
+ log: boolean
+ logPrefix: string
+ tlsHost?: string
+ layer7Proto?: string
+}
+
+export interface FirewallAddressListDto {
+ id: string
+ rosId: string
+ serverId: string
+ serverName: string
+ family: FirewallFamily
+ list: string
+ address: string
+ comment: string
+ disabled: boolean
+ timeout?: string
+}
+
+const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
+const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
+
+function dash(v: string | undefined): string {
+ const s = v?.trim() ?? ""
+ return s.length > 0 ? s : "—"
+}
+
+function rosDisabled(v: string | undefined): boolean {
+ return v === "true" || v === "yes"
+}
+
+function parseHits(raw: RosFirewallFilter): number {
+ const n = Number.parseInt(raw.packets ?? "0", 10)
+ return Number.isFinite(n) ? n : 0
+}
+
+export function ruleUiId(
+ serverId: string | number,
+ family: FirewallFamily,
+ table: FirewallTable,
+ rosId: string,
+): string {
+ return `${serverId}:${family}:${table}:${rosId}`
+}
+
+export function addressUiId(
+ serverId: string | number,
+ family: FirewallFamily,
+ rosId: string,
+): string {
+ return `${serverId}:${family}:address-list:${rosId}`
+}
+
+export function mapFirewallRule(
+ server: ServerRow,
+ family: FirewallFamily,
+ table: FirewallTable,
+ raw: RosFirewallFilter,
+ idx: number,
+): FirewallRuleDto {
+ const rosId = raw[".id"] || `*${idx}`
+ const src = raw["src-address"] || raw["src-address-list"]
+ const dst = raw["dst-address"] || raw["dst-address-list"]
+ const port = raw["dst-port"] || raw["src-port"]
+ const iface = raw["in-interface"] || raw["out-interface"]
+ return {
+ id: ruleUiId(server.id, family, table, rosId),
+ rosId,
+ serverId: String(server.id),
+ serverName: server.name || server.host,
+ family,
+ table,
+ chain: raw.chain || "",
+ action: raw.action || "",
+ proto: raw.protocol || "all",
+ src: dash(src),
+ dst: dash(dst),
+ port: dash(port),
+ iface: dash(iface),
+ comment: raw.comment ?? "",
+ enabled: !rosDisabled(raw.disabled),
+ hits: parseHits(raw),
+ log: raw.log === "true" || raw.log === "yes",
+ logPrefix: raw["log-prefix"] ?? "",
+ tlsHost: raw["tls-host"],
+ layer7Proto: raw["layer7-protocol"],
+ }
+}
+
+export function mapAddressList(
+ server: ServerRow,
+ family: FirewallFamily,
+ raw: RosFirewallAddressList,
+ idx: number,
+): FirewallAddressListDto {
+ const rosId = raw[".id"] || `*${idx}`
+ return {
+ id: addressUiId(server.id, family, rosId),
+ rosId,
+ serverId: String(server.id),
+ serverName: server.name || server.host,
+ family,
+ list: raw.list || "",
+ address: raw.address || "",
+ comment: raw.comment ?? "",
+ disabled: rosDisabled(raw.disabled),
+ timeout: raw.timeout,
+ }
+}
+
+async function safeGet(fn: () => Promise, fallback: T[] = []): Promise {
+ try {
+ const rows = await fn()
+ return Array.isArray(rows) ? rows : fallback
+ } catch {
+ return fallback
+ }
+}
+
+export async function fetchServerFirewall(server: ServerRow): Promise<{
+ rules: FirewallRuleDto[]
+ addressLists: FirewallAddressListDto[]
+}> {
+ const client = MikrotikClient.fromServer(server)
+ const ruleJobs = FAMILIES.flatMap((family) =>
+ TABLES.map(async (table) => {
+ const raw = await safeGet(() => client.getFirewallRules(family, table))
+ return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
+ }),
+ )
+ const listJobs = FAMILIES.map(async (family) => {
+ const raw = await safeGet(() => client.getFirewallAddressList(family))
+ return raw.map((row, idx) => mapAddressList(server, family, row, idx))
+ })
+ const [ruleChunks, listChunks] = await Promise.all([
+ Promise.all(ruleJobs),
+ Promise.all(listJobs),
+ ])
+ return {
+ rules: ruleChunks.flat(),
+ addressLists: listChunks.flat(),
+ }
+}
+
+export async function listFirewallAll(): Promise<{
+ rules: FirewallRuleDto[]
+ addressLists: FirewallAddressListDto[]
+}> {
+ const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
+ const perServer = await Promise.all(
+ allServers.map(async (server) => {
+ try {
+ return await fetchServerFirewall(server)
+ } catch {
+ return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
+ }
+ }),
+ )
+ return {
+ rules: perServer.flatMap((r) => r.rules),
+ addressLists: perServer.flatMap((r) => r.addressLists),
+ }
+}
+
+export { firewallRestPath, FAMILIES, TABLES }
diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts
index 7696797..7607879 100644
--- a/backend/src/services/mikrotik.ts
+++ b/backend/src/services/mikrotik.ts
@@ -7,7 +7,8 @@ import type {
RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
- RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
+ RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
+ FirewallFamily, FirewallTable,
} from "../types/server.js"
// ── connection params ─────────────────────────────────────────────────────────
@@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName.endsWith(`/${base}`)
}
+export function firewallRestPath(
+ family: FirewallFamily,
+ table: FirewallTable | "address-list",
+): string {
+ const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
+ return `${root}/${table}`
+}
+
+export function encodeRosId(rosId: string): string {
+ const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
+ return encodeURIComponent(id)
+}
+
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
@@ -461,7 +475,15 @@ export class MikrotikClient {
}
async getFirewallFilters(): Promise {
- return this.get("/ip/firewall/filter")
+ return this.getFirewallRules("ip", "filter")
+ }
+
+ async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise {
+ return this.get(firewallRestPath(family, table))
+ }
+
+ async getFirewallAddressList(family: FirewallFamily): Promise {
+ return this.get(firewallRestPath(family, "address-list"))
}
async getLogs(limit = 50): Promise {
diff --git a/backend/src/services/traffic-collector.ts b/backend/src/services/traffic-collector.ts
index 8f39fc1..89cbb5d 100644
--- a/backend/src/services/traffic-collector.ts
+++ b/backend/src/services/traffic-collector.ts
@@ -1,9 +1,10 @@
-import { and, asc, eq, gte, lt } from "drizzle-orm"
+import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js"
+import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
interface RosIfaceTraffic {
name?: string
@@ -50,6 +51,31 @@ function cleanupOldSamples(retentionDays: number) {
.run()
}
+function readPreviousWave(serverId: number): Map {
+ const last = db
+ .select({ sampledAt: trafficSamples.sampledAt })
+ .from(trafficSamples)
+ .where(eq(trafficSamples.serverId, serverId))
+ .orderBy(desc(trafficSamples.sampledAt))
+ .limit(1)
+ .all()[0]
+ if (!last) return new Map()
+ const rows = db
+ .select({
+ interfaceName: trafficSamples.interfaceName,
+ rxBytes: trafficSamples.rxBytes,
+ txBytes: trafficSamples.txBytes,
+ sampledAt: trafficSamples.sampledAt,
+ })
+ .from(trafficSamples)
+ .where(and(
+ eq(trafficSamples.serverId, serverId),
+ eq(trafficSamples.sampledAt, last.sampledAt),
+ ))
+ .all()
+ return new Map(rows.map((r) => [r.interfaceName, r]))
+}
+
export async function collectTrafficOnce(): Promise {
const sampledAt = new Date().toISOString()
if (collecting) {
@@ -80,26 +106,42 @@ export async function collectTrafficOnce(): Promise {
try {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get("/interface")
- let sumRx = 0
- let sumTx = 0
- for (const i of ifaces) {
- sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
- sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
- }
- if (ifaces.length > 0) {
- db.insert(trafficSamples).values(
- ifaces.map((i) => ({
- serverId: srv.id,
- interfaceName: i.name ?? "unknown",
- sampledAt: now,
- rxBytes: toNum(i["rx-byte"]),
- txBytes: toNum(i["tx-byte"]),
- rxBps: toNum(i["rx-bits-per-second"]),
- txBps: toNum(i["tx-bits-per-second"]),
- running: (i.running ?? "false") === "true",
- disabled: (i.disabled ?? "false") === "true",
- })),
- ).run()
+ const prevWave = readPreviousWave(srv.id)
+ const nowMs = Date.parse(now)
+ let sumRxMbps = 0
+ let sumTxMbps = 0
+ const rows = ifaces.map((i) => {
+ const interfaceName = i.name ?? "unknown"
+ const rxBytes = toNum(i["rx-byte"])
+ const txBytes = toNum(i["tx-byte"])
+ const running = (i.running ?? "false") === "true"
+ const disabled = (i.disabled ?? "false") === "true"
+ const prev = prevWave.get(interfaceName)
+ const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
+ const rxBps = prev && Number.isFinite(prevMs)
+ ? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
+ : 0
+ const txBps = prev && Number.isFinite(prevMs)
+ ? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
+ : 0
+ if (shouldIncludeIface(interfaceName, running, disabled)) {
+ sumRxMbps += bpsToMbps(rxBps)
+ sumTxMbps += bpsToMbps(txBps)
+ }
+ return {
+ serverId: srv.id,
+ interfaceName,
+ sampledAt: now,
+ rxBytes,
+ txBytes,
+ rxBps,
+ txBps,
+ running,
+ disabled,
+ }
+ })
+ if (rows.length > 0) {
+ db.insert(trafficSamples).values(rows).run()
}
snapshot.servers.push({
serverId: srv.id,
@@ -107,8 +149,8 @@ export async function collectTrafficOnce(): Promise {
host: srv.host,
ok: true,
interfaces: ifaces.length,
- sumRxMbps: Math.round(sumRx),
- sumTxMbps: Math.round(sumTx),
+ sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
+ sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
})
} catch (err) {
snapshot.servers.push({
diff --git a/backend/src/services/traffic-rate.test.ts b/backend/src/services/traffic-rate.test.ts
new file mode 100644
index 0000000..bb77768
--- /dev/null
+++ b/backend/src/services/traffic-rate.test.ts
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict"
+import {
+ bpsToMbps,
+ bucketAvg,
+ buildTrafficFromSamples,
+ isLoopbackName,
+ parseMonitorTraffic,
+ rateBpsFromDelta,
+ shouldIncludeIface,
+ type TrafficSampleLike,
+} from "./traffic-rate.js"
+
+assert.equal(isLoopbackName("lo"), true)
+assert.equal(isLoopbackName("loopback"), true)
+assert.equal(isLoopbackName("ether1"), false)
+assert.equal(shouldIncludeIface("lo", true, false), false)
+assert.equal(shouldIncludeIface("ether1", true, false), true)
+assert.equal(shouldIncludeIface("ether1", false, false), false)
+assert.equal(shouldIncludeIface("ether1", true, true), false)
+assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
+
+assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
+assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
+assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
+assert.equal(bpsToMbps(1_500_000), 1.5)
+assert.equal(bpsToMbps(400_000), 0.4)
+
+const buckets = bucketAvg(
+ [
+ { t: 0, v: 10 },
+ { t: 1000, v: 20 },
+ ],
+ 0,
+ 1000,
+ 4,
+)
+assert.equal(buckets.length, 4)
+assert.equal(buckets[0], 10)
+assert.equal(buckets[3], 20)
+assert.equal(buckets[1], 0)
+assert.equal(buckets[2], 0)
+
+const t0 = "2026-09-06T10:00:00.000Z"
+const t1 = "2026-09-06T10:00:30.000Z"
+const t2 = "2026-09-06T10:01:00.000Z"
+const start = Date.parse(t0)
+const end = Date.parse(t2)
+
+const samples: TrafficSampleLike[] = [
+ { interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
+ { interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
+ { interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
+ { interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
+ { interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
+]
+
+const built = buildTrafficFromSamples(samples, start, end)
+assert.ok(built.rxPeak > 0, "peak RX from delta")
+assert.equal(built.rxNow, 1, "last valid delta after reset skip")
+assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
+assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
+
+const live = parseMonitorTraffic([
+ { name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
+ { name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
+])
+assert.equal(live.rxMbps, 2)
+assert.equal(live.txMbps, 0.5)
+
+const onceOnly = parseMonitorTraffic(
+ { name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
+ { onlyInterface: "ether1" },
+)
+assert.equal(onceOnly.rxMbps, 1)
+
+console.log("traffic-rate tests ok")
diff --git a/backend/src/services/traffic-rate.ts b/backend/src/services/traffic-rate.ts
new file mode 100644
index 0000000..7e65c67
--- /dev/null
+++ b/backend/src/services/traffic-rate.ts
@@ -0,0 +1,221 @@
+/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
+
+export const SERIES_POINTS = 60
+
+export interface TrafficSampleLike {
+ interfaceName: string
+ sampledAt: string
+ rxBytes: number
+ txBytes: number
+ rxBps: number
+ txBps: number
+ running: boolean
+ disabled: boolean
+}
+
+export interface RatePoint {
+ t: number
+ rxMbps: number
+ txMbps: number
+}
+
+export interface BuiltTrafficSeries {
+ rxNow: number
+ txNow: number
+ rxPeak: number
+ txPeak: number
+ rxTotalGiB: number
+ txTotalGiB: number
+ sessions: number
+ rxSeries: number[]
+ txSeries: number[]
+}
+
+export interface MonitorLiveSample {
+ rxMbps: number
+ txMbps: number
+ at: string
+}
+
+export function isLoopbackName(name: string): boolean {
+ return /^(lo|loopback)(\d+)?$/i.test(name.trim())
+}
+
+export function shouldIncludeIface(
+ name: string,
+ running: boolean,
+ disabled: boolean,
+ onlyInterface?: string,
+): boolean {
+ if (onlyInterface) return name === onlyInterface
+ if (isLoopbackName(name)) return false
+ return running && !disabled
+}
+
+export function bpsToMbps(bps: number): number {
+ if (!Number.isFinite(bps) || bps <= 0) return 0
+ return Math.round((bps / 1_000_000) * 1000) / 1000
+}
+
+/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
+export function rateBpsFromDelta(
+ prevBytes: number,
+ nextBytes: number,
+ prevAtMs: number,
+ nextAtMs: number,
+): number | null {
+ const dtSec = (nextAtMs - prevAtMs) / 1000
+ if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
+ if (nextBytes < prevBytes) return null
+ return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
+}
+
+export function bucketAvg(
+ points: Array<{ t: number; v: number }>,
+ rangeStartMs: number,
+ rangeEndMs: number,
+ target = SERIES_POINTS,
+): number[] {
+ const buckets = Array.from({ length: target }, () => 0)
+ const counts = Array.from({ length: target }, () => 0)
+ const span = rangeEndMs - rangeStartMs
+ if (span <= 0 || points.length === 0) return buckets
+ for (const p of points) {
+ const ratio = (p.t - rangeStartMs) / span
+ const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
+ buckets[i] += p.v
+ counts[i] += 1
+ }
+ return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
+}
+
+function parseIsoMs(iso: string): number {
+ const t = Date.parse(iso)
+ return Number.isFinite(t) ? t : 0
+}
+
+export function buildTrafficFromSamples(
+ rows: TrafficSampleLike[],
+ rangeStartMs: number,
+ rangeEndMs: number,
+ onlyInterface?: string,
+): BuiltTrafficSeries {
+ const empty: BuiltTrafficSeries = {
+ rxNow: 0,
+ txNow: 0,
+ rxPeak: 0,
+ txPeak: 0,
+ rxTotalGiB: 0,
+ txTotalGiB: 0,
+ sessions: 0,
+ rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
+ txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
+ }
+ if (rows.length === 0) return empty
+
+ const byIface = new Map()
+ for (const r of rows) {
+ const arr = byIface.get(r.interfaceName) ?? []
+ arr.push(r)
+ byIface.set(r.interfaceName, arr)
+ }
+
+ const rxPoints: Array<{ t: number; v: number }> = []
+ const txPoints: Array<{ t: number; v: number }> = []
+ const byTs = new Map()
+
+ let rxBytesDelta = 0
+ let txBytesDelta = 0
+ let sessions = 0
+
+ for (const [name, arr] of byIface) {
+ if (onlyInterface) {
+ if (name !== onlyInterface) continue
+ } else if (isLoopbackName(name)) {
+ continue
+ }
+
+ const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
+ const last = sorted[sorted.length - 1]
+ if (!last) continue
+ if (!onlyInterface && (!last.running || last.disabled)) continue
+
+ if (last.running && !last.disabled) sessions += 1
+
+ const first = sorted[0]
+ if (first) {
+ const dRx = last.rxBytes - first.rxBytes
+ const dTx = last.txBytes - first.txBytes
+ rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
+ txBytesDelta += dTx >= 0 ? dTx : last.txBytes
+ }
+
+ for (let i = 1; i < sorted.length; i++) {
+ const prev = sorted[i - 1]
+ const cur = sorted[i]
+ if (!prev || !cur) continue
+ if (!onlyInterface && (!cur.running || cur.disabled)) continue
+ const t0 = parseIsoMs(prev.sampledAt)
+ const t1 = parseIsoMs(cur.sampledAt)
+ const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
+ const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
+ if (rxBps == null && txBps == null) continue
+ const rxMbps = bpsToMbps(rxBps ?? 0)
+ const txMbps = bpsToMbps(txBps ?? 0)
+ const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
+ acc.rx += rxMbps
+ acc.tx += txMbps
+ byTs.set(t1, acc)
+ }
+ }
+
+ for (const [t, v] of byTs) {
+ rxPoints.push({ t, v: v.rx })
+ txPoints.push({ t, v: v.tx })
+ }
+
+ const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
+ const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
+ const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
+ const last = lastTs != null ? byTs.get(lastTs) : undefined
+ const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
+ const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
+
+ return {
+ rxNow: last?.rx ?? 0,
+ txNow: last?.tx ?? 0,
+ rxPeak,
+ txPeak,
+ rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
+ txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
+ sessions,
+ rxSeries,
+ txSeries,
+ }
+}
+
+export function parseMonitorTraffic(
+ raw: unknown,
+ opts?: { onlyInterface?: string },
+): MonitorLiveSample {
+ const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
+ let rxBps = 0
+ let txBps = 0
+ for (const item of items) {
+ if (!item || typeof item !== "object") continue
+ const rec = item as Record
+ const name = String(rec.name ?? rec.interface ?? "")
+ if (opts?.onlyInterface) {
+ if (name && name !== opts.onlyInterface) continue
+ } else if (isLoopbackName(name)) {
+ continue
+ }
+ rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
+ txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
+ }
+ return {
+ rxMbps: bpsToMbps(rxBps),
+ txMbps: bpsToMbps(txBps),
+ at: new Date().toISOString(),
+ }
+}
diff --git a/backend/src/types/server.ts b/backend/src/types/server.ts
index 3ea3b20..0f28eb3 100644
--- a/backend/src/types/server.ts
+++ b/backend/src/types/server.ts
@@ -345,8 +345,24 @@ export interface RosFirewallFilter {
"dynamic"?: string
"bytes"?: string
"packets"?: string
+ "log"?: string
+ "log-prefix"?: string
}
+export interface RosFirewallAddressList {
+ ".id": string
+ list: string
+ address: string
+ comment?: string
+ disabled?: string
+ timeout?: string
+ dynamic?: string
+ "creation-time"?: string
+}
+
+export type FirewallFamily = "ip" | "ip6"
+export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
+
export interface RosLogEntry {
".id": string
"time": string
diff --git a/components/data-grids/firewall-rules-data-grid.tsx b/components/data-grids/firewall-rules-data-grid.tsx
index 7a5aa8e..d6b0be6 100644
--- a/components/data-grids/firewall-rules-data-grid.tsx
+++ b/components/data-grids/firewall-rules-data-grid.tsx
@@ -23,8 +23,11 @@ import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
+ DATA_GRID_CONTAINER_CLASS,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
+import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
+import type { DragEndEvent } from "@dnd-kit/core"
import { EmptyState } from "@/components/empty-state"
import {
CopyIcon,
@@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps {
rules: FirewallRule[]
onToggle: (id: string) => void
onEdit: (rule: FirewallRule) => void
+ onDelete?: (rule: FirewallRule) => void
+ onReorder?: (activeId: string, overId: string) => void
+ showServer?: boolean
}
-function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
+function FirewallRulesDataGrid({
+ rules,
+ onToggle,
+ onEdit,
+ onDelete,
+ onReorder,
+ showServer = false,
+}: FirewallRulesDataGridProps) {
const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
+ const reorderable = Boolean(onReorder)
+ const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
const columns = useMemo[]>(
() => [
+ ...(reorderable
+ ? [{
+ id: "drag",
+ header: () => Порядок,
+ enableSorting: false,
+ cell: () => ,
+ size: 40,
+ meta: {
+ headerClassName: DATA_GRID_CELL_PAD_FIRST,
+ cellClassName: DATA_GRID_CELL_PAD_FIRST,
+ },
+ } satisfies ColumnDef]
+ : []),
{
id: "index",
accessorKey: "_index",
@@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
),
size: 48,
meta: {
- headerClassName: DATA_GRID_CELL_PAD_FIRST,
- cellClassName: DATA_GRID_CELL_PAD_FIRST,
+ headerClassName: indexPad,
+ cellClassName: indexPad,
},
},
+ ...(showServer
+ ? [{
+ id: "server",
+ accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.serverName || row.original.serverId || "—"}
+
+ ),
+ meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
+ } satisfies ColumnDef]
+ : []),
{
id: "chain",
accessorKey: "chain",
@@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
{r.enabled ? "Отключить" : "Включить"}
-
+ onDelete?.(r)}>
Удалить правило
@@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
- [onEdit, onToggle],
+ [onEdit, onToggle, onDelete, showServer, reorderable, indexPad],
)
const table = useReactTable({
data: indexedRules,
columns,
getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
+ ...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }),
+ enableSorting: !reorderable,
getRowId: (row) => row.id,
})
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (!over || active.id === over.id || !onReorder) return
+ onReorder(String(active.id), String(over.id))
+ }
+
if (rules.length === 0) {
return (
+ >
+ {reorderable ? (
+
+ r.id)}
+ handleDragEnd={handleDragEnd}
+ />
+
+ ) : undefined}
+
)
}
diff --git a/components/data-page-toolbar.tsx b/components/data-page-toolbar.tsx
index 4d76984..c36c662 100644
--- a/components/data-page-toolbar.tsx
+++ b/components/data-page-toolbar.tsx
@@ -31,6 +31,7 @@ function DataPageToolbarFrame({
}
interface DataPageToolbarProps {
+ leading?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
@@ -48,6 +49,7 @@ interface DataPageToolbarProps {
}
function DataPageToolbar({
+ leading,
search,
onSearchChange,
searchPlaceholder = "Поиск…",
@@ -61,6 +63,7 @@ function DataPageToolbar({
}: DataPageToolbarProps) {
return (
+ {leading}
{segmented && (
+
+ {label}
+
+ )
+}
+
+function CustomTooltip({
+ active,
+ payload,
+}: {
+ active?: boolean
+ payload?: {
+ dataKey: string
+ color: string
+ value: number
+ }[]
+}) {
+ if (!active || !payload?.length) return null
+ return (
+
+ {payload.map((item) => (
+
+
+ {item.dataKey === "rx" ? "RX" : "TX"}:
+
+
+ {fmtRate(item.value)}
+
+
+ ))}
+
+ )
+}
+
+function toChartData(rx: number[], tx: number[], rangeMinutes: number) {
+ const n = Math.max(rx.length, tx.length, 1)
+ const denom = Math.max(n - 1, 1)
+ return Array.from({ length: n }, (_, i) => {
+ const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
+ return {
+ time: formatRangeAgo(minutesAgo),
+ rx: rx[i] ?? 0,
+ tx: tx[i] ?? 0,
+ }
+ })
+}
+
+export function TrafficRxTxChart({
+ rx,
+ tx,
+ range = "1h",
+}: {
+ rx: number[]
+ tx: number[]
+ range?: string
+}) {
+ const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
+ const data = toChartData(rx, tx, rangeMinutes)
+ const tickEvery = Math.max(1, Math.ceil(data.length / 6))
+
+ return (
+
+
+
+
+
+
+ fmtRate(Number(v))}
+ tickMargin={8}
+ width={72}
+ />
+ } />
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/server-rail-layout.tsx b/components/server-rail-layout.tsx
new file mode 100644
index 0000000..7812d4a
--- /dev/null
+++ b/components/server-rail-layout.tsx
@@ -0,0 +1,236 @@
+"use client"
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react"
+import { PanelLeftCloseIcon, ServerIcon } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ ALL_SERVERS_ID,
+ ServerTileRail,
+ hostnameOf,
+ type ServerTileItem,
+} from "@/components/server-tile-rail"
+import { cn } from "@/lib/utils"
+
+/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */
+
+const STORAGE_KEY = "mm:server-rail-collapsed"
+const DESKTOP_MQ = "(min-width: 768px)"
+
+function readCollapsed(): boolean {
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) === "1"
+ } catch {
+ return false
+ }
+}
+
+function writeCollapsed(value: boolean) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0")
+ } catch {
+ // quota / private mode
+ }
+}
+
+interface ServerRailContextValue {
+ openRail: () => void
+ expandRail: () => void
+ collapsed: boolean
+ selectedLabel: string
+}
+
+const ServerRailContext = createContext(null)
+
+function useServerRail() {
+ const ctx = useContext(ServerRailContext)
+ if (!ctx) {
+ throw new Error("ServerRailMobileButton must be used inside ServerRailLayout")
+ }
+ return ctx
+}
+
+function ServerRailMobileButton() {
+ const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
+ return (
+ {
+ if (window.matchMedia(DESKTOP_MQ).matches) {
+ expandRail()
+ return
+ }
+ openRail()
+ }}
+ >
+
+ {selectedLabel}
+
+ )
+}
+
+function ServerRailLayout({
+ items,
+ selectedId,
+ onSelect,
+ showAll = true,
+ allCount = 0,
+ showCount = true,
+ showType = true,
+ headerRight,
+ loading = false,
+ extra,
+ header,
+ banner,
+ contentClassName,
+ children,
+}: {
+ items: ServerTileItem[]
+ selectedId: string
+ onSelect: (id: string) => void
+ showAll?: boolean
+ allCount?: number
+ showCount?: boolean
+ showType?: boolean
+ headerRight?: ReactNode
+ loading?: boolean
+ extra?: ReactNode
+ header?: ReactNode
+ banner?: ReactNode
+ contentClassName?: string
+ children: ReactNode
+}) {
+ const [railOpen, setRailOpen] = useState(false)
+ const [collapsed, setCollapsed] = useState(false)
+ const [hydrated, setHydrated] = useState(false)
+
+ useEffect(() => {
+ setCollapsed(readCollapsed())
+ setHydrated(true)
+ }, [])
+
+ useEffect(() => {
+ if (!hydrated) return
+ writeCollapsed(collapsed)
+ }, [collapsed, hydrated])
+
+ const handleSelect = useCallback(
+ (id: string) => {
+ onSelect(id)
+ setRailOpen(false)
+ },
+ [onSelect],
+ )
+
+ const expandRail = useCallback(() => {
+ setCollapsed(false)
+ }, [])
+
+ const selectedLabel = useMemo(() => {
+ if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы"
+ const item = items.find((entry) => entry.id === selectedId)
+ return item ? hostnameOf(item) : "Сервер"
+ }, [items, selectedId, showAll])
+
+ const ctx = useMemo(
+ () => ({
+ openRail: () => setRailOpen(true),
+ expandRail,
+ collapsed,
+ selectedLabel,
+ }),
+ [collapsed, expandRail, selectedLabel],
+ )
+
+ const railHeaderRight = (
+ <>
+ {headerRight}
+ setCollapsed(true)}
+ >
+
+
+ >
+ )
+
+ const rail = (
+
+ )
+
+ return (
+
+
+ {header}
+ {banner}
+
+ {!collapsed ? (
+
+ ) : null}
+
+ {children}
+
+
+
+
+
+ Серверы
+
+
+
+ {extra}
+
+
+
+
+
+ )
+}
+
+export { ServerRailLayout, ServerRailMobileButton }
diff --git a/components/server-tile-rail.tsx b/components/server-tile-rail.tsx
index eb796b9..28309ad 100644
--- a/components/server-tile-rail.tsx
+++ b/components/server-tile-rail.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useMemo, useState, type ReactNode } from "react"
+import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
import type { ServerStatus, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
@@ -12,7 +12,15 @@ import {
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { Spinner } from "@/components/ui/spinner"
import {
InputGroup,
InputGroupAddon,
@@ -21,7 +29,7 @@ import {
import { cn } from "@/lib/utils"
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
-/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
+/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
export const ALL_SERVERS_ID = "all"
export interface ServerTileItem {
@@ -34,6 +42,9 @@ export interface ServerTileItem {
enabled?: boolean
selectable?: boolean
title?: string
+ site?: string
+ host?: string
+ meta?: string
}
function typeBadgeVariant(
@@ -58,12 +69,22 @@ function ServerTypeBadge({ type }: { type: ServerType }) {
)
}
+function hostnameOf(item: ServerTileItem): string {
+ return item.name || item.host || ""
+}
+
function matchesQuery(item: ServerTileItem, q: string): boolean {
if (!q) return true
- const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
+ const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
+ .join(" ")
+ .toLowerCase()
return hay.includes(q)
}
+function isSelectable(item: ServerTileItem): boolean {
+ return item.selectable !== false
+}
+
function ServerTileRail({
items,
selectedId,
@@ -74,6 +95,7 @@ function ServerTileRail({
showCount = true,
showType = true,
headerRight,
+ loading = false,
className,
}: {
items: ServerTileItem[]
@@ -85,18 +107,106 @@ function ServerTileRail({
showCount?: boolean
showType?: boolean
headerRight?: ReactNode
+ loading?: boolean
className?: string
}) {
const [query, setQuery] = useState("")
const q = query.trim().toLowerCase()
+ const listRef = useRef(null)
const filtered = useMemo(
() => items.filter((item) => matchesQuery(item, q)),
[items, q],
)
+ const uniqueSites = useMemo(() => {
+ const sites = new Set(
+ filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
+ )
+ return sites.size
+ }, [filtered])
+
+ const groupBySite = uniqueSites >= 2 && !q
+
+ const groups = useMemo(() => {
+ if (!groupBySite) {
+ return [{ site: "", items: filtered }]
+ }
+ const bySite = new Map()
+ for (const item of filtered) {
+ const site = item.site?.trim() || "—"
+ const list = bySite.get(site)
+ if (list) list.push(item)
+ else bySite.set(site, [item])
+ }
+ return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems }))
+ }, [filtered, groupBySite])
+
const showAllTile = showAll && !q
+ const resolvedId = useMemo(() => {
+ if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID
+ if (items.some((item) => item.id === selectedId)) return selectedId
+ if (showAll) return ALL_SERVERS_ID
+ return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
+ }, [items, selectedId, showAll])
+
+ useEffect(() => {
+ if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId)
+ }, [onSelect, resolvedId, selectedId])
+
+ const selectableIds = useMemo(() => {
+ const ids: string[] = []
+ if (showAllTile) ids.push(ALL_SERVERS_ID)
+ for (const item of filtered) {
+ if (isSelectable(item)) ids.push(item.id)
+ }
+ return ids
+ }, [filtered, showAllTile])
+
+ function moveSelection(delta: number) {
+ if (selectableIds.length === 0) return
+ const idx = selectableIds.indexOf(resolvedId)
+ const nextIdx =
+ idx < 0
+ ? delta > 0 ? 0 : selectableIds.length - 1
+ : Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
+ const nextId = selectableIds[nextIdx]
+ if (nextId) onSelect(nextId)
+ }
+
+ function handleListKeyDown(event: KeyboardEvent) {
+ if (loading) return
+ if (event.key === "ArrowDown") {
+ event.preventDefault()
+ moveSelection(1)
+ return
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault()
+ moveSelection(-1)
+ return
+ }
+ if (event.key === "Home") {
+ event.preventDefault()
+ if (selectableIds[0]) onSelect(selectableIds[0])
+ return
+ }
+ if (event.key === "End") {
+ event.preventDefault()
+ const last = selectableIds[selectableIds.length - 1]
+ if (last) onSelect(last)
+ }
+ }
+
+ const allItem: ServerTileItem = {
+ id: ALL_SERVERS_ID,
+ name: "Все серверы",
+ title: "Все серверы",
+ count: allCount,
+ meta: String(allCount),
+ }
+
return (
@@ -106,10 +216,16 @@ function ServerTileRail({
Серверы
+
+ {loading ? : null}
+ {headerRight}
+
+
+ ) : headerRight || loading ? (
+
+ {loading ? : null}
{headerRight}
- ) : headerRight ? (
- {headerRight}
) : null}
@@ -120,18 +236,27 @@ function ServerTileRail({
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
aria-label="Поиск сервера"
+ disabled={loading}
/>
-
+
{showAllTile ? (
onSelect(ALL_SERVERS_ID)}
- title="Все серверы"
- name="Все"
- count={showCount ? allCount : undefined}
+ showCount={showCount}
+ showType={false}
icon={
@@ -175,62 +310,76 @@ function ServerTileRail({
}
function ServerTileButton({
+ item,
selected,
onSelect,
- title,
- name,
- count,
- enabled = true,
- selectable = true,
- status,
- type,
+ showCount,
+ showType,
+ showSite = true,
icon,
}: {
+ item: ServerTileItem
selected: boolean
onSelect: () => void
- title: string
- name: string
- count?: number
- enabled?: boolean
- selectable?: boolean
- status?: ServerStatus
- type?: ServerType
+ showCount: boolean
+ showType: boolean
+ showSite?: boolean
icon: ReactNode
}) {
+ const isAll = item.id === ALL_SERVERS_ID
+ const selectable = isSelectable(item)
+ const hostname = isAll ? "Все серверы" : hostnameOf(item)
+ const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
+ const showStatus = Boolean(item.status && item.status !== "online")
+
return (
-
+ }
className={cn(
- "flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
- selected
- ? "bg-muted ring-1 ring-border"
- : "hover:bg-muted/60",
- !enabled && !selected && "opacity-40",
+ "h-11 min-h-11 flex-nowrap rounded-md py-0",
+ selected && "ring-1 ring-border",
+ item.enabled === false && !selected && "opacity-40",
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
)}
>
- {icon}
-
- {status ? : null}
- {name}
- {type ? : null}
-
- {count != null ? (
-
- {count}
-
+ {icon}
+
+
+ {showStatus ? : null}
+ {hostname}
+ {!isAll && showSite && item.site ? (
+
+ {item.site}
+
+ ) : null}
+ {!isAll && showType && item.type ? : null}
+
+
+ {meta ? (
+
+
+ {meta}
+
+
) : null}
-
+
)
}
-export { ServerTileRail, ServerTypeBadge }
+export { ServerTileRail, ServerTypeBadge, hostnameOf }
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 0000000..503501e
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,372 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import * as RechartsPrimitive from "recharts"
+import type { TooltipValueType } from "recharts"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+const INITIAL_DIMENSION = { width: 320, height: 200 } as const
+type TooltipNameType = number | string
+
+export type ChartConfig = Record<
+ string,
+ {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+>
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ initialDimension = INITIAL_DIMENSION,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+ initialDimension?: {
+ width: number
+ height: number
+ }
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme ?? config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+