feat: integrate sonner for toast notifications and enhance UI feedback

Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
This commit is contained in:
Denozordec committed 2026-05-07 20:49:35 +07:00
1 parent 84ecd4f061
commit 11ad94f67d
33 files changed
+12350 -252

No files matched your search

+44 -1
View File
@@ -6,6 +6,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { appendEvent } from "../modules/events/service/events-service.js"
type BackupMeta = {
id: string
@@ -121,6 +122,21 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
await writeIndex(indexRows)
job.status = "done"
job.finishedAt = new Date().toISOString()
appendEvent({
level: job.failures.length > 0 ? "warning" : "info",
eventType: "backups.job.done",
sourceModule: "backups",
title: job.failures.length > 0 ? "Бэкап завершен с ошибками" : "Бэкап завершен",
message: `Создано: ${job.created.length}, ошибок: ${job.failures.length}`,
entityType: "backup_job",
entityId: job.id,
payload: {
total: job.total,
completed: job.completed,
failures: job.failures,
notes: notes ?? null,
},
})
}
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -148,13 +164,40 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
failures: [],
}
backupJobs.set(jobId, job)
appendEvent({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
title: "Запущен бэкап",
message: `Серверов в очереди: ${ids.length}`,
entityType: "backup_job",
entityId: jobId,
payload: {
serverIds: ids,
notes: notes ?? null,
},
})
queueMicrotask(() => {
void processBackupJob(job, ids, notes).catch((err) => {
job.status = "failed"
job.finishedAt = new Date().toISOString()
job.failures.push({
const failure = {
serverId: "job",
error: err instanceof Error ? err.message : String(err),
}
job.failures.push(failure)
appendEvent({
level: "critical",
eventType: "backups.job.failed",
sourceModule: "backups",
title: "Бэкап прерван",
message: failure.error,
entityType: "backup_job",
entityId: job.id,
payload: {
total: job.total,
completed: job.completed,
},
})
})
})
+24
View File
@@ -0,0 +1,24 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { appendEventsSchema, listEventsQuerySchema } from "../../../packages/contracts/dist/events.js"
import { appendEvents, readEvents } from "../modules/events/service/events-service.js"
const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/events", async (req, reply) => {
const parsed = listEventsQuerySchema.safeParse(req.query ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
}
return reply.send({ events: readEvents(parsed.data) })
})
app.post("/events/batch", async (req, reply) => {
const parsed = appendEventsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
appendEvents(parsed.data.events)
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
})
}
export default eventsRoutes
+230 -41
View File
@@ -4,6 +4,7 @@ import { db } from "../db/index.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
import { appendEvent } from "../modules/events/service/events-service.js"
type ServerRow = typeof servers.$inferSelect
@@ -87,7 +88,6 @@ function parseInnerIps(rule: string | undefined): { localInnerIp: string; remote
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
const text = raw.rule ?? ""
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
const out: ApiFilterRule[] = []
const extractCommunities = (src: string): string[] => {
@@ -98,6 +98,25 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
return [...new Set(communities)]
}
/**
* Per-community описания: `# 65001:130: Torrents` → { "65001:130": "Torrents" }.
* Используется при round-trip Router → DB, чтобы не схлопывать описания всех
* членов сгруппированного блока в одну общую подпись.
*/
const extractDescMap = (src: string): Map<string, string> => {
const m = new Map<string, string>()
for (const cm of src.matchAll(/#\s*(\d+:\d+)\s*:\s*([^\n]+)/g)) {
m.set(cm[1], cm[2].trim())
}
return m
}
/** Первый произвольный `#` комментарий — fallback, если нет per-community. */
const firstInlineComment = (src: string): string =>
src.match(/#\s*([^\n]+)/)?.[1]?.trim() ?? ""
const rawComment = raw.comment?.trim() ?? ""
// Парсим по веткам if/else if, чтобы action применялся к "своим" communities.
const branches = [...text.matchAll(/(?:if|else\s+if)\s*\(([\s\S]*?)\)\s*\{([\s\S]*?)\}/gi)]
if (branches.length > 0) {
@@ -113,14 +132,18 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
const gwToken = body.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
const outIface = body.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
const descMap = extractDescMap(body)
const fallbackInline = firstInlineComment(body)
for (const community of comms) {
const perComm = descMap.get(community)?.trim() ?? ""
out.push({
id: `${raw[".id"] ?? "live"}-${out.length}`,
community,
action: isBlackhole ? "blackhole" : "route",
gateway: isBlackhole ? "" : gwToken,
gatewayTunnelId: isBlackhole ? "" : outIface,
description: inlineComment || raw.comment?.trim() || "",
description: perComm || fallbackInline || rawComment,
})
}
}
@@ -135,14 +158,19 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
/set\s+blackhole\s+(?:yes|true)/i.test(text)
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
return communities.map((community, idx) => ({
id: `${raw[".id"] ?? "live"}-${idx}`,
community,
action: isBlackhole ? "blackhole" : "route",
gateway: isBlackhole ? "" : gwToken,
gatewayTunnelId: isBlackhole ? "" : outIface,
description: inlineComment || raw.comment?.trim() || "",
}))
const flatDescMap = extractDescMap(text)
const flatFallback = firstInlineComment(text)
return communities.map((community, idx) => {
const perComm = flatDescMap.get(community)?.trim() ?? ""
return {
id: `${raw[".id"] ?? "live"}-${idx}`,
community,
action: isBlackhole ? "blackhole" : "route",
gateway: isBlackhole ? "" : gwToken,
gatewayTunnelId: isBlackhole ? "" : outIface,
description: perComm || flatFallback || rawComment,
}
})
}
/** Хоп для BGP filter из префикса рекурсивного статического маршрута (напр. 10.9.9.2/32 → 10.9.9.2) */
@@ -308,26 +336,66 @@ function compareDbRulesWithRouter(
}
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
return rules.map((rule, i) => {
const kw = i === 0 ? "if" : "} else if"
const comment = rule.description ? ` # ${rule.description}` : ""
if (rule.action === "blackhole") {
if (rules.length === 0) return ""
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
// синтаксису bgp-in на MikroTik.
// RouterOS routing filter parser НЕ поддерживает `else if` (см. error
// «expected '{' instead of 'if'»), поэтому между группами — независимые `if`-блоки;
// после `accept;` обработка правила завершается, следующие `if`-ы не запускаются.
// Эталон тела: `bgp-communities includes <v>`, `set gw <ip>`.
type Group = {
isBlackhole: boolean
gateway: string
outIface: string
items: ApiFilterRule[]
}
const groups: Group[] = []
const indexByKey = new Map<string, number>()
for (const rule of rules) {
const isBlackhole = rule.action === "blackhole"
const { gateway, outIface } = isBlackhole
? { gateway: "", outIface: "" }
: resolveRouteTargets(serverId, rule)
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
let idx = indexByKey.get(key)
if (idx === undefined) {
idx = groups.length
indexByKey.set(key, idx)
groups.push({ isBlackhole, gateway, outIface, items: [] })
}
groups[idx].items.push(rule)
}
return groups.map(g => {
const cond = g.items
.map(r => `bgp-communities includes ${r.community}`)
.join(" || ")
// Per-community подпись `# 65001:130: Torrents` — на роутере человеко-читаемо,
// и parseFilterRule умеет извлечь её обратно в description конкретного правила.
const commentLines = g.items
.filter(r => r.description?.trim())
.map(r => ` # ${r.community}: ${r.description.trim()}`)
if (g.isBlackhole) {
return [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
" set type blackhole;",
" accept;",
].filter(Boolean).join("\n")
`if (${cond}) {`,
...commentLines,
" set type blackhole;",
" accept;",
"}",
].join("\n")
}
const { gateway, outIface } = resolveRouteTargets(serverId, rule)
const lines = [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
` set gateway ${gateway};`,
`if (${cond}) {`,
...commentLines,
` set gw ${g.gateway};`,
]
if (outIface) lines.push(` set out-interface ${outIface};`)
lines.push(" accept;")
return lines.filter(Boolean).join("\n")
if (g.outIface) lines.push(` set out-interface ${g.outIface};`)
lines.push(" accept;")
lines.push("}")
return lines.join("\n")
}).join("\n")
}
@@ -457,30 +525,94 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
try {
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
appendEvent({
level: "info",
eventType: "filters.sync.from_router.started",
sourceModule: "filters",
title: "Синхронизация фильтров запущена",
message: `${server.name || server.host} → БД`,
entityType: "server",
entityId: String(serverId),
})
const remote = await fetchServerFilters(server)
await replaceDbRules(server.id, remote.rules)
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
appendEvent({
level: "info",
eventType: "filters.sync.from_router.done",
sourceModule: "filters",
title: "Синхронизация фильтров завершена",
message: `${server.name || server.host}: ${remote.rules.length} правил`,
entityType: "server",
entityId: String(serverId),
})
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
appendEvent({
level: "critical",
eventType: "filters.sync.from_router.failed",
sourceModule: "filters",
title: "Ошибка синхронизации фильтров",
message: `${server.name || server.host}: ${String(err)}`,
entityType: "server",
entityId: String(serverId),
})
return reply.status(500).send({ error: String(err) })
}
})
app.post("/filters/sync/to-router", async (_req, reply) => {
app.post("/filters/sync/to-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const requestedServerId = parseDbServerId(body?.serverId)
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const targetServers = requestedServerId !== null
? allServers.filter(s => s.id === requestedServerId)
: allServers
if (requestedServerId !== null && targetServers.length === 0) {
return reply.status(404).send({ error: "Server not found" })
}
let updatedServers = 0
let pushedRules = 0
const errors: Array<{ serverId: number; error: string }> = []
appendEvent({
level: "info",
eventType: "filters.sync.to_router.started",
sourceModule: "filters",
title: "Отправка фильтров на роутеры запущена",
message: `Целевых серверов: ${targetServers.length}`,
payload: { requestedServerId },
})
for (const server of allServers) {
for (const server of targetServers) {
try {
app.log.info({ serverId: server.id, host: server.host }, "Filters sync to router started")
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:"))
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/routing/filter/rule/${encodeURIComponent(r[".id"])}`)
}
const isInBgpIn = (r: RosFilterRule) =>
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
const managedComment = `RouterLists: ${server.name || server.host}`
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
const managedRule = existing.find(
r => isInBgpIn(r) && (r.comment ?? "").startsWith("RouterLists:"),
)
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
// удаляем после успешного upsert: иначе старое правило с `else { reject; }`
// отрабатывает первым и перебивает наш upsert.
const conflictIds = existing
.filter(r =>
isInBgpIn(r) &&
!(r.comment ?? "").startsWith("RouterLists:") &&
/bgp-communities/i.test(r.rule ?? ""),
)
.map(r => r[".id"])
.filter((id): id is string => Boolean(id))
const rows = db.select().from(filterRules)
.where(and(eq(filterRules.serverId, server.id)))
@@ -497,22 +629,79 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
description: r.description,
}))
// Upsert: PATCH существующего managed-правила или POST /add нового.
// Если ошибка — конфликтные правила НЕ удаляем (роутер не остаётся с пустым bgp-in).
// Путь `/routing/filter/rule/add` обязателен: голый POST на коллекцию RouterOS REST
// трактует как «вызов команды» и отдаёт 400 «no such command».
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(server.id, rules)
await client.post("/routing/filter/rule", {
chain: "bgp-in",
comment: `RouterLists: ${server.name || server.host}`,
rule: ruleBody,
})
if (managedRule && managedRule[".id"]) {
await client.patch(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
{
chain: "bgp-in",
comment: managedComment,
rule: ruleBody,
disabled: "no",
},
)
app.log.info({ serverId: server.id, id: managedRule[".id"] }, "bgp-in rule updated")
} else {
await client.post("/routing/filter/rule/add", {
chain: "bgp-in",
comment: managedComment,
rule: ruleBody,
})
app.log.info({ serverId: server.id }, "bgp-in rule created")
}
pushedRules += rules.length
} else if (managedRule && managedRule[".id"]) {
// В БД нет правил → удаляем наш managed-rule на роутере.
await client.delete(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
)
app.log.info({ serverId: server.id }, "bgp-in rule removed (no rules in DB)")
}
for (const id of conflictIds) {
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
}
updatedServers += 1
} catch {
// ignore failed server push
app.log.info(
{
serverId: server.id,
mode: managedRule ? "patch" : "create",
conflictsRemoved: conflictIds.length,
pushed: rules.length,
},
"Filters sync to router completed",
)
} catch (err) {
app.log.error({ serverId: server.id, err: String(err) }, "filters sync to-router failed")
errors.push({ serverId: server.id, error: String(err) })
}
}
return reply.send({ ok: true, updatedServers, pushedRules })
appendEvent({
level: errors.length === 0 ? "info" : "warning",
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial",
sourceModule: "filters",
title: errors.length === 0 ? "Отправка фильтров завершена" : "Отправка фильтров завершена с ошибками",
message: `Успешно: ${updatedServers}, ошибок: ${errors.length}, правил: ${pushedRules}`,
payload: {
updatedServers,
pushedRules,
errors,
},
})
return reply.send({
ok: errors.length === 0,
updatedServers,
pushedRules,
errors,
})
})
}
+32 -9
View File
@@ -11,6 +11,7 @@ import {
} from "../services/traffic-collector.js"
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js"
import { appendEvent } from "../modules/events/service/events-service.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -206,15 +207,37 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.post("/traffic/collect-now", async (_req, reply) => {
await collectTrafficOnce()
scheduleAlertEngineAfterDataCollectors()
const updated = getTrafficSettings()
return reply.send({
ok: true,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
})
try {
await collectTrafficOnce()
scheduleAlertEngineAfterDataCollectors()
const updated = getTrafficSettings()
appendEvent({
level: "info",
eventType: "traffic.collect.manual.ok",
sourceModule: "traffic",
title: "Ручной сбор трафика завершен",
message: `Длительность: ${updated.lastDurationMs ?? 0} мс`,
payload: {
lastCollectedAt: updated.lastCollectedAt ?? null,
},
})
return reply.send({
ok: true,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
})
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
appendEvent({
level: "critical",
eventType: "traffic.collect.manual.failed",
sourceModule: "traffic",
title: "Ошибка ручного сбора трафика",
message: msg,
})
return reply.status(500).send({ error: msg })
}
})
app.get("/traffic/servers", async (req, reply) => {