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

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