Added Spoolbuddy device control buttons to settings card

This commit is contained in:
maziggy
2026-04-14 09:48:23 +02:00
parent 4a5b608a81
commit 05ecceda75
5 changed files with 219 additions and 115 deletions
+1
View File
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Requires that the External URL setting (General tab) points to a hostname/IP reachable from the ML API container, since the ML API fetches snapshots by URL.
### Improved
- **Spoolbuddy Device Controls in Settings** ([#962](https://github.com/maziggy/bambuddy/issues/962)) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing `/spoolbuddy/devices/{id}/update` and `/spoolbuddy/devices/{id}/system/command` endpoints — no new backend work needed. Thanks to @TravisWilder for the request.
- **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.
### Changed
@@ -14,6 +14,11 @@ import {
CheckCircle2,
XCircle,
Clock,
Download,
Monitor,
RefreshCw,
RotateCw,
Power,
} from 'lucide-react';
import { spoolbuddyApi, type SpoolBuddyDevice } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
@@ -46,12 +51,59 @@ interface DeviceCardProps {
isDeleting: boolean;
}
type ActionKey = 'update' | 'restart_browser' | 'restart_daemon' | 'reboot' | 'shutdown';
function DeviceCard({ device, onUnregister, isDeleting }: DeviceCardProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const stats = device.system_stats;
const mem = stats?.memory;
const disk = stats?.disk;
const online = device.online;
const [pendingAction, setPendingAction] = useState<ActionKey | null>(null);
const [busyAction, setBusyAction] = useState<ActionKey | null>(null);
const runAction = async (action: ActionKey) => {
setBusyAction(action);
try {
if (action === 'update') {
await spoolbuddyApi.triggerUpdate(device.device_id);
} else {
await spoolbuddyApi.systemCommand(device.device_id, action);
}
showToast(t('settings.spoolbuddy.commandQueued'), 'success');
} catch (err) {
const msg = err instanceof Error ? err.message : t('settings.spoolbuddy.commandError');
showToast(msg, 'error');
} finally {
setBusyAction(null);
setPendingAction(null);
}
};
const actions: { key: ActionKey; label: string; icon: typeof Download; variant?: 'danger' }[] = [
{ key: 'update', label: t('settings.spoolbuddy.update'), icon: Download },
{ key: 'restart_browser', label: t('settings.spoolbuddy.restartBrowser'), icon: Monitor },
{ key: 'restart_daemon', label: t('settings.spoolbuddy.restartDaemon'), icon: RefreshCw },
{ key: 'reboot', label: t('settings.spoolbuddy.reboot'), icon: RotateCw },
{ key: 'shutdown', label: t('settings.spoolbuddy.shutdown'), icon: Power, variant: 'danger' },
];
const confirmTitles: Record<ActionKey, string> = {
update: t('settings.spoolbuddy.updateConfirmTitle'),
restart_browser: t('settings.spoolbuddy.restartBrowserConfirmTitle'),
restart_daemon: t('settings.spoolbuddy.restartDaemonConfirmTitle'),
reboot: t('settings.spoolbuddy.rebootConfirmTitle'),
shutdown: t('settings.spoolbuddy.shutdownConfirmTitle'),
};
const confirmBodies: Record<ActionKey, string> = {
update: t('settings.spoolbuddy.updateConfirmBody', { hostname: device.hostname }),
restart_browser: t('settings.spoolbuddy.restartBrowserConfirmBody', { hostname: device.hostname }),
restart_daemon: t('settings.spoolbuddy.restartDaemonConfirmBody', { hostname: device.hostname }),
reboot: t('settings.spoolbuddy.rebootConfirmBody', { hostname: device.hostname }),
shutdown: t('settings.spoolbuddy.shutdownConfirmBody', { hostname: device.hostname }),
};
return (
<Card>
@@ -111,6 +163,27 @@ function DeviceCard({ device, onUnregister, isDeleting }: DeviceCardProps) {
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{actions.map(({ key, label, icon: Icon, variant }) => (
<Button
key={key}
variant={variant ?? 'secondary'}
size="sm"
onClick={() => setPendingAction(key)}
disabled={!online || busyAction !== null}
aria-label={label}
>
{busyAction === key ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
<span>{label}</span>
</Button>
))}
</div>
{/* Hardware flags */}
<div className="flex items-center gap-3 text-xs flex-wrap">
<span className="flex items-center gap-1 text-bambu-gray">
@@ -187,6 +260,17 @@ function DeviceCard({ device, onUnregister, isDeleting }: DeviceCardProps) {
</div>
)}
</CardContent>
{pendingAction && (
<ConfirmModal
variant={pendingAction === 'shutdown' || pendingAction === 'reboot' ? 'danger' : 'default'}
title={confirmTitles[pendingAction]}
message={confirmBodies[pendingAction]}
confirmText={t('settings.spoolbuddy.commandConfirm')}
isLoading={busyAction !== null}
onConfirm={() => runAction(pendingAction)}
onCancel={() => setPendingAction(null)}
/>
)}
</Card>
);
}
+19
View File
@@ -1356,6 +1356,25 @@ export default {
cpuTemp: 'CPU temp',
memory: 'Memory',
disk: 'Disk',
// Device actions
update: 'Update',
updateConfirmTitle: 'Update Spoolbuddy daemon?',
updateConfirmBody: 'Trigger a software update on "{{hostname}}"? The daemon will restart once the update is applied.',
restartBrowser: 'Restart Browser',
restartBrowserConfirmTitle: 'Restart kiosk browser?',
restartBrowserConfirmBody: 'Restart the kiosk browser on "{{hostname}}"? The display will blank briefly.',
restartDaemon: 'Restart Daemon',
restartDaemonConfirmTitle: 'Restart Spoolbuddy daemon?',
restartDaemonConfirmBody: 'Restart the Spoolbuddy daemon on "{{hostname}}"? The device will go offline for a few seconds.',
reboot: 'Reboot',
rebootConfirmTitle: 'Reboot device?',
rebootConfirmBody: 'Reboot "{{hostname}}"? The device will be offline for around a minute.',
shutdown: 'Shutdown',
shutdownConfirmTitle: 'Shutdown device?',
shutdownConfirmBody: 'Shutdown "{{hostname}}"? You will need physical access to power it back on.',
commandConfirm: 'Confirm',
commandQueued: 'Command queued',
commandError: 'Failed to send command',
},
// LDAP settings
ldap: {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BQYDsJHk.js"></script>
<script type="module" crossorigin src="/assets/index-Bk8nW65f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7mnhxng.css">
</head>
<body>