fix(firmware): make the per-device update button actually install (v0.23.16-beta)
The "Install update" button on a device's Config tab reported success and did
nothing. Staged rollouts were unaffected, which is why this survived: the two
paths do the same job through different code, and only one of them was right.
RouterOS installs the version discovered by `check-for-updates` **in the same
API session**. Issued alone, `/system/package/update/install` replies !done and
takes no action — no download, no reboot, no error. The route issued it alone.
The rollout orchestrator calls the check first, so it always worked.
Confirmed by A/B on one switch, two minutes apart. Through the button: HTTP 200,
"Update installation initiated. Device will reboot", and ninety seconds later
the device still reported 7.24 with "New version is available" and had not
rebooted. Through a rollout: reboot within fourteen seconds, upgraded to 7.24.1.
Two further faults in the same handler, both of which made the first one harder
to see:
* `firmware_update_available` was cleared the moment the command was sent, so
every screen showed the device as current while it sat unchanged. The next
poll flipped it back, which reads as an update that "keeps coming back".
* Success was reported on submit rather than on outcome, so a device that was
already up to date and a device that had genuinely started an upgrade
produced identical messages.
The route now checks first, distinguishes "installing 7.24.1" from "already
running 7.24.1 — nothing to install", and leaves the flag alone: an upgrade
takes minutes and a reboot, neither of which an HTTP request can wait for.
Ownership of that flag moves to the version read itself. collectSystemInfo now
clears it when the observed version matches the known latest, because the
dedicated firmware check runs only once a day — without this, not clearing
optimistically would leave a device flagged for up to 24 hours after it was
upgraded. It also fixes the same staleness when a device is upgraded outside the
platform entirely.
install-routerboard had the identical shape and gets the identical treatment.
It matters more there: `/system/routerboard/upgrade` on a current bootloader is
a no-op, but the code rebooted the device afterwards regardless — an outage in
exchange for nothing.
Verified against the live fleet: the fixed button upgraded 2GT-NW-BigSwitch
7.24 -> 7.24.1; an already-current device answered "nothing to install" instead
of claiming a reboot; the flag stayed set during the upgrade and cleared once
the new version was observed; and a device genuinely behind stayed flagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f011a40748
commit
1332f7384d
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.23.15_Beta-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/version-0.23.16_Beta-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
||||
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<any>(
|
||||
`SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
const deviceRow = await queryOne<any>(`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<any>(
|
||||
`SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
const deviceRow = await queryOne<any>(`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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.23.15-beta",
|
||||
"version": "0.23.16-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -363,10 +363,19 @@ export const devicesApi = {
|
||||
api.post<Record<string, string>>(`/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<string, string>[]; disks: Record<string, string>[] }>(`/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),
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.15 Beta';
|
||||
export const APP_VERSION = 'v0.23.16 Beta';
|
||||
|
||||
Reference in New Issue
Block a user