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:
5
backend/migrations/043_agent_version.sql
Normal file
5
backend/migrations/043_agent_version.sql
Normal file
@@ -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'));
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import { FastifyInstance, FastifyPluginAsync } from 'fastify'
|
import { FastifyInstance, FastifyPluginAsync } from 'fastify'
|
||||||
import type { SocketStream } from '@fastify/websocket'
|
import type { SocketStream } from '@fastify/websocket'
|
||||||
import { WebSocket } from 'ws'
|
import { WebSocket } from 'ws'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
import { db } from './db'
|
import { db } from './db'
|
||||||
|
|
||||||
interface AgentSocket extends WebSocket {
|
interface AgentSocket extends WebSocket {
|
||||||
@@ -34,6 +35,26 @@ export async function sendCommand(
|
|||||||
return { ok: true }
|
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) {
|
export function registerAgentWs(fastify: FastifyInstance) {
|
||||||
// @ts-ignore — fastify.websocketServer добавляется плагином @fastify/websocket
|
// @ts-ignore — fastify.websocketServer добавляется плагином @fastify/websocket
|
||||||
const wss = fastify.websocketServer
|
const wss = fastify.websocketServer
|
||||||
@@ -115,9 +136,17 @@ export const setupAgentWsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Обрабатываем ответы агента (результаты команд)
|
// Обрабатываем ответы агента (результаты команд)
|
||||||
if (msg.type === 'result') {
|
if (msg.type === 'result') {
|
||||||
fastify.log.info({ workstation: wsId, result: msg }, 'Agent command 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') {
|
if (msg.type === 'hello') {
|
||||||
console.log(`[agent-ws] Hello from ${msg.agent_id} (${wsId})`)
|
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 {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import scheduleRoutes from './routes/schedule'
|
|||||||
import loyaltyRoutes from './routes/loyalty'
|
import loyaltyRoutes from './routes/loyalty'
|
||||||
import chatRoutes from './routes/chat'
|
import chatRoutes from './routes/chat'
|
||||||
import workstationRoutes from './routes/workstations'
|
import workstationRoutes from './routes/workstations'
|
||||||
|
import agentReleaseRoutes from './routes/agent-release'
|
||||||
import { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ export async function buildApp() {
|
|||||||
await fastify.register(multipart)
|
await fastify.register(multipart)
|
||||||
const uploadsDir = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads')
|
const uploadsDir = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads')
|
||||||
await fastify.register(staticFiles, { root: uploadsDir, prefix: '/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 ──────────────────────────────────────────────────────────────
|
// ── WebSocket ──────────────────────────────────────────────────────────────
|
||||||
await fastify.register(fastifyWebsocket)
|
await fastify.register(fastifyWebsocket)
|
||||||
@@ -118,6 +121,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(loyaltyRoutes)
|
await fastify.register(loyaltyRoutes)
|
||||||
await fastify.register(chatRoutes)
|
await fastify.register(chatRoutes)
|
||||||
await fastify.register(workstationRoutes)
|
await fastify.register(workstationRoutes)
|
||||||
|
await fastify.register(agentReleaseRoutes)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
23
backend/src/routes/agent-release.ts
Normal file
23
backend/src/routes/agent-release.ts
Normal 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
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { sendCommand, sendCommandAndWait } from '../agent-ws'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type WsParam = { Params: { slug: string; id: string } }
|
type WsParam = { Params: { slug: string; id: string } }
|
||||||
@@ -258,6 +259,80 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(204).send()
|
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
|
export default workstationRoutes
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ function toCamel(s: string): string {
|
|||||||
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
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 {
|
function transformKeys(val: unknown): unknown {
|
||||||
if (Array.isArray(val)) return val.map(transformKeys)
|
if (Array.isArray(val)) return val.map(transformKeys)
|
||||||
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
||||||
@@ -55,6 +59,18 @@ function transformKeys(val: unknown): unknown {
|
|||||||
return val
|
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<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||||
|
result[toSnake(k)] = transformKeysToSnake(v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
// ── Core request ─────────────────────────────────────────────────────────────
|
// ── Core request ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function req<T>(
|
async function req<T>(
|
||||||
@@ -72,7 +88,7 @@ async function req<T>(
|
|||||||
method,
|
method,
|
||||||
headers: t ? { ...headers, Authorization: `Bearer ${t}` } : headers,
|
headers: t ? { ...headers, Authorization: `Bearer ${t}` } : headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(transformKeysToSnake(body)) : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
let res = await doFetch(token)
|
let res = await doFetch(token)
|
||||||
@@ -335,6 +351,19 @@ export const api = {
|
|||||||
req<WorkstationDevice>('PATCH', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`, data),
|
req<WorkstationDevice>('PATCH', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`, data),
|
||||||
removeDevice: (slug: string, id: string, deviceId: string) =>
|
removeDevice: (slug: string, id: string, deviceId: string) =>
|
||||||
req<void>('DELETE', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`),
|
req<void>('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 ────────────────────────────────────────────────────────────────
|
// ── Hotels ────────────────────────────────────────────────────────────────
|
||||||
@@ -970,7 +999,7 @@ export interface ChatMessage {
|
|||||||
export interface WorkstationDevice {
|
export interface WorkstationDevice {
|
||||||
id: string
|
id: string
|
||||||
workstationId: string
|
workstationId: string
|
||||||
type: 'kkt' | 'printer'
|
type: 'kkt' | 'printer' | 'netup'
|
||||||
name: string
|
name: string
|
||||||
connection: 'usb' | 'network' | 'com'
|
connection: 'usb' | 'network' | 'com'
|
||||||
networkHost?: string
|
networkHost?: string
|
||||||
@@ -989,6 +1018,7 @@ export interface Workstation {
|
|||||||
ipAddress: string | null
|
ipAddress: string | null
|
||||||
isOnline: boolean
|
isOnline: boolean
|
||||||
lastSeen: string | null
|
lastSeen: string | null
|
||||||
|
agentVersion?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
devices: WorkstationDevice[] | null
|
devices: WorkstationDevice[] | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import {
|
import {
|
||||||
Monitor, Plus, Trash2, Pencil, Check, X, RefreshCw,
|
Monitor, Plus, Trash2, Pencil, Check, X, RefreshCw,
|
||||||
Wifi, WifiOff, Printer, CreditCard, Usb, Network, Cable,
|
Wifi, WifiOff, Printer, CreditCard, Usb, Network, Cable,
|
||||||
ChevronDown, ChevronRight, Copy, Clock, AlertCircle,
|
ChevronDown, ChevronRight, Copy, Clock, AlertCircle, Zap,
|
||||||
|
Download, ArrowUpCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { api, type Workstation, type WorkstationDevice } from '../lib/api'
|
import { api, type Workstation, type WorkstationDevice } from '../lib/api'
|
||||||
@@ -21,6 +22,18 @@ const PURPOSE_LABELS: Record<string, string> = {
|
|||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
kkt: 'ККТ (касса)',
|
kkt: 'ККТ (касса)',
|
||||||
printer: 'Принтер',
|
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 }) {
|
function OnlineBadge({ online, lastSeen }: { online: boolean; lastSeen: string | null }) {
|
||||||
@@ -164,19 +177,37 @@ function DeviceForm({
|
|||||||
initial,
|
initial,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
slug,
|
||||||
|
wsId,
|
||||||
|
wsOnline,
|
||||||
}: {
|
}: {
|
||||||
initial?: Partial<WorkstationDevice>
|
initial?: Partial<WorkstationDevice>
|
||||||
onSave: (data: Partial<WorkstationDevice>) => Promise<void>
|
onSave: (data: Partial<WorkstationDevice>) => Promise<void>
|
||||||
onCancel: () => void
|
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 [name, setName] = useState(initial?.name ?? '')
|
||||||
const [connection, setConnection] = useState<'usb' | 'network' | 'com'>(initial?.connection ?? 'network')
|
const [connection, setConnection] = useState<'usb' | 'network' | 'com'>(initial?.connection ?? 'network')
|
||||||
const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '')
|
const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '')
|
||||||
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
|
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 [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt')
|
||||||
const [saving, setSaving] = useState(false)
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
@@ -200,11 +231,12 @@ function DeviceForm({
|
|||||||
<label className="block text-xs font-medium text-slate-500 mb-1">Тип устройства</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1">Тип устройства</label>
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={e => setType(e.target.value as 'kkt' | 'printer')}
|
onChange={e => setType(e.target.value as 'kkt' | 'printer' | 'netup')}
|
||||||
className="w-full input text-sm"
|
className="w-full input text-sm"
|
||||||
>
|
>
|
||||||
<option value="kkt">ККТ (касса)</option>
|
<option value="kkt">ККТ (касса)</option>
|
||||||
<option value="printer">Принтер</option>
|
<option value="printer">Принтер</option>
|
||||||
|
<option value="netup">NetUp IPTV</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -280,14 +312,37 @@ function DeviceForm({
|
|||||||
|
|
||||||
{connection === 'com' && (
|
{connection === 'com' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">COM-порт</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1">
|
||||||
<input
|
COM-порт
|
||||||
value={comPort}
|
{portsLoading && <span className="ml-2 text-slate-400 font-normal">загрузка...</span>}
|
||||||
onChange={e => setComPort(e.target.value)}
|
</label>
|
||||||
className="w-full input text-sm font-mono"
|
{wsOnline && availablePorts && availablePorts.length > 0 ? (
|
||||||
placeholder="COM1"
|
<select
|
||||||
/>
|
value={comPort}
|
||||||
<p className="text-xs text-slate-400 mt-1">Windows: COM1, COM3 и т.д. Linux: /dev/ttyUSB0</p>
|
onChange={e => setComPort(e.target.value)}
|
||||||
|
className="w-full input text-sm font-mono"
|
||||||
|
>
|
||||||
|
<option value="">— выберите порт —</option>
|
||||||
|
{availablePorts.map(p => (
|
||||||
|
<option key={p.port} value={p.port}>
|
||||||
|
{p.port}{p.description ? ` — ${p.description}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={comPort}
|
||||||
|
onChange={e => setComPort(e.target.value)}
|
||||||
|
className="w-full input text-sm font-mono"
|
||||||
|
placeholder="COM1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{wsOnline && availablePorts !== null && availablePorts.length === 0 && (
|
||||||
|
<p className="text-xs text-amber-500 mt-1">COM-порты не найдены на терминале</p>
|
||||||
|
)}
|
||||||
|
{!wsOnline && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Терминал офлайн — введите порт вручную (COM1, COM3…)</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -312,10 +367,12 @@ function DeviceForm({
|
|||||||
function WorkstationCard({
|
function WorkstationCard({
|
||||||
ws,
|
ws,
|
||||||
slug,
|
slug,
|
||||||
|
latestRelease,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
ws: Workstation
|
ws: Workstation
|
||||||
slug: string
|
slug: string
|
||||||
|
latestRelease: { version: string; downloadUrl: string } | null
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
@@ -324,6 +381,17 @@ function WorkstationCard({
|
|||||||
const [showPairModal, setShowPairModal] = useState(false)
|
const [showPairModal, setShowPairModal] = useState(false)
|
||||||
const [showAddDevice, setShowAddDevice] = useState(false)
|
const [showAddDevice, setShowAddDevice] = useState(false)
|
||||||
const [editDeviceId, setEditDeviceId] = useState<string | null>(null)
|
const [editDeviceId, setEditDeviceId] = useState<string | null>(null)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [updateMsg, setUpdateMsg] = useState<string | null>(null)
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; msg: string } | null>>({})
|
||||||
|
const [testingDeviceId, setTestingDeviceId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const hasUpdate = !!(
|
||||||
|
ws.isOnline &&
|
||||||
|
latestRelease &&
|
||||||
|
ws.agentVersion &&
|
||||||
|
compareVersions(latestRelease.version, ws.agentVersion) > 0
|
||||||
|
)
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
if (!name.trim() || name === ws.name) { setEditing(false); return }
|
if (!name.trim() || name === ws.name) { setEditing(false); return }
|
||||||
@@ -356,6 +424,36 @@ function WorkstationCard({
|
|||||||
onRefresh()
|
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 ?? []
|
const devices = ws.devices ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -392,14 +490,35 @@ function WorkstationCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ws.hostname && (
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}</p>
|
{ws.hostname && (
|
||||||
)}
|
<p className="text-xs text-slate-500 dark:text-slate-400">{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}</p>
|
||||||
|
)}
|
||||||
|
{ws.agentVersion && (
|
||||||
|
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">v{ws.agentVersion}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<OnlineBadge online={ws.isOnline} lastSeen={ws.lastSeen} />
|
<OnlineBadge online={ws.isOnline} lastSeen={ws.lastSeen} />
|
||||||
|
|
||||||
|
{hasUpdate && (
|
||||||
|
<button
|
||||||
|
onClick={sendUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 transition-colors disabled:opacity-50"
|
||||||
|
title={`Обновить до v${latestRelease?.version}`}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle size={12} />
|
||||||
|
{updating ? '...' : `Обновить до v${latestRelease?.version}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateMsg && (
|
||||||
|
<span className="text-xs text-slate-500">{updateMsg}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{!ws.agentId ? (
|
{!ws.agentId ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPairModal(true)}
|
onClick={() => setShowPairModal(true)}
|
||||||
@@ -440,39 +559,74 @@ function WorkstationCard({
|
|||||||
initial={device}
|
initial={device}
|
||||||
onSave={data => updateDevice(device.id, data)}
|
onSave={data => updateDevice(device.id, data)}
|
||||||
onCancel={() => setEditDeviceId(null)}
|
onCancel={() => setEditDeviceId(null)}
|
||||||
|
slug={slug}
|
||||||
|
wsId={ws.id}
|
||||||
|
wsOnline={ws.isOnline}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 group">
|
<div>
|
||||||
<div className={cn(
|
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 group">
|
||||||
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
|
<div className={cn(
|
||||||
device.type === 'kkt' ? 'bg-violet-100 dark:bg-violet-900/40' : 'bg-blue-100 dark:bg-blue-900/40',
|
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
|
||||||
)}>
|
device.type === 'kkt' ? 'bg-violet-100 dark:bg-violet-900/40' : 'bg-blue-100 dark:bg-blue-900/40',
|
||||||
{device.type === 'kkt'
|
)}>
|
||||||
? <CreditCard size={13} className="text-violet-600 dark:text-violet-400" />
|
{device.type === 'kkt'
|
||||||
: <Printer size={13} className="text-blue-600 dark:text-blue-400" />
|
? <CreditCard size={13} className="text-violet-600 dark:text-violet-400" />
|
||||||
}
|
: <Printer size={13} className="text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{device.name}</p>
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{device.name}</p>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
|
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
|
||||||
<span className="text-slate-300 dark:text-slate-600">·</span>
|
<span className="text-slate-300 dark:text-slate-600">·</span>
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
|
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
|
||||||
{device.connection === 'usb' ? <Usb size={10} /> : device.connection === 'com' ? <Cable size={10} /> : <Network size={10} />}
|
{device.connection === 'usb' ? <Usb size={10} /> : device.connection === 'com' ? <Cable size={10} /> : <Network size={10} />}
|
||||||
{device.connection === 'usb' ? 'USB' : device.connection === 'com' ? device.comPort : `${device.networkHost}:${device.networkPort}`}
|
{device.connection === 'usb'
|
||||||
</span>
|
? 'USB'
|
||||||
|
: device.connection === 'com'
|
||||||
|
? (device.comPort ?? 'COM?')
|
||||||
|
: device.networkHost
|
||||||
|
? `${device.networkHost}:${device.networkPort}`
|
||||||
|
: 'IP не задан'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{ws.isOnline && (
|
||||||
|
<button
|
||||||
|
onClick={() => testDevice(device.id)}
|
||||||
|
disabled={testingDeviceId === device.id}
|
||||||
|
className="btn-ghost p-1.5 text-slate-400 hover:text-violet-500"
|
||||||
|
title="Тест"
|
||||||
|
>
|
||||||
|
{testingDeviceId === device.id
|
||||||
|
? <RefreshCw size={12} className="animate-spin" />
|
||||||
|
: <Zap size={12} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setEditDeviceId(device.id)} className="btn-ghost p-1.5 text-slate-400">
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteDevice(device.id)} className="btn-ghost p-1.5 text-slate-400 hover:text-red-500">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{testResults[device.id] !== undefined && testResults[device.id] !== null && (
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className={cn(
|
||||||
<button onClick={() => setEditDeviceId(device.id)} className="btn-ghost p-1.5 text-slate-400">
|
'mt-1 px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5',
|
||||||
<Pencil size={12} />
|
testResults[device.id]!.ok
|
||||||
</button>
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400'
|
||||||
<button onClick={() => deleteDevice(device.id)} className="btn-ghost p-1.5 text-slate-400 hover:text-red-500">
|
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400',
|
||||||
<Trash2 size={12} />
|
)}>
|
||||||
</button>
|
{testResults[device.id]!.ok ? <Check size={11} /> : <X size={11} />}
|
||||||
</div>
|
{testResults[device.id]!.msg || (testResults[device.id]!.ok ? 'Успешно' : 'Ошибка')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -482,6 +636,9 @@ function WorkstationCard({
|
|||||||
<DeviceForm
|
<DeviceForm
|
||||||
onSave={addDevice}
|
onSave={addDevice}
|
||||||
onCancel={() => setShowAddDevice(false)}
|
onCancel={() => setShowAddDevice(false)}
|
||||||
|
slug={slug}
|
||||||
|
wsId={ws.id}
|
||||||
|
wsOnline={ws.isOnline}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -519,6 +676,8 @@ export function EquipmentPage() {
|
|||||||
const [addingName, setAddingName] = useState('')
|
const [addingName, setAddingName] = useState('')
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [latestRelease, setLatestRelease] = useState<{ version: string; downloadUrl: string } | null>(null)
|
||||||
|
const [updatingAll, setUpdatingAll] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
@@ -535,6 +694,10 @@ export function EquipmentPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.agents.getLatestRelease().then(setLatestRelease).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!addingName.trim()) return
|
if (!addingName.trim()) return
|
||||||
setAdding(true)
|
setAdding(true)
|
||||||
@@ -545,10 +708,26 @@ export function EquipmentPage() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateAll = async () => {
|
||||||
|
if (updatingAll) return
|
||||||
|
setUpdatingAll(true)
|
||||||
|
try {
|
||||||
|
await api.workstations.updateAll(slug)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setUpdatingAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const online = workstations.filter(w => w.isOnline).length
|
const online = workstations.filter(w => w.isOnline).length
|
||||||
const total = workstations.length
|
const total = workstations.length
|
||||||
const noPairs = workstations.filter(w => !w.agentId).length
|
const noPairs = workstations.filter(w => !w.agentId).length
|
||||||
|
|
||||||
|
const anyOutdated = latestRelease !== null && workstations.some(w =>
|
||||||
|
w.isOnline && w.agentVersion && compareVersions(latestRelease.version, w.agentVersion) > 0,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl mx-auto space-y-6">
|
<div className="p-6 max-w-3xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -559,13 +738,36 @@ export function EquipmentPage() {
|
|||||||
Рабочие места, кассы и принтеры
|
Рабочие места, кассы и принтеры
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowAdd(true)}
|
{anyOutdated && (
|
||||||
className="btn-primary flex items-center gap-2 py-2 px-4"
|
<button
|
||||||
>
|
onClick={handleUpdateAll}
|
||||||
<Plus size={16} />
|
disabled={updatingAll}
|
||||||
Новое рабочее место
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 transition-colors disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
|
<ArrowUpCircle size={15} />
|
||||||
|
{updatingAll ? 'Отправка...' : 'Обновить все'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{latestRelease && (
|
||||||
|
<a
|
||||||
|
href={latestRelease.downloadUrl}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 transition-colors"
|
||||||
|
title={`Скачать агент v${latestRelease.version}`}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
Скачать агент v{latestRelease.version}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
className="btn-primary flex items-center gap-2 py-2 px-4"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Новое рабочее место
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -631,7 +833,13 @@ export function EquipmentPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 group">
|
<div className="space-y-4 group">
|
||||||
{workstations.map(ws => (
|
{workstations.map(ws => (
|
||||||
<WorkstationCard key={ws.id} ws={ws} slug={slug} onRefresh={load} />
|
<WorkstationCard
|
||||||
|
key={ws.id}
|
||||||
|
ws={ws}
|
||||||
|
slug={slug}
|
||||||
|
latestRelease={latestRelease}
|
||||||
|
onRefresh={load}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user