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