diff --git a/README.md b/README.md index 0aacf07..2c48636 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index 1e2d59f..df6281e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.15-beta", + "version": "0.23.16-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index c64c5de..705d1c1 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -1571,24 +1571,61 @@ router.post('/:id/check-update', async (req: Request, res: Response) => { } }); -// POST /api/devices/:id/install-update +/** + * POST /api/devices/:id/install-update — install a pending RouterOS update. + * + * The check on the line below is load-bearing, not a courtesy. RouterOS installs + * the version discovered by `check-for-updates` **in the same API session**; + * issued on its own, `/system/package/update/install` replies `!done` and does + * nothing at all. This route used to do exactly that, so the button reported + * "Update installation initiated. Device will reboot." while the device sat + * untouched — verified against a switch that ignored this endpoint and then + * upgraded two minutes later through the rollout path, which does check first. + * + * The flag is likewise not cleared here. An upgrade takes minutes and a reboot, + * neither of which an HTTP request can wait for, so claiming the device is + * current the moment the command is sent puts a false "up to date" on every + * screen until the poller contradicts it. The poller owns that transition; this + * route reports only what it actually knows. + */ router.post('/:id/install-update', requireWrite, async (req: Request, res: Response) => { - const deviceRow = await queryOne( - `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, - [req.params.id] - ); + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); + + const status = await collector.checkForUpdates(); + const installed = (status['installed-version'] || '').trim(); + const latest = (status['latest-version'] || '').trim(); + + if (!latest || latest === installed) { + // Nothing to do is a real answer, not a failure — and it is the one state + // in which the device genuinely is current, so the flag can be trusted. + await query( + `UPDATE devices SET firmware_update_available = FALSE, + latest_ros_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`, + [deviceRow.id, latest] + ); + return res.json({ + started: false, + installed_version: installed || null, + message: `Already running ${installed || 'the latest version'} — nothing to install.`, + }); + } + await collector.installUpdate(); - // Clear the update flag — device is rebooting with new firmware await query( - `UPDATE devices SET firmware_update_available = FALSE, updated_at = NOW() WHERE id = $1`, - [deviceRow.id] + `UPDATE devices SET latest_ros_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`, + [deviceRow.id, latest] ); - return res.json({ message: 'Update installation initiated. Device will reboot.' }); + return res.json({ + started: true, + installed_version: installed || null, + target_version: latest, + message: `Installing ${latest}. The device will reboot; the new version appears once it is back and polled.`, + }); } finally { collector.disconnect(); } @@ -1616,23 +1653,49 @@ router.post('/:id/check-routerboard', async (req: Request, res: Response) => { } }); -// POST /api/devices/:id/install-routerboard +/** + * POST /api/devices/:id/install-routerboard — apply a pending RouterBOOT upgrade. + * + * Same shape as install-update above, and it had the same two faults: no check + * that anything was pending, and the flag cleared before the reboot it depends + * on had happened. `/system/routerboard/upgrade` on a current bootloader is a + * no-op, so the reboot that followed accomplished nothing while the UI reported + * an upgrade — and rebooting a device to achieve nothing is worse here than in + * the RouterOS case, because it costs an outage. + */ router.post('/:id/install-routerboard', requireWrite, async (req: Request, res: Response) => { - const deviceRow = await queryOne( - `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, - [req.params.id] - ); + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); + + const info = await collector.checkRouterboardUpgrade(); + if (!info.upgradeAvailable) { + await query( + `UPDATE devices SET routerboard_upgrade_available = FALSE, + upgrade_firmware_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`, + [deviceRow.id, info.upgradeFirmware] + ); + return res.json({ + started: false, + current_firmware: info.currentFirmware || null, + message: `RouterBOOT is already at ${info.currentFirmware || 'the current version'} — nothing to upgrade.`, + }); + } + await collector.installRouterboardUpgrade(); await query( - `UPDATE devices SET routerboard_upgrade_available = FALSE, updated_at = NOW() WHERE id = $1`, - [deviceRow.id] + `UPDATE devices SET upgrade_firmware_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`, + [deviceRow.id, info.upgradeFirmware] ); - return res.json({ message: 'RouterBOOT upgrade initiated. Device will reboot.' }); + return res.json({ + started: true, + current_firmware: info.currentFirmware || null, + target_firmware: info.upgradeFirmware, + message: `Upgrading RouterBOOT to ${info.upgradeFirmware}. The device will reboot.`, + }); } finally { collector.disconnect(); } diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 1da3a2b..4cd9df1 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -211,6 +211,16 @@ export class DeviceCollector { serial_number = COALESCE($3, serial_number), firmware_version = COALESCE($4, firmware_version), ros_version = COALESCE($5, ros_version), + -- An update that has landed is no longer pending. The dedicated + -- firmware check only runs once a day, so without this a device stays + -- flagged "update available" for up to 24 hours after it has already + -- been upgraded — including when someone upgrades it outside the + -- platform entirely. The version read here is the authority. + firmware_update_available = CASE + WHEN NULLIF($5,'') IS NOT NULL AND latest_ros_version IS NOT NULL + AND NULLIF($5,'') = latest_ros_version THEN FALSE + ELSE firmware_update_available + END, updated_at = NOW() WHERE id = $6`, [identityName, model, serial, firmware, rosVersion, this.device.id] diff --git a/frontend/package.json b/frontend/package.json index e3ee6e6..9e9c70f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.15-beta", + "version": "0.23.16-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/SystemConfigTab.tsx b/frontend/src/components/device-detail/SystemConfigTab.tsx index c5a64ec..15720dd 100644 --- a/frontend/src/components/device-detail/SystemConfigTab.tsx +++ b/frontend/src/components/device-detail/SystemConfigTab.tsx @@ -181,9 +181,21 @@ export default function SystemConfigTab({ deviceId, device }: Props) { const installUpdateMutation = useMutation({ mutationFn: () => devicesApi.installUpdate(deviceId), - onSuccess: () => { - setUpdateInfo(null); - setInstallSuccess('Update installation initiated. The device will reboot shortly.'); + // The server now distinguishes "installing" from "there was nothing to + // install", because this button used to report the first while doing the + // second. Report back whichever actually happened. + onSuccess: (res) => { + setInstallSuccess(res.data.message); + if (res.data.started) { + setUpdateInfo(null); + } else { + setUpdateInfo({ + 'installed-version': res.data.installed_version || '', + 'latest-version': res.data.installed_version || '', + 'status': 'System is up to date', + 'channel': '', + }); + } invalidateFirmwareState(); }, onError: (err: unknown) => { @@ -232,9 +244,9 @@ export default function SystemConfigTab({ deviceId, device }: Props) { const installRouterboardMutation = useMutation({ mutationFn: () => devicesApi.installRouterboard(deviceId), - onSuccess: () => { - setRbInfo(null); - setRbInstallSuccess('RouterBOOT upgrade initiated. The device will reboot shortly.'); + onSuccess: (res) => { + setRbInstallSuccess(res.data.message); + if (res.data.started) setRbInfo(null); invalidateFirmwareState(); }, onError: (err: unknown) => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c06bc7e..6215f49 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -363,10 +363,19 @@ export const devicesApi = { api.post>(`/devices/${id}/check-update`), checkRouterboard: (id: number) => api.post<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean }>(`/devices/${id}/check-routerboard`), - installRouterboard: (id: number) => api.post(`/devices/${id}/install-routerboard`), + installRouterboard: (id: number) => + api.post<{ + started: boolean; current_firmware: string | null; + target_firmware?: string; message: string; + }>(`/devices/${id}/install-routerboard`), getHardware: (id: number) => api.get<{ health: Record[]; disks: Record[] }>(`/devices/${id}/hardware`), - installUpdate: (id: number) => api.post(`/devices/${id}/install-update`), + /** `started: false` means the device was already current — not a failure. */ + installUpdate: (id: number) => + api.post<{ + started: boolean; installed_version: string | null; + target_version?: string; message: string; + }>(`/devices/${id}/install-update`), getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`), setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) => api.put(`/devices/${id}/clock`, data), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 13d120f..74b21ac 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.15 Beta'; +export const APP_VERSION = 'v0.23.16 Beta';