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

@@ -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