feat: agent version tracking, update button, device test, COM port list

- Migration 043: agent_version in workstations, netup device type
- agent-ws: sendCommandAndWait for async commands, save version on hello
- workstations route: list-ports, test-device, update, update-all endpoints
- agent-release route: GET /api/agents/latest-release reads latest.yml
- app.ts: serve /agent-updates/ static files
- api.ts: fix camelCase→snake_case for outgoing requests (fixes null:null)
- api.ts: new methods testDevice, sendUpdate, updateAll, listPorts
- EquipmentPage: version badge, update button, test (zap) button per device
- EquipmentPage: COM-port dropdown loaded from agent when online

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 13:05:23 +03:00
parent b2b2034f4c
commit 0cce5f566f
7 changed files with 427 additions and 53 deletions

View File

@@ -6,6 +6,7 @@
import { FastifyInstance, FastifyPluginAsync } from 'fastify'
import type { SocketStream } from '@fastify/websocket'
import { WebSocket } from 'ws'
import { randomUUID } from 'crypto'
import { db } from './db'
interface AgentSocket extends WebSocket {
@@ -34,6 +35,26 @@ export async function sendCommand(
return { ok: true }
}
const pendingResponses = new Map<string, { resolve: (d: unknown) => void; reject: (e: Error) => void }>()
export async function sendCommandAndWait(workstationId: string, command: object, timeoutMs = 10000): Promise<unknown> {
const cmdId = randomUUID()
const socket = agentSockets.get(workstationId)
if (!socket || socket.readyState !== WebSocket.OPEN) {
throw new Error('Агент не подключён или офлайн')
}
return new Promise((resolve, reject) => {
pendingResponses.set(cmdId, { resolve, reject })
socket.send(JSON.stringify({ ...command, command_id: cmdId }))
setTimeout(() => {
if (pendingResponses.has(cmdId)) {
pendingResponses.delete(cmdId)
reject(new Error('Таймаут ответа агента (10с)'))
}
}, timeoutMs)
})
}
export function registerAgentWs(fastify: FastifyInstance) {
// @ts-ignore — fastify.websocketServer добавляется плагином @fastify/websocket
const wss = fastify.websocketServer
@@ -115,9 +136,17 @@ export const setupAgentWsRoute: FastifyPluginAsync = async (fastify) => {
// Обрабатываем ответы агента (результаты команд)
if (msg.type === 'result') {
fastify.log.info({ workstation: wsId, result: msg }, 'Agent command result')
if (msg.command_id && pendingResponses.has(msg.command_id)) {
const p = pendingResponses.get(msg.command_id)!
pendingResponses.delete(msg.command_id)
p.resolve(msg)
}
}
if (msg.type === 'hello') {
console.log(`[agent-ws] Hello from ${msg.agent_id} (${wsId})`)
if (msg.version) {
db.query('UPDATE workstations SET agent_version = $1 WHERE id = $2', [msg.version, wsId]).catch(() => {})
}
}
} catch {}
})

View File

