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

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