Browse Source

Added Spoolbuddy device control buttons to settings card

maziggy 1 month ago
parent
commit
05ecceda75

+ 1 - 0
CHANGELOG.md

@@ -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

+ 84 - 0
frontend/src/components/SpoolBuddySettings.tsx

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 it is too large
+ 0 - 0
static/assets/index-Bk8nW65f.js


+ 1 - 1
static/index.html

@@ -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>

Some files were not shown because too many files changed in this diff