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 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 {}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
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 { 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
|
||||
|
||||
@@ -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<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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function req<T>(
|
||||
@@ -72,7 +88,7 @@ async function req<T>(
|
||||
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<WorkstationDevice>('PATCH', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`, data),
|
||||
removeDevice: (slug: string, id: string, deviceId: string) =>
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
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<WorkstationDevice>
|
||||
onSave: (data: Partial<WorkstationDevice>) => Promise<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 [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({
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Тип устройства</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="kkt">ККТ (касса)</option>
|
||||
<option value="printer">Принтер</option>
|
||||
<option value="netup">NetUp IPTV</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -280,14 +312,37 @@ function DeviceForm({
|
||||
|
||||
{connection === 'com' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">COM-порт</label>
|
||||
<input
|
||||
value={comPort}
|
||||
onChange={e => setComPort(e.target.value)}
|
||||
className="w-full input text-sm font-mono"
|
||||
placeholder="COM1"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">Windows: COM1, COM3 и т.д. Linux: /dev/ttyUSB0</p>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">
|
||||
COM-порт
|
||||
{portsLoading && <span className="ml-2 text-slate-400 font-normal">загрузка...</span>}
|
||||
</label>
|
||||
{wsOnline && availablePorts && availablePorts.length > 0 ? (
|
||||
<select
|
||||
value={comPort}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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<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 () => {
|
||||
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({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ws.hostname && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{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 className="flex items-center gap-2 shrink-0">
|
||||
<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 ? (
|
||||
<button
|
||||
onClick={() => setShowPairModal(true)}
|
||||
@@ -440,39 +559,74 @@ function WorkstationCard({
|
||||
initial={device}
|
||||
onSave={data => updateDevice(device.id, data)}
|
||||
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 className={cn(
|
||||
'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" />
|
||||
: <Printer size={13} className="text-blue-600 dark:text-blue-400" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 group">
|
||||
<div className={cn(
|
||||
'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" />
|
||||
: <Printer size={13} className="text-blue-600 dark:text-blue-400" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">
|
||||
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
|
||||
<span className="text-slate-300 dark:text-slate-600">·</span>
|
||||
<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' : device.connection === 'com' ? device.comPort : `${device.networkHost}:${device.networkPort}`}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">
|
||||
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
|
||||
<span className="text-slate-300 dark:text-slate-600">·</span>
|
||||
<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'
|
||||
: 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 className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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>
|
||||
{testResults[device.id] !== undefined && testResults[device.id] !== null && (
|
||||
<div className={cn(
|
||||
'mt-1 px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5',
|
||||
testResults[device.id]!.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400',
|
||||
)}>
|
||||
{testResults[device.id]!.ok ? <Check size={11} /> : <X size={11} />}
|
||||
{testResults[device.id]!.msg || (testResults[device.id]!.ok ? 'Успешно' : 'Ошибка')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -482,6 +636,9 @@ function WorkstationCard({
|
||||
<DeviceForm
|
||||
onSave={addDevice}
|
||||
onCancel={() => setShowAddDevice(false)}
|
||||
slug={slug}
|
||||
wsId={ws.id}
|
||||
wsOnline={ws.isOnline}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
@@ -519,6 +676,8 @@ export function EquipmentPage() {
|
||||
const [addingName, setAddingName] = useState('')
|
||||
const [showAdd, setShowAdd] = 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 () => {
|
||||
if (!slug) return
|
||||
@@ -535,6 +694,10 @@ export function EquipmentPage() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
api.agents.getLatestRelease().then(setLatestRelease).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addingName.trim()) return
|
||||
setAdding(true)
|
||||
@@ -545,10 +708,26 @@ export function EquipmentPage() {
|
||||
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 total = workstations.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 (
|
||||
<div className="p-6 max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
@@ -559,13 +738,36 @@ export function EquipmentPage() {
|
||||
Рабочие места, кассы и принтеры
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="btn-primary flex items-center gap-2 py-2 px-4"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Новое рабочее место
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{anyOutdated && (
|
||||
<button
|
||||
onClick={handleUpdateAll}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -631,7 +833,13 @@ export function EquipmentPage() {
|
||||
) : (
|
||||
<div className="space-y-4 group">
|
||||
{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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user