diff --git a/backend/migrations/043_agent_version.sql b/backend/migrations/043_agent_version.sql new file mode 100644 index 0000000..f77f524 --- /dev/null +++ b/backend/migrations/043_agent_version.sql @@ -0,0 +1,5 @@ +-- Track agent version per workstation +ALTER TABLE workstations ADD COLUMN IF NOT EXISTS agent_version VARCHAR(20); +-- Extend device types to include netup +ALTER TABLE workstation_devices DROP CONSTRAINT IF EXISTS workstation_devices_type_check; +ALTER TABLE workstation_devices ADD CONSTRAINT workstation_devices_type_check CHECK (type IN ('kkt', 'printer', 'netup')); diff --git a/backend/src/agent-ws.ts b/backend/src/agent-ws.ts index 0db839f..07d1992 100644 --- a/backend/src/agent-ws.ts +++ b/backend/src/agent-ws.ts @@ -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 void; reject: (e: Error) => void }>() + +export async function sendCommandAndWait(workstationId: string, command: object, timeoutMs = 10000): Promise { + 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 {} }) diff --git a/backend/src/app.ts b/backend/src/app.ts index a2c528d..ba31cc1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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() diff --git a/backend/src/routes/agent-release.ts b/backend/src/routes/agent-release.ts new file mode 100644 index 0000000..e72ff15 --- /dev/null +++ b/backend/src/routes/agent-release.ts @@ -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 diff --git a/backend/src/routes/workstations.ts b/backend/src/routes/workstations.ts index d4b8ca8..5cfe31b 100644 --- a/backend/src/routes/workstations.ts +++ b/backend/src/routes/workstations.ts @@ -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( + '/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( + '/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( + '/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( + '/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 diff --git a/src/lib/api.ts b/src/lib/api.ts index 9781b2a..b8249c0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -43,6 +43,10 @@ function toCamel(s: string): string { return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } +function toSnake(s: string): string { + return s.replace(/[A-Z]/g, c => '_' + c.toLowerCase()) +} + function transformKeys(val: unknown): unknown { if (Array.isArray(val)) return val.map(transformKeys) if (val && typeof val === 'object' && !(val instanceof Date)) { @@ -55,6 +59,18 @@ function transformKeys(val: unknown): unknown { return val } +function transformKeysToSnake(val: unknown): unknown { + if (Array.isArray(val)) return val.map(transformKeysToSnake) + if (val && typeof val === 'object' && !(val instanceof Date)) { + const result: Record = {} + for (const [k, v] of Object.entries(val as Record)) { + result[toSnake(k)] = transformKeysToSnake(v) + } + return result + } + return val +} + // ── Core request ───────────────────────────────────────────────────────────── async function req( @@ -72,7 +88,7 @@ async function req( method, headers: t ? { ...headers, Authorization: `Bearer ${t}` } : headers, credentials: 'include', - body: body !== undefined ? JSON.stringify(body) : undefined, + body: body !== undefined ? JSON.stringify(transformKeysToSnake(body)) : undefined, }) let res = await doFetch(token) @@ -335,6 +351,19 @@ export const api = { req('PATCH', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`, data), removeDevice: (slug: string, id: string, deviceId: string) => req('DELETE', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`), + testDevice: (slug: string, wsId: string, deviceId: string) => + req<{ ok: boolean; error?: string; message?: string }>('POST', `/api/hotels/${slug}/workstations/${wsId}/test-device/${deviceId}`), + sendUpdate: (slug: string, wsId: string) => + req<{ ok: true }>('POST', `/api/hotels/${slug}/workstations/${wsId}/update`), + updateAll: (slug: string) => + req<{ ok: true; sent: number }>('POST', `/api/hotels/${slug}/workstations/update-all`), + listPorts: (slug: string, wsId: string) => + req<{ ports: { port: string; description?: string }[] }>('GET', `/api/hotels/${slug}/workstations/${wsId}/ports`), + }, + + // ── Agents ──────────────────────────────────────────────────────────────── + agents: { + getLatestRelease: () => req<{ version: string; fileName: string; downloadUrl: string }>('GET', '/api/agents/latest-release'), }, // ── Hotels ──────────────────────────────────────────────────────────────── @@ -970,7 +999,7 @@ export interface ChatMessage { export interface WorkstationDevice { id: string workstationId: string - type: 'kkt' | 'printer' + type: 'kkt' | 'printer' | 'netup' name: string connection: 'usb' | 'network' | 'com' networkHost?: string @@ -989,6 +1018,7 @@ export interface Workstation { ipAddress: string | null isOnline: boolean lastSeen: string | null + agentVersion?: string createdAt: string devices: WorkstationDevice[] | null } diff --git a/src/pages/EquipmentPage.tsx b/src/pages/EquipmentPage.tsx index faf43f9..016fa9f 100644 --- a/src/pages/EquipmentPage.tsx +++ b/src/pages/EquipmentPage.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useCallback } from 'react' import { Monitor, Plus, Trash2, Pencil, Check, X, RefreshCw, Wifi, WifiOff, Printer, CreditCard, Usb, Network, Cable, - ChevronDown, ChevronRight, Copy, Clock, AlertCircle, + ChevronDown, ChevronRight, Copy, Clock, AlertCircle, Zap, + Download, ArrowUpCircle, } from 'lucide-react' import { cn } from '../lib/utils' import { api, type Workstation, type WorkstationDevice } from '../lib/api' @@ -21,6 +22,18 @@ const PURPOSE_LABELS: Record = { const TYPE_LABELS: Record = { kkt: 'ККТ (касса)', printer: 'Принтер', + netup: 'NetUp IPTV', +} + +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const da = pa[i] ?? 0 + const db = pb[i] ?? 0 + if (da !== db) return da - db + } + return 0 } function OnlineBadge({ online, lastSeen }: { online: boolean; lastSeen: string | null }) { @@ -164,19 +177,37 @@ function DeviceForm({ initial, onSave, onCancel, + slug, + wsId, + wsOnline, }: { initial?: Partial onSave: (data: Partial) => Promise onCancel: () => void + slug: string + wsId: string + wsOnline: boolean }) { - const [type, setType] = useState<'kkt' | 'printer'>(initial?.type ?? 'printer') + const [type, setType] = useState<'kkt' | 'printer' | 'netup'>(initial?.type ?? 'printer') const [name, setName] = useState(initial?.name ?? '') const [connection, setConnection] = useState<'usb' | 'network' | 'com'>(initial?.connection ?? 'network') const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '') const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100)) - const [comPort, setComPort] = useState(initial?.comPort ?? 'COM1') + const [comPort, setComPort] = useState(initial?.comPort ?? '') const [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt') const [saving, setSaving] = useState(false) + const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null) + const [portsLoading, setPortsLoading] = useState(false) + + // Загружаем порты когда выбирается COM + useEffect(() => { + if (connection !== 'com' || !wsOnline || availablePorts !== null) return + setPortsLoading(true) + api.workstations.listPorts(slug, wsId) + .then(r => setAvailablePorts(r.ports)) + .catch(() => setAvailablePorts([])) + .finally(() => setPortsLoading(false)) + }, [connection, wsOnline, slug, wsId, availablePorts]) const handleSave = async () => { if (!name.trim()) return @@ -200,11 +231,12 @@ function DeviceForm({
@@ -280,14 +312,37 @@ function DeviceForm({ {connection === 'com' && (
- - setComPort(e.target.value)} - className="w-full input text-sm font-mono" - placeholder="COM1" - /> -

Windows: COM1, COM3 и т.д. Linux: /dev/ttyUSB0

+ + {wsOnline && availablePorts && availablePorts.length > 0 ? ( + + ) : ( + setComPort(e.target.value)} + className="w-full input text-sm font-mono" + placeholder="COM1" + /> + )} + {wsOnline && availablePorts !== null && availablePorts.length === 0 && ( +

COM-порты не найдены на терминале

+ )} + {!wsOnline && ( +

Терминал офлайн — введите порт вручную (COM1, COM3…)

+ )}
)} @@ -312,10 +367,12 @@ function DeviceForm({ function WorkstationCard({ ws, slug, + latestRelease, onRefresh, }: { ws: Workstation slug: string + latestRelease: { version: string; downloadUrl: string } | null onRefresh: () => void }) { const [expanded, setExpanded] = useState(true) @@ -324,6 +381,17 @@ function WorkstationCard({ const [showPairModal, setShowPairModal] = useState(false) const [showAddDevice, setShowAddDevice] = useState(false) const [editDeviceId, setEditDeviceId] = useState(null) + const [updating, setUpdating] = useState(false) + const [updateMsg, setUpdateMsg] = useState(null) + const [testResults, setTestResults] = useState>({}) + const [testingDeviceId, setTestingDeviceId] = useState(null) + + const hasUpdate = !!( + ws.isOnline && + latestRelease && + ws.agentVersion && + compareVersions(latestRelease.version, ws.agentVersion) > 0 + ) const saveName = async () => { if (!name.trim() || name === ws.name) { setEditing(false); return } @@ -356,6 +424,36 @@ function WorkstationCard({ onRefresh() } + const sendUpdate = async () => { + if (updating) return + setUpdating(true) + setUpdateMsg(null) + try { + await api.workstations.sendUpdate(slug, ws.id) + setUpdateMsg('Команда отправлена') + } catch (err) { + setUpdateMsg(err instanceof Error ? err.message : 'Ошибка') + } finally { + setUpdating(false) + setTimeout(() => setUpdateMsg(null), 4000) + } + } + + const testDevice = async (deviceId: string) => { + if (!ws.isOnline) return + setTestResults(prev => ({ ...prev, [deviceId]: null })) + setTestingDeviceId(deviceId) + try { + const r = await api.workstations.testDevice(slug, ws.id, deviceId) + setTestResults(prev => ({ ...prev, [deviceId]: { ok: r.ok, msg: r.message ?? r.error ?? '' } })) + } catch (err) { + setTestResults(prev => ({ ...prev, [deviceId]: { ok: false, msg: err instanceof Error ? err.message : 'Ошибка' } })) + } finally { + setTestingDeviceId(null) + setTimeout(() => setTestResults(prev => ({ ...prev, [deviceId]: null })), 4000) + } + } + const devices = ws.devices ?? [] return ( @@ -392,14 +490,35 @@ function WorkstationCard({
)} - {ws.hostname && ( -

{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}

- )} +
+ {ws.hostname && ( +

{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}

+ )} + {ws.agentVersion && ( + v{ws.agentVersion} + )} +
+ {hasUpdate && ( + + )} + + {updateMsg && ( + {updateMsg} + )} + {!ws.agentId ? ( + )} + +
- -
- - -
+ {testResults[device.id] !== undefined && testResults[device.id] !== null && ( +
+ {testResults[device.id]!.ok ? : } + {testResults[device.id]!.msg || (testResults[device.id]!.ok ? 'Успешно' : 'Ошибка')} +
+ )} )} @@ -482,6 +636,9 @@ function WorkstationCard({ setShowAddDevice(false)} + slug={slug} + wsId={ws.id} + wsOnline={ws.isOnline} /> ) : ( +
+ {anyOutdated && ( + + )} + {latestRelease && ( + + + Скачать агент v{latestRelease.version} + + )} + +
{/* Stats */} @@ -631,7 +833,13 @@ export function EquipmentPage() { ) : (
{workstations.map(ws => ( - + ))}
)}