188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
import {boundedCompleteCount,completeContainerCount} from "./complete-count.js"
|
|
import { eq } from "drizzle-orm"
|
|
import { db } from "../db/index.js"
|
|
import { servers } from "../db/schema.js"
|
|
import { MikrotikClient } from "./mikrotik.js"
|
|
import { containerRuntimeStatus } from "./container-runtime-status.js"
|
|
import { parseDbServerId } from "../utils/server-id.js"
|
|
|
|
type ServerRow = typeof servers.$inferSelect
|
|
|
|
interface RosContainer {
|
|
".id"?: string
|
|
name?: string
|
|
tag?: string
|
|
"remote-image"?: string
|
|
interface?: string
|
|
envlist?: string
|
|
envlists?: string
|
|
mounts?: string
|
|
mountlists?: string
|
|
cmd?: string
|
|
"start-on-boot"?: string
|
|
comment?: string
|
|
status?: string
|
|
"memory-high"?: string
|
|
"memory-current"?: string
|
|
cpu?: string
|
|
running?: string | boolean
|
|
stopped?: string | boolean
|
|
error?: string | boolean
|
|
failed?: string | boolean
|
|
extracting?: string | boolean
|
|
downloading?: string | boolean
|
|
starting?: string | boolean
|
|
stopping?: string | boolean
|
|
}
|
|
|
|
interface RosContainerEnv {
|
|
name?: string
|
|
list?: string
|
|
key?: string
|
|
value?: string
|
|
}
|
|
|
|
interface RosContainerMount {
|
|
name?: string
|
|
list?: string
|
|
src?: string
|
|
dst?: string
|
|
}
|
|
|
|
export type ContainerLiveStatus = "running" | "stopped" | "error" | "unknown"
|
|
|
|
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 | null
|
|
comment: string
|
|
uptime?: string
|
|
cpu?: number
|
|
memMb?: number
|
|
}
|
|
|
|
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: "", tag: "" }
|
|
}
|
|
|
|
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 envNames = new Set(splitCsv(c.envlists ?? c.envlist))
|
|
const mountNames = new Set(splitCsv(c.mountlists ?? c.mounts))
|
|
const envRows = envs.filter(e => envNames.has((e.list ?? e.name ?? "").trim()) && (e.key ?? "").trim())
|
|
const mountRows = mounts.filter((m) => mountNames.has((m.list ?? m.name ?? "").trim()) && (m.dst ?? "").trim())
|
|
const cpuRaw = Number(c.cpu?.trim() || NaN)
|
|
const memRaw = Number(c["memory-current"]?.trim() || NaN)
|
|
const boot = c["start-on-boot"]
|
|
return {
|
|
id: `${server.id}-${rosId}`,
|
|
rosId,
|
|
name,
|
|
serverId: String(server.id),
|
|
image,
|
|
tag,
|
|
status: containerRuntimeStatus({ ...c }),
|
|
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: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null,
|
|
comment: c.comment ?? "",
|
|
cpu: Number.isFinite(cpuRaw) && cpuRaw >= 0 && cpuRaw <= 100 ? cpuRaw : undefined,
|
|
memMb: Number.isFinite(memRaw) && memRaw >= 0 ? Math.round(memRaw / (1024 * 1024)) : undefined,
|
|
}
|
|
}
|
|
|
|
async function fetchContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
|
|
if (!server.enabled) throw new Error("Сервер выключен. Опрос контейнеров недоступен.")
|
|
return requireCompleteContainerCatalog(await listContainersState([server]), [server])
|
|
}
|
|
|
|
export async function listContainers(): Promise<ContainerLive[]> {
|
|
const selected = await db.select().from(servers).where(eq(servers.enabled, true))
|
|
return requireCompleteContainerCatalog(await listContainersState(selected), selected)
|
|
}
|
|
|
|
/** Legacy REST is fail-closed on partial reads. MCP retains the same observed
|
|
* rows together with explicit per-device unsupported/error evidence. */
|
|
function requireCompleteContainerCatalog(state: Awaited<ReturnType<typeof listContainersState>>, selected: ServerRow[]): ContainerLive[] {
|
|
for (const server of state.servers) {
|
|
if (server.availability === "error" || server.failures.length) throw new Error("Не удалось полностью получить контейнеры сервера " + selected.find(row => row.id === server.serverId)?.name + ": " + server.failures.map(f => f.error).join("; "))
|
|
}
|
|
return state.servers.flatMap(server => server.containers)
|
|
}
|
|
|
|
export async function listContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
|
|
return fetchContainersForServer(server)
|
|
}
|
|
|
|
export async function countContainers():Promise<number|undefined>{return boundedCompleteCount(()=>listContainersState(),completeContainerCount)}
|
|
|
|
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)
|
|
const { removeStoppedContainer } = await import("./container-removal.js")
|
|
await removeStoppedContainer(client, rosId)
|
|
}
|
|
|
|
export async function getEnabledServerById(serverId: string | number) {
|
|
const id = parseDbServerId(serverId)
|
|
if (id === null) return null
|
|
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
|
}
|
|
|
|
export async function listContainersState(selectedServers?: ServerRow[]) {
|
|
const { collectContainersState } = await import("./containers-state.js")
|
|
const enabledServers = selectedServers ?? await db.select().from(servers).where(eq(servers.enabled, true))
|
|
return collectContainersState(enabledServers, server => {
|
|
const client = MikrotikClient.fromServer(server)
|
|
return path => client.get<unknown>(path)
|
|
})
|
|
}
|