feat(filters, recursive-routes): enhance configuration history and live data handling
- Introduced configuration history management in Filters and Recursive Routes pages, allowing users to view and restore previous configurations. - Updated state management to handle live data loading and error states more effectively, improving user experience during data fetching. - Added new components for displaying configuration history and integrated them into existing pages. - Enhanced API interactions to support fetching and applying configuration revisions, ensuring data consistency across the application. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { hasManagedCommentPrefix, isOwnedRecursiveComment, managedRecursiveComment, stripManagedRecursiveComment } from "../managed-markers.js"
|
||||
import {
|
||||
planBgpInApply,
|
||||
planRecursiveApply,
|
||||
unmanagedRouteIds,
|
||||
} from "./config-apply-plan.js"
|
||||
import {
|
||||
canonicalFilterRules,
|
||||
fingerprintPayload,
|
||||
} from "./config-revisions.js"
|
||||
import { mapRosManagedRoutes } from "./config-apply-plan.js"
|
||||
|
||||
{
|
||||
const fp1 = fingerprintPayload(canonicalFilterRules([
|
||||
{ community: "65001:100", action: "route", gateway: "10.0.0.1", gatewayTunnelId: "gre1", description: "a" },
|
||||
]))
|
||||
const fp2 = fingerprintPayload(canonicalFilterRules([
|
||||
{ community: "65001:100", action: "route", gateway: "10.0.0.1", gatewayTunnelId: "gre1", description: "a" },
|
||||
]))
|
||||
const fp3 = fingerprintPayload(canonicalFilterRules([
|
||||
{ community: "65001:100", action: "route", gateway: "10.0.0.2", gatewayTunnelId: "gre1", description: "a" },
|
||||
]))
|
||||
assert.equal(fp1, fp2)
|
||||
assert.notEqual(fp1, fp3)
|
||||
}
|
||||
|
||||
{
|
||||
const existing = [
|
||||
{ ".id": "*1", chain: "bgp-in", comment: "MikrotikManager: msk", rule: "if (true) { accept; }" },
|
||||
{ ".id": "*2", chain: "bgp-in", comment: "legacy", rule: "if (bgp-communities includes 1:1) { reject; }" },
|
||||
{ ".id": "*3", chain: "bgp-out", comment: "MikrotikManager: other", rule: "if (bgp-communities includes 1:1) { accept; }" },
|
||||
]
|
||||
const patch = planBgpInApply(existing, 3)
|
||||
assert.equal(patch.action, "patch")
|
||||
assert.equal(patch.managedId, "*1")
|
||||
assert.deepEqual(patch.conflictIds, ["*2"])
|
||||
|
||||
const create = planBgpInApply(existing.filter((r) => r[".id"] !== "*1"), 1)
|
||||
assert.equal(create.action, "create")
|
||||
assert.equal(create.managedId, undefined)
|
||||
|
||||
const del = planBgpInApply(existing, 0)
|
||||
assert.equal(del.action, "delete")
|
||||
assert.equal(del.managedId, "*1")
|
||||
|
||||
const noop = planBgpInApply([], 0)
|
||||
assert.equal(noop.action, "noop")
|
||||
}
|
||||
|
||||
{
|
||||
const routes = [
|
||||
{ ".id": "*10", comment: "MikrotikManager:recursive via de", static: "true", "dst-address": "8.8.8.8/32", gateway: "1.1.1.1" },
|
||||
{ ".id": "*11", comment: "user static", static: "true", "dst-address": "1.1.1.1/32", gateway: "9.9.9.9" },
|
||||
{ ".id": "*12", comment: "recursive: old", static: "true", "dst-address": "9.9.9.9/32", gateway: "1.1.1.1" },
|
||||
]
|
||||
const plan = planRecursiveApply(routes)
|
||||
assert.deepEqual(plan.deleteIds, ["*10", "*12"])
|
||||
assert.deepEqual(unmanagedRouteIds(routes), ["*11"])
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(stripManagedRecursiveComment("MikrotikManager:recursive via de"), "via de")
|
||||
assert.equal(stripManagedRecursiveComment("recursive: old"), "old")
|
||||
assert.equal(managedRecursiveComment("MikrotikManager:recursive via de"), "MikrotikManager:recursive via de")
|
||||
assert.equal(isOwnedRecursiveComment("MikrotikManager:recursive via de"), true)
|
||||
assert.equal(isOwnedRecursiveComment("recursive: x"), true)
|
||||
assert.equal(isOwnedRecursiveComment("user static"), false)
|
||||
assert.equal(hasManagedCommentPrefix("MikrotikManager: msk"), true)
|
||||
}
|
||||
|
||||
{
|
||||
const mapped = mapRosManagedRoutes([
|
||||
{
|
||||
".id": "*1",
|
||||
static: "true",
|
||||
"dst-address": "10.9.9.2/32",
|
||||
gateway: "1.2.3.4",
|
||||
comment: "MikrotikManager:recursive hop-de",
|
||||
distance: "1",
|
||||
},
|
||||
{
|
||||
".id": "*2",
|
||||
static: "true",
|
||||
"dst-address": "10.9.9.3/32",
|
||||
gateway: "1.2.3.4",
|
||||
comment: "not ours",
|
||||
distance: "1",
|
||||
},
|
||||
])
|
||||
assert.equal(mapped.length, 1)
|
||||
assert.equal(mapped[0]?.dstAddress, "10.9.9.2/32")
|
||||
assert.equal(mapped[0]?.comment, "hop-de")
|
||||
}
|
||||
|
||||
console.log("config-apply-plan.test.ts: ok")
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
hasManagedCommentPrefix,
|
||||
isOwnedRecursiveComment,
|
||||
stripManagedRecursiveComment,
|
||||
} from "../managed-markers.js"
|
||||
|
||||
export type RosFilterRuleLike = {
|
||||
".id"?: string
|
||||
chain?: string
|
||||
rule?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export type BgpInApplyAction = "patch" | "create" | "delete" | "noop"
|
||||
|
||||
export interface BgpInApplyPlan {
|
||||
action: BgpInApplyAction
|
||||
managedId?: string
|
||||
conflictIds: string[]
|
||||
}
|
||||
|
||||
function isInBgpIn(rule: RosFilterRuleLike): boolean {
|
||||
return (rule.chain ?? "").trim().toLowerCase() === "bgp-in"
|
||||
}
|
||||
|
||||
export function planBgpInApply(
|
||||
existing: RosFilterRuleLike[],
|
||||
rulesCount: number,
|
||||
): BgpInApplyPlan {
|
||||
const managed = existing.find(
|
||||
(r) => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
|
||||
)
|
||||
const conflictIds = existing
|
||||
.filter((r) =>
|
||||
isInBgpIn(r) &&
|
||||
!hasManagedCommentPrefix(r.comment ?? "") &&
|
||||
/bgp-communities/i.test(r.rule ?? ""),
|
||||
)
|
||||
.map((r) => r[".id"])
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
if (rulesCount > 0) {
|
||||
return {
|
||||
action: managed?.[".id"] ? "patch" : "create",
|
||||
managedId: managed?.[".id"],
|
||||
conflictIds,
|
||||
}
|
||||
}
|
||||
if (managed?.[".id"]) {
|
||||
return { action: "delete", managedId: managed[".id"], conflictIds }
|
||||
}
|
||||
return { action: "noop", conflictIds }
|
||||
}
|
||||
|
||||
export type RosRouteLike = {
|
||||
".id"?: string
|
||||
comment?: string
|
||||
static?: string
|
||||
dynamic?: string
|
||||
blackhole?: string
|
||||
unreachable?: string
|
||||
prohibit?: string
|
||||
"dst-address"?: string
|
||||
gateway?: string
|
||||
}
|
||||
|
||||
export function planRecursiveApply(existing: RosRouteLike[]): { deleteIds: string[] } {
|
||||
return {
|
||||
deleteIds: existing
|
||||
.filter((r) => isOwnedRecursiveComment(r.comment))
|
||||
.map((r) => r[".id"])
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
}
|
||||
}
|
||||
|
||||
export function unmanagedRouteIds(existing: RosRouteLike[]): string[] {
|
||||
return existing
|
||||
.filter((r) => Boolean(r[".id"]) && !isOwnedRecursiveComment(r.comment))
|
||||
.map((r) => r[".id"] as string)
|
||||
}
|
||||
|
||||
export function userRecursiveComment(comment: string | undefined): string {
|
||||
return stripManagedRecursiveComment(comment ?? "")
|
||||
}
|
||||
|
||||
function isIpGateway(gw: string): boolean {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
|
||||
}
|
||||
|
||||
export function isManagedRecursiveRoute(r: RosRouteLike): boolean {
|
||||
if ((r.static ?? "false") !== "true") return false
|
||||
if ((r.dynamic ?? "false") === "true") return false
|
||||
if ((r.blackhole ?? "false") === "true") return false
|
||||
if ((r.unreachable ?? "false") === "true") return false
|
||||
if ((r.prohibit ?? "false") === "true") return false
|
||||
const dst = r["dst-address"] ?? ""
|
||||
const gw = r.gateway ?? ""
|
||||
if (!dst || !gw) return false
|
||||
if (!isIpGateway(gw)) return false
|
||||
return isOwnedRecursiveComment(r.comment)
|
||||
}
|
||||
|
||||
export interface MappedRecursiveRoute {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
routingTable: string
|
||||
checkGateway: string
|
||||
country: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function mapRosManagedRoutes(
|
||||
rosRoutes: Array<RosRouteLike & {
|
||||
distance?: string
|
||||
scope?: string
|
||||
"target-scope"?: string
|
||||
"routing-table"?: string
|
||||
"check-gateway"?: string
|
||||
disabled?: string
|
||||
}>,
|
||||
): MappedRecursiveRoute[] {
|
||||
return rosRoutes.filter(isManagedRecursiveRoute).map((r, i) => ({
|
||||
id: r[".id"] ?? `ros-${i}`,
|
||||
dstAddress: r["dst-address"] ?? "",
|
||||
gateway: r.gateway ?? "",
|
||||
distance: Number.parseInt(r.distance ?? "1", 10) || 1,
|
||||
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null,
|
||||
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null,
|
||||
routingTable: r["routing-table"] ?? "main",
|
||||
checkGateway: r["check-gateway"] ?? "",
|
||||
country: "",
|
||||
comment: userRecursiveComment(r.comment),
|
||||
disabled: r.disabled === "true",
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
import { dbQuery } from "../db/index.js"
|
||||
import {
|
||||
appendRevisionIfChanged,
|
||||
fingerprintPayload,
|
||||
getRevisionById,
|
||||
listRevisions,
|
||||
pruneRevisions,
|
||||
} from "./config-revisions.js"
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("config-revisions.test.ts: skip")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const tag = `rev-test-${Date.now()}`
|
||||
await dbQuery(`INSERT INTO servers (name, host) VALUES ($1, '127.0.0.1')`, [tag])
|
||||
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = $1 LIMIT 1`, [tag])
|
||||
const serverId = rows[0]?.id
|
||||
assert.ok(serverId)
|
||||
|
||||
try {
|
||||
const payloadA = [{ community: "1:1", action: "route" }]
|
||||
const first = await appendRevisionIfChanged({
|
||||
serverId,
|
||||
section: "filters",
|
||||
source: "apply",
|
||||
payload: payloadA,
|
||||
})
|
||||
assert.equal(first.created, true)
|
||||
assert.equal(first.revision.source, "apply")
|
||||
|
||||
const dup = await appendRevisionIfChanged({
|
||||
serverId,
|
||||
section: "filters",
|
||||
source: "observed",
|
||||
payload: payloadA,
|
||||
})
|
||||
assert.equal(dup.created, false)
|
||||
assert.equal(dup.revision.id, first.revision.id)
|
||||
|
||||
const payloadB = [{ community: "1:2", action: "blackhole" }]
|
||||
const second = await appendRevisionIfChanged({
|
||||
serverId,
|
||||
section: "filters",
|
||||
source: "rollback",
|
||||
payload: payloadB,
|
||||
})
|
||||
assert.equal(second.created, true)
|
||||
assert.equal(second.revision.source, "rollback")
|
||||
assert.notEqual(second.revision.fingerprint, first.revision.fingerprint)
|
||||
|
||||
const listed = await listRevisions(serverId, "filters")
|
||||
assert.equal(listed.length, 2)
|
||||
assert.equal(listed[0]?.source, "rollback")
|
||||
|
||||
const stored = await getRevisionById(second.revision.id)
|
||||
assert.ok(stored)
|
||||
assert.equal(fingerprintPayload(stored.payload), second.revision.fingerprint)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await appendRevisionIfChanged({
|
||||
serverId,
|
||||
section: "filters",
|
||||
source: "apply",
|
||||
payload: [{ community: `9:${i}`, action: "route" }],
|
||||
})
|
||||
}
|
||||
const pruned = await pruneRevisions(serverId, "filters", 3)
|
||||
assert.ok(pruned >= 1)
|
||||
const after = await listRevisions(serverId, "filters")
|
||||
assert.equal(after.length, 3)
|
||||
} finally {
|
||||
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
|
||||
}
|
||||
|
||||
console.log("config-revisions.test.ts: ok")
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { configRevisions, type ConfigRevisionRow } from "../db/schema.js"
|
||||
|
||||
export const CONFIG_REVISION_KEEP = 50
|
||||
|
||||
export type ConfigSection = "filters" | "recursive-routes"
|
||||
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
|
||||
|
||||
export interface ConfigRevisionDto {
|
||||
id: string
|
||||
serverId: string
|
||||
section: ConfigSection
|
||||
source: ConfigRevisionSource
|
||||
fingerprint: string
|
||||
createdAt: string
|
||||
note: string | null
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
|
||||
const obj = value as Record<string, unknown>
|
||||
const keys = Object.keys(obj).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`
|
||||
}
|
||||
|
||||
export function fingerprintPayload(payload: unknown): string {
|
||||
return createHash("sha256").update(stableStringify(payload)).digest("hex")
|
||||
}
|
||||
|
||||
export function canonicalFilterRules(
|
||||
rules: Array<{
|
||||
community?: string
|
||||
action?: string
|
||||
gateway?: string
|
||||
gatewayTunnelId?: string
|
||||
description?: string
|
||||
}>,
|
||||
): unknown[] {
|
||||
return rules.map((r) => ({
|
||||
community: (r.community ?? "").trim(),
|
||||
action: r.action === "blackhole" ? "blackhole" : "route",
|
||||
gateway: r.gateway ?? "",
|
||||
gatewayTunnelId: r.gatewayTunnelId ?? "",
|
||||
description: r.description ?? "",
|
||||
}))
|
||||
}
|
||||
|
||||
export function canonicalRecursiveRoutes(
|
||||
routes: Array<{
|
||||
dstAddress?: string
|
||||
gateway?: string
|
||||
distance?: number
|
||||
scope?: number | null
|
||||
targetScope?: number | null
|
||||
routingTable?: string
|
||||
checkGateway?: string
|
||||
comment?: string
|
||||
disabled?: boolean
|
||||
country?: string
|
||||
}>,
|
||||
): unknown[] {
|
||||
return routes.map((r) => ({
|
||||
dstAddress: (r.dstAddress ?? "").trim(),
|
||||
gateway: r.gateway ?? "",
|
||||
distance: r.distance ?? 1,
|
||||
scope: r.scope ?? null,
|
||||
targetScope: r.targetScope ?? null,
|
||||
routingTable: r.routingTable || "main",
|
||||
checkGateway: r.checkGateway ?? "",
|
||||
comment: r.comment ?? "",
|
||||
disabled: Boolean(r.disabled),
|
||||
country: r.country ?? "",
|
||||
}))
|
||||
}
|
||||
|
||||
export function toRevisionDto(row: ConfigRevisionRow): ConfigRevisionDto {
|
||||
const payload = row.payload
|
||||
const itemCount = Array.isArray(payload) ? payload.length : 0
|
||||
return {
|
||||
id: row.id,
|
||||
serverId: String(row.serverId),
|
||||
section: row.section,
|
||||
source: row.source,
|
||||
fingerprint: row.fingerprint,
|
||||
createdAt: row.createdAt,
|
||||
note: row.note ?? null,
|
||||
itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRevisions(
|
||||
serverId: number,
|
||||
section: ConfigSection,
|
||||
limit = CONFIG_REVISION_KEEP,
|
||||
): Promise<ConfigRevisionDto[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(configRevisions)
|
||||
.where(and(eq(configRevisions.serverId, serverId), eq(configRevisions.section, section)))
|
||||
.orderBy(desc(configRevisions.createdAt))
|
||||
.limit(limit)
|
||||
return rows.map(toRevisionDto)
|
||||
}
|
||||
|
||||
export async function getRevisionById(id: string): Promise<ConfigRevisionRow | undefined> {
|
||||
return (await db.select().from(configRevisions).where(eq(configRevisions.id, id)).limit(1))[0]
|
||||
}
|
||||
|
||||
export async function pruneRevisions(
|
||||
serverId: number,
|
||||
section: ConfigSection,
|
||||
keep = CONFIG_REVISION_KEEP,
|
||||
): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ id: configRevisions.id })
|
||||
.from(configRevisions)
|
||||
.where(and(eq(configRevisions.serverId, serverId), eq(configRevisions.section, section)))
|
||||
.orderBy(desc(configRevisions.createdAt))
|
||||
const extra = rows.slice(keep)
|
||||
if (extra.length === 0) return 0
|
||||
for (const row of extra) {
|
||||
await db.delete(configRevisions).where(eq(configRevisions.id, row.id))
|
||||
}
|
||||
return extra.length
|
||||
}
|
||||
|
||||
export async function appendRevisionIfChanged(input: {
|
||||
serverId: number
|
||||
section: ConfigSection
|
||||
source: ConfigRevisionSource
|
||||
payload: unknown
|
||||
note?: string | null
|
||||
}): Promise<{ created: boolean; revision: ConfigRevisionDto }> {
|
||||
const fingerprint = fingerprintPayload(input.payload)
|
||||
const latest = (await db
|
||||
.select()
|
||||
.from(configRevisions)
|
||||
.where(and(
|
||||
eq(configRevisions.serverId, input.serverId),
|
||||
eq(configRevisions.section, input.section),
|
||||
))
|
||||
.orderBy(desc(configRevisions.createdAt))
|
||||
.limit(1))[0]
|
||||
|
||||
if (latest?.fingerprint === fingerprint) {
|
||||
return { created: false, revision: toRevisionDto(latest) }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const id = randomUUID()
|
||||
await db.insert(configRevisions).values({
|
||||
id,
|
||||
serverId: input.serverId,
|
||||
section: input.section,
|
||||
source: input.source,
|
||||
fingerprint,
|
||||
payload: Array.isArray(input.payload) ? input.payload : [],
|
||||
note: input.note ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
await pruneRevisions(input.serverId, input.section)
|
||||
const row = await getRevisionById(id)
|
||||
if (!row) throw new Error("config-revisions: insert vanished")
|
||||
return { created: true, revision: toRevisionDto(row) }
|
||||
}
|
||||
Reference in New Issue
Block a user