Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience. - Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval. - Implemented mapping functions for backend server data to frontend types, ensuring consistency across components. - Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
216 lines
6.5 KiB
TypeScript
216 lines
6.5 KiB
TypeScript
import { eq } from "drizzle-orm"
|
|
import { db } from "../db/index.js"
|
|
import { servers } from "../db/schema.js"
|
|
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
|
|
|
|
type ServerRow = typeof servers.$inferSelect
|
|
|
|
interface RosContainer {
|
|
".id"?: string
|
|
name?: string
|
|
tag?: string
|
|
"remote-image"?: string
|
|
interface?: string
|
|
envlist?: string
|
|
mounts?: string
|
|
cmd?: string
|
|
"start-on-boot"?: string
|
|
comment?: string
|
|
status?: string
|
|
"memory-high"?: string
|
|
cpu?: string
|
|
}
|
|
|
|
interface RosContainerEnv {
|
|
name?: string
|
|
key?: string
|
|
value?: string
|
|
}
|
|
|
|
interface RosContainerMount {
|
|
name?: string
|
|
src?: string
|
|
dst?: string
|
|
}
|
|
|
|
export type ContainerLiveStatus = "running" | "stopped" | "error"
|
|
|
|
export type ContainerLive = {
|
|
id: string
|
|
rosId: string
|
|
name: string
|
|
serverId: string
|
|
image: string
|
|
tag: string
|
|
status: ContainerLiveStatus
|
|
envs: { key: string; value: string }[]
|
|
mounts: { dst: string; src?: string }[]
|
|
interfaces: string[]
|
|
cmd?: string
|
|
startOnBoot: boolean
|
|
comment: string
|
|
uptime?: string
|
|
cpu?: number
|
|
memMb?: number
|
|
}
|
|
|
|
function rosYes(v: string | undefined): boolean {
|
|
return v === "true" || v === "yes"
|
|
}
|
|
|
|
function mapStatus(raw: string | undefined): ContainerLiveStatus {
|
|
const s = (raw ?? "").toLowerCase()
|
|
if (s === "running") return "running"
|
|
if (s === "error" || s === "failed") return "error"
|
|
return "stopped"
|
|
}
|
|
|
|
function splitCsv(v: string | undefined): string[] {
|
|
return (v ?? "")
|
|
.split(",")
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function parseImageTag(c: RosContainer): { image: string; tag: string } {
|
|
const remote = (c["remote-image"] ?? "").trim()
|
|
if (remote) {
|
|
const idx = remote.lastIndexOf(":")
|
|
if (idx > 0 && !remote.slice(idx + 1).includes("/")) {
|
|
return { image: remote.slice(0, idx), tag: remote.slice(idx + 1) }
|
|
}
|
|
return { image: remote, tag: (c.tag ?? "latest").trim() || "latest" }
|
|
}
|
|
return { image: (c.name ?? "").trim(), tag: (c.tag ?? "latest").trim() || "latest" }
|
|
}
|
|
|
|
function isMissingPackage(err: unknown): boolean {
|
|
if (err instanceof MikrotikError) {
|
|
if (err.statusCode === 404) return true
|
|
const body = err.body.toLowerCase()
|
|
return body.includes("no such command") || body.includes("not found") || body.includes("unknown")
|
|
}
|
|
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
|
|
return msg.includes("no such command") || msg.includes("404")
|
|
}
|
|
|
|
export function mapContainerRow(
|
|
server: ServerRow,
|
|
c: RosContainer,
|
|
envs: RosContainerEnv[],
|
|
mounts: RosContainerMount[],
|
|
idx: number,
|
|
): ContainerLive {
|
|
const rosId = String(c[".id"] ?? `c-${idx}`)
|
|
const name = (c.name ?? "").trim() || `container-${idx + 1}`
|
|
const { image, tag } = parseImageTag(c)
|
|
const envlist = (c.envlist ?? "").trim()
|
|
const mountNames = new Set(splitCsv(c.mounts))
|
|
const envRows = envlist
|
|
? envs.filter((e) => (e.name ?? "").trim() === envlist && (e.key ?? "").trim())
|
|
: []
|
|
const mountRows = mounts.filter((m) => mountNames.has((m.name ?? "").trim()) && (m.dst ?? "").trim())
|
|
const cpuRaw = Number.parseInt(c.cpu ?? "", 10)
|
|
const memRaw = Number.parseInt(c["memory-high"] ?? "", 10)
|
|
return {
|
|
id: `${server.id}-${rosId}`,
|
|
rosId,
|
|
name,
|
|
serverId: String(server.id),
|
|
image,
|
|
tag,
|
|
status: mapStatus(c.status),
|
|
envs: envRows.map((e) => ({ key: e.key ?? "", value: e.value ?? "" })),
|
|
mounts: mountRows.map((m) => ({ dst: m.dst ?? "", src: m.src || undefined })),
|
|
interfaces: splitCsv(c.interface),
|
|
cmd: (c.cmd ?? "").trim() || undefined,
|
|
startOnBoot: rosYes(c["start-on-boot"]),
|
|
comment: c.comment ?? "",
|
|
cpu: Number.isFinite(cpuRaw) ? cpuRaw : undefined,
|
|
memMb: Number.isFinite(memRaw) ? Math.round(memRaw / (1024 * 1024)) || undefined : undefined,
|
|
}
|
|
}
|
|
|
|
async function fetchContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
const [raw, envsRaw, mountsRaw] = await Promise.all([
|
|
client.get<RosContainer[]>("/container"),
|
|
client.get<RosContainerEnv[]>("/container/envs").catch(() => [] as RosContainerEnv[]),
|
|
client.get<RosContainerMount[]>("/container/mounts").catch(() => [] as RosContainerMount[]),
|
|
])
|
|
const list = Array.isArray(raw) ? raw : []
|
|
const envs = Array.isArray(envsRaw) ? envsRaw : []
|
|
const mounts = Array.isArray(mountsRaw) ? mountsRaw : []
|
|
return list.map((c, idx) => mapContainerRow(server, c, envs, mounts, idx))
|
|
} catch (err) {
|
|
if (isMissingPackage(err)) return []
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function listContainers(): Promise<ContainerLive[]> {
|
|
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
|
const results = await Promise.all(
|
|
enabledServers.map(async (server) => {
|
|
try {
|
|
return await fetchContainersForServer(server)
|
|
} catch {
|
|
return [] as ContainerLive[]
|
|
}
|
|
}),
|
|
)
|
|
return results.flat()
|
|
}
|
|
|
|
export async function listContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
|
|
try {
|
|
return await fetchContainersForServer(server)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function countContainers(): Promise<number> {
|
|
try {
|
|
const result = await Promise.race([
|
|
listContainers(),
|
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
|
|
])
|
|
if (!result) return 0
|
|
return result.length
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function encodeRosId(rosId: string): string {
|
|
return encodeURIComponent(rosId)
|
|
}
|
|
|
|
export async function startContainer(server: ServerRow, rosId: string): Promise<void> {
|
|
const client = MikrotikClient.fromServer(server)
|
|
await client.post("/container/start", { ".id": rosId })
|
|
}
|
|
|
|
export async function stopContainer(server: ServerRow, rosId: string): Promise<void> {
|
|
const client = MikrotikClient.fromServer(server)
|
|
await client.post("/container/stop", { ".id": rosId })
|
|
}
|
|
|
|
export async function restartContainer(server: ServerRow, rosId: string): Promise<void> {
|
|
await stopContainer(server, rosId)
|
|
await startContainer(server, rosId)
|
|
}
|
|
|
|
export async function removeContainer(server: ServerRow, rosId: string): Promise<void> {
|
|
const client = MikrotikClient.fromServer(server)
|
|
await client.delete(`/container/${encodeRosId(rosId)}`)
|
|
}
|
|
|
|
export async function getEnabledServerById(serverId: string | number) {
|
|
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
|
|
if (!Number.isFinite(id)) return null
|
|
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
|
}
|