fix(api): показать частичные отказы каталога контейнеров
This commit is contained in:
1 parent
1e0fc4cd2d
commit
644ddb64b3
10 files changed
+191
-5
No files matched your search
@@ -26,9 +26,10 @@
|
|||||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||||
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
||||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
|
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
|
||||||
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file",
|
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers",
|
||||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
|
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
|
||||||
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts"
|
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
||||||
|
"test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||||
|
import { createContainersRoutes } from "./containers.js"
|
||||||
|
import { permissionForRequest } from "../lib/permissions.js"
|
||||||
|
const empty = {collectedAt:new Date().toISOString(),servers:[]}
|
||||||
|
let value:unknown=empty,failed=false
|
||||||
|
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
|
||||||
|
await app.register(createContainersRoutes({getState:async()=>{if(failed)throw new Error("DB unavailable");return value as typeof empty}}),{prefix:"/api"})
|
||||||
|
const read=()=>app.inject({method:"GET",url:"/api/containers/state"})
|
||||||
|
assert.equal((await read()).statusCode,200);assert.deepEqual((await read()).json().servers,[])
|
||||||
|
value={...empty,servers:[{serverId:1,collectedAt:empty.collectedAt,availability:"error",containers:[],failures:[{resource:"containers",error:"offline"}]}]}
|
||||||
|
assert.equal((await read()).statusCode,200);assert.equal((await read()).json().servers[0].availability,"error")
|
||||||
|
for(const bad of [{containers:[]},{...empty,servers:[{serverId:1}]},{...empty,collectedAt:"yesterday"}]){value=bad;assert.equal((await read()).statusCode,502)}
|
||||||
|
failed=true;assert.equal((await read()).statusCode,500)
|
||||||
|
assert.equal(permissionForRequest("GET","/api/containers/state"),"mm:dashboard:read")
|
||||||
|
assert.equal(permissionForRequest("POST","/api/servers/1/containers/start"),"mm:servers:write")
|
||||||
|
await app.close();console.log("Containers HTTP: explicit empty/partial error, invalid state 502, DB failure 500, existing permission contract")
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { containersStateSchema } from "@mmapp/contracts/containers"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import {
|
import {
|
||||||
getEnabledServerById,
|
getEnabledServerById,
|
||||||
listContainers,
|
listContainers,
|
||||||
|
listContainersState,
|
||||||
listContainersForServer,
|
listContainersForServer,
|
||||||
removeContainer,
|
removeContainer,
|
||||||
restartContainer,
|
restartContainer,
|
||||||
@@ -18,7 +20,13 @@ const RosIdBodySchema = z.object({
|
|||||||
type RosIdBody = z.infer<typeof RosIdBodySchema>
|
type RosIdBody = z.infer<typeof RosIdBodySchema>
|
||||||
type MutateFn = typeof startContainer
|
type MutateFn = typeof startContainer
|
||||||
|
|
||||||
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
|
export function createContainersRoutes(deps: { getState?: typeof listContainersState } = {}): FastifyPluginAsyncZod {
|
||||||
|
return async (app) => {
|
||||||
|
app.get("/containers/state", async (_req, reply) => {
|
||||||
|
const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)())
|
||||||
|
if (!state.success) return reply.status(502).send({ error: "Некорректное состояние каталога контейнеров" })
|
||||||
|
return reply.send(state.data)
|
||||||
|
})
|
||||||
app.get("/containers", async (_req, reply) => {
|
app.get("/containers", async (_req, reply) => {
|
||||||
const containers = await listContainers()
|
const containers = await listContainers()
|
||||||
return reply.send({ containers })
|
return reply.send({ containers })
|
||||||
@@ -55,4 +63,6 @@ const containersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
registerMutate("/servers/:id/containers/remove", removeContainer)
|
registerMutate("/servers/:id/containers/remove", removeContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default containersRoutes
|
}
|
||||||
|
|
||||||
|
export default createContainersRoutes()
|
||||||
@@ -213,3 +213,12 @@ export async function getEnabledServerById(serverId: string | number) {
|
|||||||
if (!Number.isFinite(id)) return null
|
if (!Number.isFinite(id)) return null
|
||||||
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listContainersState() {
|
||||||
|
const { collectContainersState } = await import("./containers-state.js")
|
||||||
|
const enabledServers = 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { readContainerServerState, collectContainersState } from "./containers-state.js"
|
||||||
|
import { MikrotikError } from "./mikrotik.js"
|
||||||
|
import { containersStateSchema } from "@mmapp/contracts/containers"
|
||||||
|
|
||||||
|
const server = { id: 1, name: "Router" } as Parameters<typeof readContainerServerState>[0]
|
||||||
|
const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-high": "0" }
|
||||||
|
const read = async (path: string): Promise<unknown> => path === "/container" ? [row] : path === "/container/envs" ? [{name:"e",key:"A",value:"B"}] : [{name:"m",src:"/disk",dst:"/data"}]
|
||||||
|
const good = await readContainerServerState(server, read)
|
||||||
|
assert.equal(good.availability, "available"); assert.equal(good.containers[0].status,"running")
|
||||||
|
assert.equal(good.containers[0].cpu,0);assert.equal(good.containers[0].memMb,0);assert.equal(good.containers[0].startOnBoot,true)
|
||||||
|
assert.deepEqual(good.containers[0].envs,[{key:"A",value:"B"}]);assert.deepEqual(good.containers[0].mounts,[{src:"/disk",dst:"/data"}])
|
||||||
|
const unknown = await readContainerServerState(server, async path => path === "/container" ? [{".id":"*B",name:"pending",status:"extracting"}] : [])
|
||||||
|
assert.equal(unknown.containers[0].status,"unknown");assert.equal(unknown.containers[0].startOnBoot,null);assert.equal(unknown.containers[0].image,"")
|
||||||
|
const empty = await readContainerServerState(server, async()=>[])
|
||||||
|
assert.equal(empty.availability,"available");assert.equal(empty.containers.length,0)
|
||||||
|
for (const raw of [{containers:[]},[{}],[row,row]]) {
|
||||||
|
const result = await readContainerServerState(server, async()=>raw)
|
||||||
|
assert.equal(result.availability,"error");assert.equal(result.failures[0].resource,"containers")
|
||||||
|
}
|
||||||
|
for(const status of [401,403,500]){const result=await readContainerServerState(server,async()=>{throw new MikrotikError(status,"/container","refused")});assert.equal(result.availability,"error")}
|
||||||
|
const unsupported=await readContainerServerState(server,async()=>{throw new MikrotikError(404,"/container","no such command")});assert.equal(unsupported.availability,"unsupported");assert.deepEqual(unsupported.failures,[])
|
||||||
|
for(const resource of ["envs","mounts"]){const result=await readContainerServerState(server,async path=>{if(path==="/container/"+resource)throw new Error("dependency refused");return read(path)});assert.equal(result.availability,"available");assert.equal(result.containers.length,1);assert.equal(result.failures[0].resource,resource)}
|
||||||
|
const badDependency=await readContainerServerState(server,async path=>path==="/container/envs"?{}:read(path));assert.equal(badDependency.failures[0].resource,"envs")
|
||||||
|
const aggregate=await collectContainersState([server,{...server,id:2}],srv=>srv.id===1?read:async()=>{throw new Error("offline")});assert.equal(aggregate.servers[0].containers.length,1);assert.equal(aggregate.servers[1].availability,"error")
|
||||||
|
assert.equal((await collectContainersState([],()=>read)).servers.length,0)
|
||||||
|
for (const value of [
|
||||||
|
{...aggregate,servers:[good,good]},
|
||||||
|
{...aggregate,servers:[{...good,serverId:2}]},
|
||||||
|
{...aggregate,servers:[{...good,availability:"error",failures:[]}]},
|
||||||
|
{...aggregate,servers:[{...good,containers:[good.containers[0],good.containers[0]]}]},
|
||||||
|
{...aggregate,servers:[{...unsupported,failures:[{resource:"envs",error:"x"}]}]},
|
||||||
|
]) assert.equal(containersStateSchema.safeParse(value).success,false)
|
||||||
|
console.log("Containers state: independent devices, malformed/duplicate IDs, unknown status/boot, known empty, missing package, partial dependencies, zeros and envelope validation; no DB/RouterOS")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { containersStateSchema, containerServerStateSchema, type ContainersState, type ContainerServerState } from "@mmapp/contracts/containers"
|
||||||
|
import { mapContainerRow } from "./containers-live.js"
|
||||||
|
import { MikrotikError } from "./mikrotik.js"
|
||||||
|
|
||||||
|
type ServerRow = Parameters<typeof mapContainerRow>[0]
|
||||||
|
type Reader = (path: string) => Promise<unknown>
|
||||||
|
const rawSchema = z.array(z.object({
|
||||||
|
".id": z.string().min(1), name: z.string().optional(), tag: z.string().optional(), "remote-image": z.string().optional(),
|
||||||
|
interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(),
|
||||||
|
cmd: z.string().optional(), "start-on-boot": z.string().optional(), comment: z.string().optional(),
|
||||||
|
status: z.string().optional(), "memory-high": z.string().optional(), cpu: z.string().optional(),
|
||||||
|
})).superRefine((rows, ctx) => {
|
||||||
|
if (new Set(rows.map(r => r[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" })
|
||||||
|
})
|
||||||
|
const envSchema = z.array(z.object({ name: z.string().optional(), key: z.string().optional(), value: z.string().optional() }))
|
||||||
|
const mountSchema = z.array(z.object({ name: z.string().optional(), src: z.string().optional(), dst: z.string().optional() }))
|
||||||
|
const errorMessage = (error: unknown) => error instanceof z.ZodError ? "Некорректный ответ RouterOS" : (error instanceof Error ? error.message.slice(0, 500) : "Ошибка RouterOS")
|
||||||
|
function missingPackage(error: unknown) {
|
||||||
|
return error instanceof MikrotikError && (error.statusCode === 404 || /no such command|unknown command/i.test(error.body))
|
||||||
|
}
|
||||||
|
export async function readContainerServerState(server: ServerRow, read: Reader): Promise<ContainerServerState> {
|
||||||
|
const base = { serverId: server.id, collectedAt: new Date().toISOString(), containers: [], failures: [] }
|
||||||
|
let raw: z.infer<typeof rawSchema>
|
||||||
|
try { raw = rawSchema.parse(await read("/container")) }
|
||||||
|
catch (error) {
|
||||||
|
return containerServerStateSchema.parse({ ...base, collectedAt: new Date().toISOString(), availability: missingPackage(error) ? "unsupported" : "error",
|
||||||
|
failures: missingPackage(error) ? [] : [{ resource: "containers", error: errorMessage(error) }] })
|
||||||
|
}
|
||||||
|
const collectedAt = new Date().toISOString()
|
||||||
|
const [envs, mounts] = await Promise.allSettled([
|
||||||
|
read("/container/envs").then(value => envSchema.parse(value)),
|
||||||
|
read("/container/mounts").then(value => mountSchema.parse(value)),
|
||||||
|
])
|
||||||
|
const failures: ContainerServerState["failures"] = []
|
||||||
|
if (envs.status === "rejected") failures.push({ resource: "envs", error: errorMessage(envs.reason) })
|
||||||
|
if (mounts.status === "rejected") failures.push({ resource: "mounts", error: errorMessage(mounts.reason) })
|
||||||
|
const containers = raw.map((row, index) => {
|
||||||
|
const mapped = mapContainerRow(server, row, envs.status === "fulfilled" ? envs.value : [], mounts.status === "fulfilled" ? mounts.value : [], index)
|
||||||
|
const status = (row.status ?? "").toLowerCase()
|
||||||
|
const boot = row["start-on-boot"]
|
||||||
|
const mem = Number.parseInt(row["memory-high"] ?? "", 10)
|
||||||
|
return { ...mapped, image: row["remote-image"] ? mapped.image : "", tag: row["remote-image"] ? mapped.tag : "",
|
||||||
|
status: status === "running" || status === "stopped" ? status : status === "error" || status === "failed" ? "error" : "unknown",
|
||||||
|
startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null,
|
||||||
|
cpu: mapped.cpu !== undefined && mapped.cpu >= 0 && mapped.cpu <= 100 ? mapped.cpu : undefined,
|
||||||
|
memMb: Number.isFinite(mem) && mem >= 0 ? Math.round(mem / (1024 * 1024)) : undefined }
|
||||||
|
})
|
||||||
|
return containerServerStateSchema.parse({ serverId: server.id, collectedAt, availability: "available", containers, failures })
|
||||||
|
}
|
||||||
|
export async function collectContainersState(servers: ServerRow[], makeReader: (server: ServerRow) => Reader): Promise<ContainersState> {
|
||||||
|
const states = await Promise.all(servers.map(server => readContainerServerState(server, makeReader(server))))
|
||||||
|
return containersStateSchema.parse({ collectedAt: new Date().toISOString(), servers: states })
|
||||||
|
}
|
||||||
+10
-1
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.8; пользователи приняты локально, далее контейнеры |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.9; пользователи приняты локально, state API контейнеров подготовлен |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01)
|
||||||
|
|
||||||
|
- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные.
|
||||||
|
- Загрузка envs/mounts независима, partial failures явно включены в контракт. Неизвестные status/startOnBoot не становятся stopped/false; отсутствующий образ не подменяется именем контейнера; нулевые CPU/RAM сохранены.
|
||||||
|
- Runtime контракт отклоняет отсутствующие/дубли RouterOS ID, чужой serverId, неверные состояния/даты и дубли устройств/ошибок. Новый reader не выдаёт синтетический rosId для сетевого действия.
|
||||||
|
- Старые /containers и /servers/:id/containers, mapper и сетевые операции сохранены для совместимости. RBAC соответствует существующим правилам: каталог dashboard read, команды server write; новых разрешений не введено.
|
||||||
|
- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки.
|
||||||
|
- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон.
|
||||||
|
|
||||||
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
|
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
|
||||||
|
|
||||||
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
|
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
|
||||||
|
|||||||
@@ -106,3 +106,13 @@
|
|||||||
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
|
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
|
||||||
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
|
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01)
|
||||||
|
|
||||||
|
- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные.
|
||||||
|
- Загрузка envs/mounts независима, partial failures явно включены в контракт. Неизвестные status/startOnBoot не становятся stopped/false; отсутствующий образ не подменяется именем контейнера; нулевые CPU/RAM сохранены.
|
||||||
|
- Runtime контракт отклоняет отсутствующие/дубли RouterOS ID, чужой serverId, неверные состояния/даты и дубли устройств/ошибок. Новый reader не выдаёт синтетический rosId для сетевого действия.
|
||||||
|
- Старые /containers и /servers/:id/containers, mapper и сетевые операции сохранены для совместимости. RBAC соответствует существующим правилам: каталог dashboard read, команды server write; новых разрешений не введено.
|
||||||
|
- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки.
|
||||||
|
- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон.
|
||||||
|
|
||||||
@@ -57,6 +57,10 @@
|
|||||||
"./statistics": {
|
"./statistics": {
|
||||||
"types": "./dist/statistics.d.ts",
|
"types": "./dist/statistics.d.ts",
|
||||||
"default": "./dist/statistics.js"
|
"default": "./dist/statistics.js"
|
||||||
|
},
|
||||||
|
"./containers": {
|
||||||
|
"types": "./dist/containers.d.ts",
|
||||||
|
"default": "./dist/containers.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const serverId = z.number().int().positive().safe()
|
||||||
|
const time = z.string().datetime({ offset: true })
|
||||||
|
export const containerStateRowSchema = z.object({
|
||||||
|
id: z.string().min(1), rosId: z.string().min(1), name: z.string().min(1),
|
||||||
|
serverId: z.string().regex(/^[1-9]\d*$/),
|
||||||
|
image: z.string(), tag: z.string(), status: z.enum(["running", "stopped", "error", "unknown"]),
|
||||||
|
envs: z.array(z.object({ key: z.string().min(1), value: z.string() })),
|
||||||
|
mounts: z.array(z.object({ dst: z.string().min(1), src: z.string().optional() })),
|
||||||
|
interfaces: z.array(z.string().min(1)), cmd: z.string().optional(),
|
||||||
|
startOnBoot: z.boolean().nullable(), comment: z.string(), uptime: z.string().optional(),
|
||||||
|
cpu: z.number().finite().min(0).max(100).optional(), memMb: z.number().finite().nonnegative().optional(),
|
||||||
|
})
|
||||||
|
export const containerServerStateSchema = z.object({
|
||||||
|
serverId, collectedAt: time, availability: z.enum(["available", "unsupported", "error"]),
|
||||||
|
containers: z.array(containerStateRowSchema),
|
||||||
|
failures: z.array(z.object({ resource: z.enum(["containers", "envs", "mounts"]), error: z.string().min(1) })),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const row of value.containers) {
|
||||||
|
if (row.serverId !== String(value.serverId) || row.id !== value.serverId + "-" + row.rosId || ids.has(row.rosId)) ctx.addIssue({ code: "custom", message: "Контейнеры не соответствуют устройству или содержат дубли" })
|
||||||
|
ids.add(row.rosId)
|
||||||
|
}
|
||||||
|
const resources = value.failures.map(f => f.resource)
|
||||||
|
if (new Set(resources).size !== resources.length) ctx.addIssue({ code: "custom", message: "Дубли ошибок ресурсов" })
|
||||||
|
if (value.availability !== "available" && value.containers.length) ctx.addIssue({ code: "custom", message: "Неуспешная загрузка содержит контейнеры" })
|
||||||
|
if (value.availability === "error" && !resources.includes("containers") || value.availability === "available" && resources.includes("containers") || value.availability === "unsupported" && resources.length) ctx.addIssue({ code: "custom", message: "Состояние не соответствует ошибкам" })
|
||||||
|
})
|
||||||
|
export const containersStateSchema = z.object({
|
||||||
|
collectedAt: time, servers: z.array(containerServerStateSchema),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (new Set(value.servers.map(s => s.serverId)).size !== value.servers.length) ctx.addIssue({ code: "custom", message: "Дубли устройств" })
|
||||||
|
})
|
||||||
|
export type ContainerStateRow = z.infer<typeof containerStateRowSchema>
|
||||||
|
export type ContainerServerState = z.infer<typeof containerServerStateSchema>
|
||||||
|
export type ContainersState = z.infer<typeof containersStateSchema>
|
||||||
Reference in new issue
Block a user