@@ -35,6 +35,7 @@ import scheduleRoutes from './routes/schedule'
import loyaltyRoutes from './routes/loyalty'
import chatRoutes from './routes/chat'
import workstationRoutes from './routes/workstations'
import agentReleaseRoutes from './routes/agent-release'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs'
@@ -53,6 +54,8 @@ export async function buildApp() {
await fastify.register(multipart)
const uploadsDir = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads')
await fastify.register(staticFiles, { root: uploadsDir, prefix: '/uploads/' })
const agentUpdatesDir = process.env.AGENT_UPDATES_DIR ?? join(process.cwd(), '..', 'agent-updates')
await fastify.register(staticFiles, { root: agentUpdatesDir, prefix: '/agent-updates/', decorateReply: false })
// ── WebSocket ──────────────────────────────────────────────────────────────
await fastify.register(fastifyWebsocket)
@@ -118,6 +121,7 @@ export async function buildApp() {
await fastify.register(loyaltyRoutes)
await fastify.register(chatRoutes)
await fastify.register(workstationRoutes)
await fastify.register(agentReleaseRoutes)
await fastify.register(setupAgentWsRoute)
startJobs()

View File

@@ -0,0 +1,23 @@
import { FastifyPluginAsync } from 'fastify'
import { readFile } from 'fs/promises'
import { join } from 'path'
const agentRelease: FastifyPluginAsync = async (fastify) => {
fastify.get('/api/agents/latest-release', { onRequest: [fastify.authenticate] }, async (_req, reply) => {
const updatesDir = process.env.AGENT_UPDATES_DIR ?? join(process.cwd(), '..', 'agent-updates')
try {
const yaml = await readFile(join(updatesDir, 'latest.yml'), 'utf8')
// Parse version: line like "version: 1.0.6"
const versionMatch = yaml.match(/^version:\s*(.+)$/m)
const pathMatch = yaml.match(/^path:\s*(.+)$/m)
if (!versionMatch) return reply.code(404).send({ error: 'latest.yml not found or invalid' })
const version = versionMatch[1].trim()
const fileName = pathMatch?.[1].trim() ?? `HotelSync Agent Setup ${version}.exe`
return { version, fileName, downloadUrl: `/agent-updates/${encodeURIComponent(fileName)}` }
} catch {
return reply.code(404).send({ error: 'No release available' })
}
})
}
export default agentRelease

View File

@@ -16,6 +16,7 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import crypto from 'crypto'
import { sendCommand, sendCommandAndWait } from '../agent-ws'
type SlugParam = { Params: { slug: string } }
type WsParam = { Params: { slug: string; id: string } }
@@ -258,6 +259,80 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
return reply.code(204).send()
},
)
// ── GET /api/hotels/:slug/workstations/:id/ports ────────────────────────────
fastify.get<WsParam>(
'/api/hotels/:slug/workstations/:id/ports',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { id } = req.params
try {
const result = await sendCommandAndWait(id, { type: 'list_ports' }, 8000) as { ok: boolean; ports?: { port: string; description?: string }[]; error?: string }
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Агент недоступен' })
return { ports: result.ports ?? [] }
} catch (err) {
return reply.code(502).send({ error: err instanceof Error ? err.message : 'Ошибка' })
}
},
)
// ── POST /api/hotels/:slug/workstations/:id/test-device/:deviceId ───────────
fastify.post<DevParam>(
'/api/hotels/:slug/workstations/:id/test-device/:deviceId',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { id, deviceId } = req.params
const { rows: [device] } = await db.query('SELECT * FROM workstation_devices WHERE id = $1', [deviceId])
if (!device) return reply.code(404).send({ error: 'Device not found' })
try {
const result = await sendCommandAndWait(id, {
type: 'test_device',
device: {
id: device.id,
type: device.type,
connection: device.connection,
network_host: device.network_host,
network_port: device.network_port,
com_port: device.com_port,
},
}) as { ok: boolean; error?: string; message?: string }
return result
} catch (err) {
return reply.code(502).send({ ok: false, error: err instanceof Error ? err.message : 'Ошибка' })
}
},
)
// ── POST /api/hotels/:slug/workstations/:id/update ──────────────────────────
fastify.post<WsParam>(
'/api/hotels/:slug/workstations/:id/update',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { id } = req.params
const r = await sendCommand(id, { type: 'update_now' })
if (!r.ok) return reply.code(502).send({ error: r.error ?? 'Агент недоступен' })
return { ok: true }
},
)
// ── POST /api/hotels/:slug/workstations/update-all ──────────────────────────
fastify.post<SlugParam>(
'/api/hotels/:slug/workstations/update-all',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { slug } = req.params
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
const { rows } = await db.query('SELECT id FROM workstations WHERE hotel_id = $1 AND is_online = true', [hotelId])
let sent = 0
for (const ws of rows) {
const r = await sendCommand(ws.id, { type: 'update_now' })
if (r.ok) sent++
}
return { ok: true, sent }
},
)
}
export default workstationRoutes