feat: RouterBOOT in staged rollouts, and snoozeable insights (v0.23.10-beta)
Closes #113, #102. #113 — RouterBOOT as a follow-on rollout step Firmware rollouts upgraded RouterOS and stopped, leaving the bootloader to be done by hand on every device — the exact task staged rollouts exist to remove. The data was already collected and the collector already had the two methods; the orchestrator simply never called them. An optional "Then RouterBOOT" step now runs per device after the RouterOS upgrade has been verified, rides out the second reboot, and confirms the firmware version actually moved. Ordering is deliberate: the bootloader ships inside the RouterOS package, so upgrading it first would apply the old one. It runs only once the OS half is confirmed good, and off by default, because it is a second flash and a second reboot per device. A device with nothing pending is a success rather than a failure — most of a fleet is already current, and treating "nothing to do" as an error would halt rollouts for no reason. When RouterBOOT does fail after a successful RouterOS upgrade the error says so explicitly, rather than reporting the device as simply failed and sending someone to re-run an upgrade it already has. #102 — snooze an item in "Things to handle" Insights are recomputed from live state on every request and have no id, so a dismissal is keyed on a fingerprint of category, target and title. That survives a reworded title without swallowing a different device's version of the same issue. Dismissals expire, defaulting to 24 hours. "Handled" and "not now" look identical to the code, and an item that is genuinely resolved stops being generated anyway — so a permanent dismissal could only ever hide something still true. Expired rows are cleared on read, so a lapse needs no scheduled job, and the panel reports how many items are currently hidden rather than quietly shrinking. Verified against the fleet: snoozing took the list from 8 items to 7 with dismissedCount 1, and removing the dismissal restored it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
60d7d13bd3
commit
37887d38c2
@@ -16,7 +16,7 @@ import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../servi
|
||||
import TerminalModal from '../components/TerminalModal';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import type { Device, DeviceEvent } from '../types';
|
||||
import clsx from 'clsx';
|
||||
@@ -608,6 +608,11 @@ function OperationsView({
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? [];
|
||||
const dismissInsight = useMutation({
|
||||
mutationFn: (v: { fingerprint: string; hours: number; category?: string; title?: string }) =>
|
||||
operationsApi.dismissInsight(v.fingerprint, v.hours, v.category, v.title),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ops-insights'] }),
|
||||
});
|
||||
const capacity: OpsCapacityRow[] = insights?.capacity ?? [];
|
||||
const activityAll: OpsActivityItem[] = insights?.activity ?? [];
|
||||
// The app's own write requests are audit-log noise from an operations point of
|
||||
@@ -840,6 +845,24 @@ function OperationsView({
|
||||
>
|
||||
{item.action} →
|
||||
</button>
|
||||
{/* "Not now" rather than "never": an item that is genuinely resolved
|
||||
stops being generated, so a dismissal is time-boxed (#102). */}
|
||||
{item.fingerprint && (
|
||||
<button
|
||||
onClick={() => dismissInsight.mutate({
|
||||
fingerprint: item.fingerprint!, hours: 24,
|
||||
category: item.category, title: item.title,
|
||||
})}
|
||||
disabled={dismissInsight.isPending}
|
||||
title="Hide this for 24 hours. It returns if it is still true."
|
||||
className="text-[11.5px] rounded-[5px] px-2 py-[6px] flex-shrink-0 transition-colors"
|
||||
style={{ background: 'transparent', border: '1px solid var(--line)', color: 'var(--ink-3)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-3)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
Snooze
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex items-center justify-center gap-2 py-8" style={{ color: 'var(--ink-3)' }}>
|
||||
@@ -847,6 +870,11 @@ function OperationsView({
|
||||
<span className="text-[13px]">Nothing needs attention</span>
|
||||
</div>
|
||||
)}
|
||||
{(insights?.dismissedCount ?? 0) > 0 && (
|
||||
<div className="px-5 py-2 text-[11.5px]" style={{ color: 'var(--ink-3)', borderTop: '1px solid var(--line-soft)' }}>
|
||||
{insights!.dismissedCount} snoozed — they return automatically if still true.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowUpCircle, RefreshCw, CheckCircle, XCircle, AlertTriangle, Clock,
|
||||
HardDrive, ShieldAlert, Rocket, Ban, ChevronRight,
|
||||
HardDrive, ShieldAlert, Rocket, Ban, ChevronRight, Cpu,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { firmwareApi } from '../services/api';
|
||||
@@ -73,6 +73,7 @@ function RolloutPanel({ rolloutId, canWrite }: { rolloutId: number; canWrite: bo
|
||||
<span className="ml-auto flex items-center gap-3 text-xs text-gray-400 dark:text-slate-500">
|
||||
{rollout.pre_backup && <span className="flex items-center gap-1"><HardDrive className="w-3 h-3" />pre-backup</span>}
|
||||
{rollout.halt_on_failure && <span className="flex items-center gap-1"><ShieldAlert className="w-3 h-3" />halt on failure</span>}
|
||||
{rollout.routerboot_after && <span className="flex items-center gap-1"><Cpu className="w-3 h-3" />+ RouterBOOT</span>}
|
||||
{canWrite && active && (
|
||||
<button onClick={() => cancelMutation.mutate()} disabled={cancelMutation.isPending}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
@@ -124,6 +125,8 @@ export default function FirmwarePage() {
|
||||
const [rolloutName, setRolloutName] = useState('');
|
||||
const [preBackup, setPreBackup] = useState(true);
|
||||
const [haltOnFailure, setHaltOnFailure] = useState(true);
|
||||
// Off by default: it is a second flash and a second reboot per device (#113).
|
||||
const [routerbootAfter, setRouterbootAfter] = useState(false);
|
||||
const [scheduleAt, setScheduleAt] = useState('');
|
||||
const [viewRolloutId, setViewRolloutId] = useState<number | null>(null);
|
||||
const [checkResult, setCheckResult] = useState<string | null>(null);
|
||||
@@ -170,6 +173,7 @@ export default function FirmwarePage() {
|
||||
name: rolloutName.trim() || `RouterOS upgrade ${new Date().toISOString().slice(0, 10)}`,
|
||||
halt_on_failure: haltOnFailure,
|
||||
pre_backup: preBackup,
|
||||
routerboot_after: routerbootAfter,
|
||||
scheduled_at: scheduleAt ? new Date(scheduleAt).toISOString() : null,
|
||||
start: !scheduleAt,
|
||||
devices: [...selected.entries()].map(([device_id, wave]) => ({ device_id, wave })),
|
||||
@@ -345,6 +349,13 @@ export default function FirmwarePage() {
|
||||
<input type="checkbox" className="w-4 h-4 rounded" checked={haltOnFailure} onChange={e => setHaltOnFailure(e.target.checked)} />
|
||||
Halt on failure
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300 cursor-pointer"
|
||||
title="After each RouterOS upgrade verifies, apply any pending RouterBOOT upgrade. This is a second flash and a second reboot per device."
|
||||
>
|
||||
<input type="checkbox" className="w-4 h-4 rounded" checked={routerbootAfter} onChange={e => setRouterbootAfter(e.target.checked)} />
|
||||
Then RouterBOOT
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<input type="datetime-local" className="input py-1 text-xs w-auto" value={scheduleAt} onChange={e => setScheduleAt(e.target.value)} />
|
||||
|
||||
@@ -597,7 +597,7 @@ export interface FirmwareDeviceRow {
|
||||
}
|
||||
export interface FirmwareRollout {
|
||||
id: number; name: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
halt_on_failure: boolean; pre_backup: boolean;
|
||||
halt_on_failure: boolean; pre_backup: boolean; routerboot_after: boolean;
|
||||
scheduled_at: string | null; started_at: string | null; finished_at: string | null; created_at: string;
|
||||
device_count?: number; success_count?: number; failed_count?: number;
|
||||
}
|
||||
@@ -616,7 +616,7 @@ export const firmwareApi = {
|
||||
api.post<{ results: { name: string; ok: boolean; installed?: string; latest?: string; available?: boolean; error?: string }[] }>(
|
||||
'/firmware/check-all', undefined, { timeout: 180_000 }),
|
||||
createRollout: (data: {
|
||||
name: string; halt_on_failure: boolean; pre_backup: boolean;
|
||||
name: string; halt_on_failure: boolean; pre_backup: boolean; routerboot_after?: boolean;
|
||||
scheduled_at?: string | null; start?: boolean;
|
||||
devices: { device_id: number; wave: number }[];
|
||||
}) => api.post<{ id: number }>('/firmware/rollouts', data),
|
||||
@@ -637,6 +637,8 @@ export interface OpsAttentionItem {
|
||||
body: string;
|
||||
action: string;
|
||||
path: string;
|
||||
/** Stable identity used to dismiss an item that is otherwise recomputed. */
|
||||
fingerprint?: string;
|
||||
}
|
||||
export interface OpsCapacityRow { id: number; name: string; cpu: number; mem_pct: number }
|
||||
export interface OpsActivityItem {
|
||||
@@ -648,6 +650,8 @@ export interface OpsActivityItem {
|
||||
}
|
||||
export interface OpsInsights {
|
||||
attention: OpsAttentionItem[];
|
||||
/** How many items are currently hidden by a dismissal. */
|
||||
dismissedCount?: number;
|
||||
capacity: OpsCapacityRow[];
|
||||
activity: OpsActivityItem[];
|
||||
}
|
||||
@@ -655,6 +659,13 @@ export interface OpsActionResult { total: number; results: { name: string; ok: b
|
||||
|
||||
export const operationsApi = {
|
||||
insights: () => api.get<OpsInsights>('/operations/insights'),
|
||||
dismissInsight: (fingerprint: string, hours: number, category?: string, title?: string) =>
|
||||
api.post('/operations/insights/dismiss', { fingerprint, hours, category, title }),
|
||||
listDismissed: () =>
|
||||
api.get<{ fingerprint: string; category: string | null; title: string | null; expires_at: string }[]>(
|
||||
'/operations/insights/dismissed'),
|
||||
undismissInsight: (fingerprint: string) =>
|
||||
api.delete(`/operations/insights/dismiss/${encodeURIComponent(fingerprint)}`),
|
||||
backupAll: () => api.post<OpsActionResult>('/operations/backup-all', undefined, { timeout: 180_000 }),
|
||||
syncAll: () => api.post<OpsActionResult>('/operations/sync-all', undefined, { timeout: 180_000 }),
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.9 Beta';
|
||||
export const APP_VERSION = 'v0.23.10 Beta';
|
||||
|
||||
Reference in New Issue
Block a user