-
+
diff --git a/backend/package.json b/backend/package.json
index e349ba8..71c0c19 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.26-beta",
+ "version": "0.23.27-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index 9d9abd4..8768057 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -878,6 +878,47 @@ CREATE INDEX IF NOT EXISTS idx_data_cap_sends ON lte_data_cap_sends(device_id, a
-- The password is retained alongside. Key auth falling back to a password is a
-- degraded state worth knowing about; key auth failing with no fallback is an
-- outage.
+-- Bulk command execution (#118).
+--
+-- Waves are not a nicety. Running one command across sixty devices in parallel
+-- means a mistake reaches all sixty before the first failure is visible, and the
+-- whole reason to do this here rather than in a shell loop is that this one can
+-- stop. wave_size and halt_on_failure are what make that possible.
+CREATE TABLE IF NOT EXISTS command_runs (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ command TEXT NOT NULL,
+ wave_size INTEGER NOT NULL DEFAULT 1,
+ halt_on_failure BOOLEAN NOT NULL DEFAULT TRUE,
+ -- Change Guard restores a device that stops answering after the command.
+ -- Defaults on; an operator may turn it off deliberately, per run.
+ use_change_guard BOOLEAN NOT NULL DEFAULT TRUE,
+ -- pending | running | completed | failed | halted | cancelled
+ status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ created_by VARCHAR(50),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ started_at TIMESTAMPTZ,
+ finished_at TIMESTAMPTZ
+);
+
+-- Output is kept per device because "it failed" without the device's own words
+-- is not actionable, and because some commands exist purely for their output.
+CREATE TABLE IF NOT EXISTS command_run_devices (
+ id SERIAL PRIMARY KEY,
+ run_id INTEGER NOT NULL REFERENCES command_runs(id) ON DELETE CASCADE,
+ device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ wave INTEGER NOT NULL DEFAULT 1,
+ -- pending | running | success | failed | skipped | reverted
+ status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ output TEXT,
+ error TEXT,
+ guarded BOOLEAN NOT NULL DEFAULT FALSE,
+ auto_reverted BOOLEAN NOT NULL DEFAULT FALSE,
+ started_at TIMESTAMPTZ,
+ finished_at TIMESTAMPTZ
+);
+CREATE INDEX IF NOT EXISTS idx_command_run_devices ON command_run_devices(run_id, wave);
+
CREATE TABLE IF NOT EXISTS device_ssh_keys (
device_id INTEGER PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
key_type VARCHAR(24) NOT NULL DEFAULT 'ed25519',
diff --git a/backend/src/index.ts b/backend/src/index.ts
index be9acbc..cbbe20f 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -62,6 +62,7 @@ import wirelessRoutes from './routes/wireless';
import networkServicesRoutes from './routes/networkServices';
import trafficAnalyticsRoutes from './routes/trafficAnalytics';
import credentialPresetsRoutes from './routes/credentialPresets';
+import commandRoutes from './routes/commands';
import systemRoutes, { setPollerService as setSystemPoller } from './routes/system';
import { auditMiddleware } from './middleware/auditMiddleware';
@@ -327,6 +328,7 @@ app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
app.use('/api/config-templates', configTemplatesRoutes);
app.use('/api/config-history', configHistoryRoutes);
app.use('/api/system', systemRoutes);
+app.use('/api/commands', commandRoutes);
// ─── Error Handler ────────────────────────────────────────────────────────────
app.use(errorHandler);
diff --git a/backend/src/routes/commands.ts b/backend/src/routes/commands.ts
new file mode 100644
index 0000000..f41d666
--- /dev/null
+++ b/backend/src/routes/commands.ts
@@ -0,0 +1,170 @@
+/**
+ * Bulk command execution across devices (#118).
+ *
+ * The dry run is not decoration. This endpoint family can misconfigure an entire
+ * fleet in one call, and the difference between a tool and an accident is being
+ * able to see what will happen first.
+ */
+import { Router } from 'express';
+import type { Request, Response } from 'express';
+import { requireAuth, requireWrite } from '../middleware/auth';
+import { query, queryOne } from '../config/database';
+import { commandRunner } from '../services/CommandRunner';
+
+const router = Router();
+router.use(requireAuth);
+
+/** Commands that change how a device is reached, and so deserve a warning. */
+const RISKY_PATTERNS: { pattern: RegExp; why: string }[] = [
+ { pattern: /\/ip\/?\s*address\s+(remove|set)/i, why: 'changes IP addressing, which can remove the management address' },
+ { pattern: /\/ip\/?\s*route\s+(remove|set)/i, why: 'changes routing, which can cut the path back to this server' },
+ { pattern: /\/ip\/?\s*firewall/i, why: 'edits firewall rules, which can block management access' },
+ { pattern: /\/interface\/?\s*\w*\s*(disable|remove)/i, why: 'disables or removes an interface, possibly the one in use' },
+ { pattern: /\/system\/?\s*(reboot|shutdown|reset-configuration)/i, why: 'reboots, shuts down or resets the device' },
+ { pattern: /\/user\s+(remove|set|disable)/i, why: 'changes user accounts, which can revoke this server’s access' },
+ { pattern: /\/ip\/?\s*service\s+(disable|set|remove)/i, why: 'changes management services such as the API or SSH' },
+];
+
+function assessCommand(command: string): { risky: boolean; reasons: string[] } {
+ const reasons = RISKY_PATTERNS.filter((r) => r.pattern.test(command)).map((r) => r.why);
+ return { risky: reasons.length > 0, reasons };
+}
+
+/**
+ * POST /api/commands/preview — what would happen, without doing it.
+ *
+ * Returns the devices, their wave assignment, and any reason the command looks
+ * capable of severing management. Warnings only: the operator decides.
+ */
+router.post('/preview', async (req: Request, res: Response) => {
+ const { command, device_ids, wave_size } = req.body as
+ { command?: string; device_ids?: number[]; wave_size?: number };
+
+ if (!command?.trim()) return res.status(400).json({ error: 'command is required' });
+ if (!Array.isArray(device_ids) || device_ids.length === 0) {
+ return res.status(400).json({ error: 'device_ids array is required' });
+ }
+
+ const size = Math.max(1, Math.min(50, Number(wave_size) || 1));
+ const devices = await query<{ id: number; name: string; ip_address: string; status: string }>(
+ `SELECT id, name, ip_address, status FROM devices WHERE id = ANY($1::int[]) ORDER BY name`,
+ [device_ids]
+ );
+
+ // Devices without any SSH credential cannot be reached at all; saying so now
+ // is better than a wave of identical authentication failures.
+ const unreachable = await query<{ id: number; name: string }>(
+ `SELECT d.id, d.name FROM devices d
+ WHERE d.id = ANY($1::int[])
+ AND d.ssh_username IS NULL AND d.api_username IS NULL`,
+ [device_ids]
+ );
+
+ res.json({
+ command,
+ ...assessCommand(command),
+ wave_size: size,
+ devices: devices.map((d, i) => ({ ...d, wave: Math.floor(i / size) + 1 })),
+ waves: Math.ceil(devices.length / size),
+ unreachable,
+ });
+});
+
+// POST /api/commands/runs — create a run, optionally starting it immediately
+router.post('/runs', requireWrite, async (req: Request, res: Response) => {
+ const {
+ name, command, device_ids, wave_size, halt_on_failure, use_change_guard, start,
+ } = req.body as {
+ name?: string; command?: string; device_ids?: number[]; wave_size?: number;
+ halt_on_failure?: boolean; use_change_guard?: boolean; start?: boolean;
+ };
+
+ if (!command?.trim()) return res.status(400).json({ error: 'command is required' });
+ if (!Array.isArray(device_ids) || device_ids.length === 0) {
+ return res.status(400).json({ error: 'device_ids array is required' });
+ }
+
+ const size = Math.max(1, Math.min(50, Number(wave_size) || 1));
+ const run = await queryOne<{ id: number }>(
+ `INSERT INTO command_runs
+ (name, command, wave_size, halt_on_failure, use_change_guard, created_by)
+ VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
+ [
+ (name?.trim() || command.trim()).slice(0, 120),
+ command.trim(),
+ size,
+ halt_on_failure !== false,
+ // Guards are opt-out, never opt-in. Someone who has not thought about it
+ // gets the protected behaviour.
+ use_change_guard !== false,
+ (req as unknown as { user?: { username?: string } }).user?.username ?? null,
+ ]
+ );
+
+ const ordered = await query<{ id: number }>(
+ `SELECT id FROM devices WHERE id = ANY($1::int[]) ORDER BY name`, [device_ids]
+ );
+ for (const [i, d] of ordered.entries()) {
+ await query(
+ `INSERT INTO command_run_devices (run_id, device_id, wave) VALUES ($1,$2,$3)`,
+ [run!.id, d.id, Math.floor(i / size) + 1]
+ );
+ }
+
+ if (start) {
+ try {
+ await commandRunner.start(run!.id);
+ } catch (e) {
+ return res.status(409).json({ error: (e as Error).message, id: run!.id });
+ }
+ }
+ res.status(201).json({ id: run!.id });
+});
+
+// GET /api/commands/runs — recent runs with progress
+router.get('/runs', async (_req: Request, res: Response) => {
+ const runs = await query(
+ `SELECT r.*,
+ COUNT(d.*)::int AS total,
+ COUNT(*) FILTER (WHERE d.status = 'success')::int AS succeeded,
+ COUNT(*) FILTER (WHERE d.status IN ('failed','reverted'))::int AS failed
+ FROM command_runs r
+ LEFT JOIN command_run_devices d ON d.run_id = r.id
+ GROUP BY r.id ORDER BY r.id DESC LIMIT 25`
+ );
+ res.json(runs);
+});
+
+// GET /api/commands/runs/:id — one run with per-device output
+router.get('/runs/:id', async (req: Request, res: Response) => {
+ const run = await queryOne(`SELECT * FROM command_runs WHERE id = $1`, [req.params.id]);
+ if (!run) return res.status(404).json({ error: 'Run not found' });
+ const devices = await query(
+ `SELECT rd.*, d.name AS device_name, d.ip_address
+ FROM command_run_devices rd
+ JOIN devices d ON d.id = rd.device_id
+ WHERE rd.run_id = $1 ORDER BY rd.wave, d.name`,
+ [req.params.id]
+ );
+ res.json({ ...run, devices });
+});
+
+// POST /api/commands/runs/:id/start
+router.post('/runs/:id/start', requireWrite, async (req: Request, res: Response) => {
+ try {
+ await commandRunner.start(Number(req.params.id));
+ res.json({ message: 'Run started' });
+ } catch (e) {
+ res.status(409).json({ error: (e as Error).message });
+ }
+});
+
+// POST /api/commands/runs/:id/cancel — stop before the next wave
+router.post('/runs/:id/cancel', requireWrite, async (req: Request, res: Response) => {
+ commandRunner.cancel(Number(req.params.id));
+ // Devices already running finish; cancellation prevents the next wave, which
+ // is the only point at which stopping is safe.
+ res.json({ message: 'Cancellation requested. Waves already running will finish.' });
+});
+
+export default router;
diff --git a/backend/src/services/CommandRunner.ts b/backend/src/services/CommandRunner.ts
new file mode 100644
index 0000000..b73de19
--- /dev/null
+++ b/backend/src/services/CommandRunner.ts
@@ -0,0 +1,172 @@
+/**
+ * Running one command across many devices, in waves.
+ *
+ * The waves are the point. `for h in $hosts; do ssh $h "$cmd"; done` already
+ * exists, costs nothing, and does most of this — but it reaches every device
+ * before the first error is visible. What this adds is the ability to stop: a
+ * small first wave, a look at what happened, and a halt before the mistake
+ * becomes sixty mistakes (#118).
+ *
+ * Within that, the operator is trusted. Arbitrary commands, because a curated
+ * library cannot express "add a channel to LACP" or "rewrite a WireGuard
+ * endpoint". Change Guard on by default because bulk shutdown and firewall edits
+ * are on the list of things people actually run, and off in one click because a
+ * sharp tool that refuses to cut is not a tool.
+ */
+import { query, queryOne } from '../config/database';
+import { runSshCommand, looksLikeFailure, type SshExecDevice } from './sshExec';
+import { withSafeApply, type GuardDevice } from './changeGuard/ChangeGuard';
+
+interface RunRow {
+ id: number;
+ name: string;
+ command: string;
+ wave_size: number;
+ halt_on_failure: boolean;
+ use_change_guard: boolean;
+ status: string;
+}
+
+interface RunDeviceRow {
+ id: number;
+ device_id: number;
+ wave: number;
+}
+
+type DeviceRow = SshExecDevice & GuardDevice;
+
+export class CommandRunner {
+ private activeRunId: number | null = null;
+ private cancelRequested = false;
+
+ get isRunning(): boolean {
+ return this.activeRunId !== null;
+ }
+
+ async start(runId: number): Promise {
+ if (this.activeRunId) throw new Error(`Run #${this.activeRunId} is already in progress`);
+ const run = await queryOne(`SELECT * FROM command_runs WHERE id = $1`, [runId]);
+ if (!run) throw new Error('Run not found');
+ if (run.status !== 'pending') throw new Error(`Run is ${run.status} — only pending runs can start`);
+
+ this.activeRunId = runId;
+ this.cancelRequested = false;
+ await query(`UPDATE command_runs SET status='running', started_at=NOW() WHERE id=$1`, [runId]);
+
+ // Fire and forget; callers poll. A bulk run outlives any HTTP request.
+ void this.run(run)
+ .catch(async (e) => {
+ console.error(`[Command] run #${runId} crashed:`, e);
+ await query(`UPDATE command_runs SET status='failed', finished_at=NOW() WHERE id=$1`, [runId]);
+ })
+ .finally(() => { this.activeRunId = null; });
+ }
+
+ cancel(runId: number): void {
+ if (this.activeRunId === runId) this.cancelRequested = true;
+ }
+
+ private async run(run: RunRow): Promise {
+ const items = await query(
+ `SELECT id, device_id, wave FROM command_run_devices WHERE run_id = $1 ORDER BY wave, id`,
+ [run.id]
+ );
+
+ const waves = [...new Set(items.map((i) => i.wave))].sort((a, b) => a - b);
+ let halted = false;
+
+ for (const wave of waves) {
+ if (this.cancelRequested || halted) break;
+ const inWave = items.filter((i) => i.wave === wave);
+
+ // Devices inside a wave run together; waves themselves are strictly
+ // sequential. That is what gives the operator a decision point.
+ const results = await Promise.all(inWave.map((item) => this.runOne(run, item)));
+
+ if (run.halt_on_failure && results.some((ok) => !ok)) {
+ halted = true;
+ console.warn(`[Command] run #${run.id}: halting after failures in wave ${wave}`);
+ }
+ }
+
+ // Anything not reached is skipped, not failed — it never ran.
+ await query(
+ `UPDATE command_run_devices SET status='skipped', finished_at=NOW()
+ WHERE run_id=$1 AND status='pending'`, [run.id]);
+
+ const status = this.cancelRequested ? 'cancelled' : halted ? 'halted' : 'completed';
+ await query(`UPDATE command_runs SET status=$2, finished_at=NOW() WHERE id=$1`, [run.id, status]);
+ console.log(`[Command] run #${run.id} ${status}`);
+ }
+
+ /** Returns true when the device succeeded. */
+ private async runOne(run: RunRow, item: RunDeviceRow): Promise {
+ const device = await queryOne(
+ `SELECT id, name, ip_address, api_port, api_username, api_password_encrypted,
+ ssh_port, ssh_username, ssh_password_encrypted
+ FROM devices WHERE id = $1`,
+ [item.device_id]
+ );
+ if (!device) {
+ await this.finish(item.id, 'failed', null, 'Device no longer exists');
+ return false;
+ }
+
+ await query(
+ `UPDATE command_run_devices SET status='running', started_at=NOW(), guarded=$2 WHERE id=$1`,
+ [item.id, run.use_change_guard]
+ );
+
+ const execute = async () => {
+ const { output } = await runSshCommand(device, run.command);
+ // RouterOS reports most errors in the text rather than an exit code, so an
+ // unread output is an unnoticed failure.
+ if (looksLikeFailure(output)) throw new Error(output.slice(0, 500));
+ return output;
+ };
+
+ try {
+ if (!run.use_change_guard) {
+ const output = await execute();
+ await this.finish(item.id, 'success', output, null);
+ return true;
+ }
+
+ const outcome = await withSafeApply(device, {
+ kind: 'command.bulk',
+ summary: `Bulk command: ${run.command.slice(0, 80)}`,
+ }, execute);
+
+ if (outcome.autoReverting) {
+ // The device stopped answering and is restoring itself. That is the
+ // guard working, and it must be reported as loudly as a failure.
+ await this.finish(
+ item.id, 'reverted', outcome.result ?? null,
+ 'Device stopped responding after the command and is restoring itself.',
+ true
+ );
+ return false;
+ }
+
+ await this.finish(item.id, 'success', outcome.result ?? null,
+ outcome.unprotectedReason ? `Ran unprotected: ${outcome.unprotectedReason}` : null);
+ return true;
+ } catch (e) {
+ await this.finish(item.id, 'failed', null, (e as Error).message);
+ return false;
+ }
+ }
+
+ private async finish(
+ id: number, status: string, output: string | null, error: string | null, reverted = false,
+ ): Promise {
+ await query(
+ `UPDATE command_run_devices
+ SET status=$2, output=$3, error=$4, auto_reverted=$5, finished_at=NOW()
+ WHERE id=$1`,
+ [id, status, output, error, reverted]
+ );
+ }
+}
+
+export const commandRunner = new CommandRunner();
diff --git a/backend/src/services/sshExec.ts b/backend/src/services/sshExec.ts
new file mode 100644
index 0000000..cf7c665
--- /dev/null
+++ b/backend/src/services/sshExec.ts
@@ -0,0 +1,131 @@
+/**
+ * Running a command on a device over SSH, with whichever credential works.
+ *
+ * Console syntax — `:put`, `:foreach`, `/interface print` — is not reachable over
+ * the binary API, which has its own command tree. Anything that runs what an
+ * operator would type has to go over SSH (#118).
+ *
+ * Credential choice is not a preference. RouterOS refuses password
+ * authentication for a user once a key is bound to them, so a device with an
+ * installed key can *only* be reached by that key. Password is the path for
+ * devices without one (#110).
+ */
+import { Client as SSHClient } from 'ssh2';
+import { queryOne } from '../config/database';
+import { decrypt } from '../utils/crypto';
+
+export interface SshExecDevice {
+ id: number;
+ name: string;
+ ip_address: string;
+ ssh_port?: number | null;
+ ssh_username?: string | null;
+ ssh_password_encrypted?: string | null;
+ api_username?: string | null;
+ api_password_encrypted?: string | null;
+}
+
+export interface SshExecResult {
+ /** Combined stdout and stderr, as an operator would see at a console. */
+ output: string;
+ /** Which credential actually carried the session. */
+ auth: 'key' | 'password';
+}
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+/**
+ * Resolve the credential for a device.
+ *
+ * A key is used only when it has been *proved* — a key that was generated or
+ * pushed but never authenticated is not evidence the device will accept it.
+ */
+async function resolveAuth(device: SshExecDevice): Promise<{
+ username: string;
+ auth: { password: string } | { privateKey: string };
+ kind: 'key' | 'password';
+}> {
+ const key = await queryOne<{ private_key_encrypted: string; ssh_username: string | null }>(
+ `SELECT private_key_encrypted, ssh_username FROM device_ssh_keys
+ WHERE device_id = $1 AND status = 'verified'`,
+ [device.id]
+ );
+
+ if (key) {
+ const username = key.ssh_username || device.ssh_username || device.api_username;
+ if (!username) throw new Error('No SSH username available for key authentication');
+ return { username, auth: { privateKey: decrypt(key.private_key_encrypted) }, kind: 'key' };
+ }
+
+ const username = device.ssh_username || device.api_username;
+ if (!username) throw new Error('No SSH username configured');
+ const encrypted = device.ssh_password_encrypted || device.api_password_encrypted;
+ if (!encrypted) throw new Error('No SSH credential configured');
+ return { username, auth: { password: decrypt(encrypted) }, kind: 'password' };
+}
+
+/**
+ * Run one command and return everything it printed.
+ *
+ * Errors are thrown, never swallowed. A command runner that reports success
+ * because it discarded the failure is worse than one that does nothing — and a
+ * quietly discarded error is exactly how a shipped feature spent a week doing
+ * nothing at all.
+ */
+export async function runSshCommand(
+ device: SshExecDevice,
+ command: string,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+): Promise {
+ const { username, auth, kind } = await resolveAuth(device);
+
+ return new Promise((resolve, reject) => {
+ const conn = new SSHClient();
+ let settled = false;
+ const finish = (fn: () => void) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ try { conn.end(); } catch { /* already closing */ }
+ fn();
+ };
+
+ const timer = setTimeout(
+ () => finish(() => reject(new Error(`Command timed out after ${timeoutMs}ms`))),
+ timeoutMs,
+ );
+
+ conn.on('ready', () => {
+ conn.exec(command, (err, stream) => {
+ if (err) return finish(() => reject(err));
+ let output = '';
+ stream.on('data', (d: Buffer) => { output += d.toString(); });
+ stream.stderr.on('data', (d: Buffer) => { output += d.toString(); });
+ stream.on('close', () => finish(() => resolve({ output: output.trim(), auth: kind })));
+ });
+ });
+ conn.on('error', (err) => finish(() => reject(err)));
+
+ conn.connect({
+ host: device.ip_address,
+ port: device.ssh_port ?? 22,
+ username,
+ readyTimeout: Math.min(timeoutMs, 20_000),
+ ...auth,
+ });
+ });
+}
+
+/**
+ * Does RouterOS's output indicate the command failed?
+ *
+ * RouterOS reports most errors in the text rather than through an exit code, so
+ * a command that "succeeded" and one that printed `syntax error` are
+ * indistinguishable unless the output is read. Deliberately conservative: only
+ * unambiguous markers count, because misreading ordinary output as failure would
+ * halt a rollout that was working.
+ */
+export function looksLikeFailure(output: string): boolean {
+ return /(^|\n)\s*(syntax error|bad command name|expected end of command|no such item|failure:|input does not match any value)/i
+ .test(output);
+}
diff --git a/backend/src/utils/__tests__/sshExec.test.ts b/backend/src/utils/__tests__/sshExec.test.ts
new file mode 100644
index 0000000..b96d507
--- /dev/null
+++ b/backend/src/utils/__tests__/sshExec.test.ts
@@ -0,0 +1,32 @@
+import { looksLikeFailure } from '../../services/sshExec';
+
+describe('looksLikeFailure', () => {
+ it('catches the ways RouterOS reports a bad command', () => {
+ // RouterOS signals most errors in the text, not an exit code — a command
+ // that worked and one that was rejected are otherwise indistinguishable.
+ expect(looksLikeFailure('syntax error (line 1 column 5)')).toBe(true);
+ expect(looksLikeFailure('bad command name foo (line 1 column 1)')).toBe(true);
+ expect(looksLikeFailure('expected end of command (line 1 column 20)')).toBe(true);
+ expect(looksLikeFailure('no such item (4)')).toBe(true);
+ expect(looksLikeFailure('failure: already have such address')).toBe(true);
+ expect(looksLikeFailure('input does not match any value of interface')).toBe(true);
+ });
+
+ it('treats ordinary output as success', () => {
+ // Misreading normal output as failure would halt a rollout that was working,
+ // which is worse than the error it was trying to catch.
+ expect(looksLikeFailure('')).toBe(false);
+ expect(looksLikeFailure(' name: MikroTik-Test')).toBe(false);
+ expect(looksLikeFailure('0 ether1 ether 1500 enabled')).toBe(false);
+ expect(looksLikeFailure('142.250.187.206')).toBe(false);
+ });
+
+ it('does not fire on words merely containing an error term', () => {
+ expect(looksLikeFailure('comment="no such item handling"')).toBe(false);
+ expect(looksLikeFailure('name=syntax-error-test')).toBe(false);
+ });
+
+ it('finds a failure on any line, not only the first', () => {
+ expect(looksLikeFailure('0 ether1\n1 ether2\nno such item (4)')).toBe(true);
+ });
+});
diff --git a/frontend/package.json b/frontend/package.json
index b84ede0..77cc880 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.26-beta",
+ "version": "0.23.27-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ba4aed2..10b4af9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,6 +10,7 @@ import EventsPage from './pages/EventsPage';
import TopologyPage from './pages/TopologyPage';
import BackupsPage from './pages/BackupsPage';
import FirmwarePage from './pages/FirmwarePage';
+import CommandsPage from './pages/CommandsPage';
import SettingsPage from './pages/SettingsPage';
import OidcCallbackPage from './pages/OidcCallbackPage';
import WirelessPage from './pages/WirelessPage';
@@ -57,6 +58,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
{/* Legacy routes — consolidated in the v0.16.4 UI reorganization */}
} />
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 49846cd..615155e 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -5,7 +5,7 @@ import {
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Ticket, ArrowUpCircle, Radio,
- LayoutGrid,
+ LayoutGrid, TerminalSquare,
} from 'lucide-react';
import clsx from 'clsx';
import { APP_VERSION } from '../../version';
@@ -38,6 +38,7 @@ const networkServicesSubItems = [
const operationsItems = [
{ to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' },
+ { to: '/commands', icon: TerminalSquare, label: 'Bulk Commands' },
{ to: '/backups', icon: HardDrive, label: 'Backups' },
];
diff --git a/frontend/src/pages/CommandsPage.tsx b/frontend/src/pages/CommandsPage.tsx
new file mode 100644
index 0000000..bd72d9f
--- /dev/null
+++ b/frontend/src/pages/CommandsPage.tsx
@@ -0,0 +1,256 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Terminal, AlertTriangle, Play, ShieldCheck, ChevronDown, ChevronRight, Ban } from 'lucide-react';
+import { commandsApi, devicesApi, type CommandRunDetail } from '../services/api';
+
+/**
+ * Bulk command execution.
+ *
+ * The controls here are the feature. A shell loop already runs a command on many
+ * hosts; what it cannot do is stop after the first wave once it becomes clear the
+ * command was wrong. Wave size and halt-on-failure are therefore prominent, and
+ * the guards default on — with a single deliberate switch to turn them off,
+ * because a tool that refuses to cut is not a sharp tool (#118).
+ */
+export default function CommandsPage() {
+ const queryClient = useQueryClient();
+ const [command, setCommand] = useState('');
+ const [selected, setSelected] = useState([]);
+ const [waveSize, setWaveSize] = useState(1);
+ const [haltOnFailure, setHaltOnFailure] = useState(true);
+ const [useGuard, setUseGuard] = useState(true);
+ const [acknowledged, setAcknowledged] = useState(false);
+ const [openRun, setOpenRun] = useState(null);
+ const [expanded, setExpanded] = useState>({});
+
+ const { data: devices = [] } = useQuery({
+ queryKey: ['devices'],
+ queryFn: () => devicesApi.list().then(r => r.data),
+ });
+
+ const { data: runs = [] } = useQuery({
+ queryKey: ['command-runs'],
+ queryFn: () => commandsApi.listRuns().then(r => r.data),
+ refetchInterval: 5_000,
+ });
+
+ const { data: detail } = useQuery({
+ queryKey: ['command-run', openRun],
+ queryFn: () => commandsApi.getRun(openRun!).then(r => r.data),
+ enabled: openRun != null,
+ refetchInterval: 3_000,
+ });
+
+ const { data: preview } = useQuery({
+ queryKey: ['command-preview', command, selected, waveSize],
+ queryFn: () => commandsApi.preview(command, selected, waveSize).then(r => r.data),
+ enabled: command.trim().length > 0 && selected.length > 0,
+ });
+
+ const startRun = useMutation({
+ mutationFn: () => commandsApi.createRun({
+ name: command.slice(0, 60), command, device_ids: selected,
+ wave_size: waveSize, halt_on_failure: haltOnFailure,
+ use_change_guard: useGuard, start: true,
+ }),
+ onSuccess: (r) => {
+ setOpenRun(r.data.id);
+ setAcknowledged(false);
+ queryClient.invalidateQueries({ queryKey: ['command-runs'] });
+ },
+ });
+
+ const toggle = (id: number) =>
+ setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
+
+ const risky = preview?.risky ?? false;
+ // Guards off on a command that can sever management is the one combination
+ // worth insisting the operator states out loud.
+ const needsAck = risky && !useGuard;
+ const canRun = command.trim() && selected.length > 0 && (!needsAck || acknowledged) && !startRun.isPending;
+
+ return (
+
+
+
+ Bulk commands
+
+
+ Run one RouterOS console command across many devices, in waves, stopping at the first
+ failure. Commands run over SSH, so console syntax such as :put works.
+
+
+
+
+
+
+ {risky && (
+
+
+
+
This command can cut management access.
+
+ {preview?.reasons.map((r, i) =>
{r}
)}
+
+
+ It is not blocked — you may know exactly why you are running it. With Change Guard on,
+ a device that stops answering afterwards restores itself.
+