diff --git a/README.md b/README.md index b86f0e8..6ada7e3 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 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. +

+
+ +
+