diff --git a/README.md b/README.md
index 0aacf07..2c48636 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
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';