Преглед изворни кода

Firmware version badge on printer cards (#311)

- Show firmware version badge on printer card (green=up to date,
  orange=update available) with click to view release notes or
  start update workflow
- Badge only shown when firmware checking is enabled in settings
- Modal adapts: info-only view when up to date (release notes
  auto-expanded), update workflow when update available
- Gate badge query with firmware:read permission, upload button
  with firmware:update permission
maziggy пре 3 месеци
родитељ
комит
cc7bbd6fbe

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **H2C Nozzle Rack — Translate Type Codes & Add Flow Info** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Raw nozzle type codes (e.g. "HS", "HH01") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New "Flow" row in the hover card. Translations added in all 4 locales (en, de, ja, it).
 - **H2C Nozzle Rack — Show Filament Material in Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle hover card now shows the loaded filament material type (e.g. "PLA", "PETG") alongside the color swatch, captured from MQTT nozzle info data.
 - **H2C Nozzle Rack Compact Layout** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.
+- **Firmware Version Badge on Printer Card** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect `firmware:read` and `firmware:update` permissions. Translations added in all 4 locales.
 - **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
 - **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
 

+ 1 - 1
README.md

@@ -172,7 +172,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Interval reminders (hours/days)
 - Print time accuracy stats
 - File manager for printer storage
-- Firmware update helper (LAN-only printers)
+- Firmware update helper with version badge (LAN-only printers)
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)

+ 92 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -183,4 +183,96 @@ describe('PrintersPage', () => {
       expect(disabledPrinter).toBeInTheDocument();
     });
   });
+
+  describe('firmware version badge', () => {
+    const firmwareUpToDate = {
+      printer_id: 1,
+      current_version: '01.09.00.00',
+      latest_version: '01.09.00.00',
+      update_available: false,
+      download_url: null,
+      release_notes: 'Bug fixes and improvements.',
+    };
+
+    const firmwareUpdateAvailable = {
+      printer_id: 1,
+      current_version: '01.08.00.00',
+      latest_version: '01.09.00.00',
+      update_available: true,
+      download_url: 'https://example.com/firmware.bin',
+      release_notes: 'New features added.',
+    };
+
+    it('shows green badge when firmware is up to date', async () => {
+      server.use(
+        http.get('/api/v1/firmware/updates/:id', () => {
+          return HttpResponse.json(firmwareUpToDate);
+        }),
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: true,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('01.09.00.00').length).toBeGreaterThan(0);
+      });
+
+      const badge = screen.getAllByText('01.09.00.00')[0].closest('button');
+      expect(badge).toBeInTheDocument();
+      expect(badge?.className).toContain('text-status-ok');
+    });
+
+    it('shows orange badge when firmware update is available', async () => {
+      server.use(
+        http.get('/api/v1/firmware/updates/:id', () => {
+          return HttpResponse.json(firmwareUpdateAvailable);
+        }),
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: true,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('01.08.00.00').length).toBeGreaterThan(0);
+      });
+
+      const badge = screen.getAllByText('01.08.00.00')[0].closest('button');
+      expect(badge).toBeInTheDocument();
+      expect(badge?.className).toContain('text-orange-400');
+    });
+
+    it('hides badge when firmware check is disabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: false,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Version should not appear when firmware check is disabled
+      expect(screen.queryByText('01.09.00.00')).not.toBeInTheDocument();
+      expect(screen.queryByText('01.08.00.00')).not.toBeInTheDocument();
+    });
+  });
 });

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -378,6 +378,7 @@ export default {
     openCameraWindow: 'Kamera in neuem Fenster öffnen',
     // Firmware
     firmwareUpdateAvailable: 'Firmware-Update verfügbar: {{current}} → {{latest}}',
+    firmwareUpToDate: 'Firmware {{version}} — Aktuell',
     firmwareUpdateButton: 'Update',
     // Plate detection
     plateDetection: {
@@ -414,6 +415,7 @@ export default {
     // Firmware modal
     firmwareModal: {
       title: 'Firmware-Update',
+      titleUpToDate: 'Firmware-Info',
       currentVersion: 'Aktuell:',
       latestVersion: 'Neueste:',
       releaseNotes: 'Versionshinweise',
@@ -428,6 +430,7 @@ export default {
       done: 'Fertig',
       starting: 'Starte...',
       uploadFirmware: 'Firmware hochladen',
+      uploadFailed: 'Upload fehlgeschlagen: {{error}}',
       uploadedToast: 'Firmware hochgeladen! Starten Sie das Update vom Druckerbildschirm.',
     },
     accessCodePlaceholder: 'Leer lassen, um den aktuellen zu behalten',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -378,6 +378,7 @@ export default {
     openCameraWindow: 'Open camera in new window',
     // Firmware
     firmwareUpdateAvailable: 'Firmware update available: {{current}} → {{latest}}',
+    firmwareUpToDate: 'Firmware {{version}} — Up to date',
     firmwareUpdateButton: 'Update',
     // Plate detection
     plateDetection: {
@@ -414,6 +415,7 @@ export default {
     // Firmware modal
     firmwareModal: {
       title: 'Firmware Update',
+      titleUpToDate: 'Firmware Info',
       currentVersion: 'Current:',
       latestVersion: 'Latest:',
       releaseNotes: 'Release Notes',
@@ -428,6 +430,7 @@ export default {
       done: 'Done',
       starting: 'Starting...',
       uploadFirmware: 'Upload Firmware',
+      uploadFailed: 'Failed to start upload: {{error}}',
       uploadedToast: 'Firmware uploaded! Trigger update from printer screen.',
     },
     accessCodePlaceholder: 'Leave empty to keep current',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -378,6 +378,7 @@ export default {
     openCameraWindow: 'Apri camera in nuova finestra',
     // Firmware
     firmwareUpdateAvailable: 'Aggiornamento firmware disponibile: {{current}} → {{latest}}',
+    firmwareUpToDate: 'Firmware {{version}} — Aggiornato',
     firmwareUpdateButton: 'Aggiorna',
     // Plate detection
     plateDetection: {
@@ -414,6 +415,7 @@ export default {
     // Firmware modal
     firmwareModal: {
       title: 'Aggiornamento Firmware',
+      titleUpToDate: 'Info Firmware',
       currentVersion: 'Corrente:',
       latestVersion: 'Ultima:',
       releaseNotes: 'Note di rilascio',
@@ -428,6 +430,7 @@ export default {
       done: 'Fatto',
       starting: 'Avvio...',
       uploadFirmware: 'Carica Firmware',
+      uploadFailed: 'Avvio caricamento fallito: {{error}}',
       uploadedToast: 'Firmware caricato! Avvia aggiornamento dal display.',
     },
     accessCodePlaceholder: 'Lascia vuoto per mantenere quello attuale',

+ 4 - 1
frontend/src/i18n/locales/ja.ts

@@ -335,6 +335,7 @@ export default {
     },
     filaments: 'フィラメント',
     firmwareUpdateAvailable: 'ファームウェアアップデートあり: {{current}} → {{latest}}',
+    firmwareUpToDate: 'ファームウェア {{version}} — 最新',
     plateDetection: {
       noPermission: 'このページにアクセスする権限がありません。',
       title: 'プレート検出',
@@ -377,7 +378,8 @@ export default {
     estimatedCompletion: '完了予定時刻',
     slotOptions: 'スロットオプション',
     firmwareModal: {
-      title: 'プリンター',
+      title: 'ファームウェアアップデート',
+      titleUpToDate: 'ファームウェア情報',
       currentVersion: '現在のバージョン',
       latestVersion: '最新バージョン',
       releaseNotes: 'リリースノート',
@@ -392,6 +394,7 @@ export default {
       uploadFirmware: 'ファームウェアをアップロード',
       checkingPrereqs: '前提条件を確認中...',
       uploadedSuccess: 'ファームウェアをSDカードにアップロードしました!',
+      uploadFailed: 'アップロード開始に失敗しました: {{error}}',
       uploadedToast: 'ファームウェアをアップロードしました!プリンター画面からアップデートを実行してください。',
     },
     accessCodePlaceholder: 'プリンター設定から取得',

+ 41 - 22
frontend/src/pages/PrintersPage.tsx

@@ -1260,7 +1260,7 @@ function PrinterCard({
     queryFn: () => firmwareApi.checkPrinterUpdate(printer.id),
     staleTime: 5 * 60 * 1000,
     refetchInterval: 5 * 60 * 1000,
-    enabled: checkPrinterFirmware,
+    enabled: checkPrinterFirmware && hasPermission('firmware:read'),
   });
 
   // Collect unique tray_info_idx values for cloud filament info lookup
@@ -1991,15 +1991,23 @@ function PrinterCard({
                   {queueCount}
                 </button>
               )}
-              {/* Firmware Update Badge */}
-              {firmwareInfo?.update_available && (
+              {/* Firmware Version Badge */}
+              {firmwareInfo?.current_version && (
                 <button
                   onClick={() => setShowFirmwareModal(true)}
-                  className="flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-orange-500/20 text-orange-400 hover:opacity-80 transition-opacity"
-                  title={t('printers.firmwareUpdateAvailable', { current: firmwareInfo.current_version, latest: firmwareInfo.latest_version })}
+                  className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs hover:opacity-80 transition-opacity ${
+                    firmwareInfo.update_available
+                      ? 'bg-orange-500/20 text-orange-400'
+                      : 'bg-status-ok/20 text-status-ok'
+                  }`}
+                  title={
+                    firmwareInfo.update_available
+                      ? t('printers.firmwareUpdateAvailable', { current: firmwareInfo.current_version, latest: firmwareInfo.latest_version })
+                      : t('printers.firmwareUpToDate', { version: firmwareInfo.current_version })
+                  }
                 >
-                  <Download className="w-3 h-3" />
-                  {t('printers.firmwareUpdateButton')}
+                  {firmwareInfo.update_available ? <Download className="w-3 h-3" /> : <CheckCircle className="w-3 h-3" />}
+                  {firmwareInfo.current_version}
                 </button>
               )}
             </div>
@@ -4045,15 +4053,18 @@ function FirmwareUpdateModal({
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+  const canUpdate = hasPermission('firmware:update');
   const [uploadStatus, setUploadStatus] = useState<FirmwareUploadStatus | null>(null);
   const [isUploading, setIsUploading] = useState(false);
   const [pollInterval, setPollInterval] = useState<NodeJS.Timeout | null>(null);
 
-  // Prepare check query
+  // Prepare check query (only when update available and user can update)
   const { data: prepareInfo, isLoading: isPreparing } = useQuery({
     queryKey: ['firmwarePrepare', printer.id],
     queryFn: () => firmwareApi.prepareUpload(printer.id),
     staleTime: 30000,
+    enabled: firmwareInfo.update_available && canUpdate,
   });
 
   // Start upload mutation
@@ -4082,7 +4093,7 @@ function FirmwareUpdateModal({
       setPollInterval(interval);
     },
     onError: (error: Error) => {
-      showToast(`Failed to start upload: ${error.message}`, 'error');
+      showToast(t('printers.firmwareModal.uploadFailed', { error: error.message }), 'error');
       setIsUploading(false);
     },
   });
@@ -4104,11 +4115,15 @@ function FirmwareUpdateModal({
       <Card className="w-full max-w-md mx-4">
         <CardContent>
           <div className="flex items-start gap-3 mb-4">
-            <div className="p-2 rounded-full bg-orange-500/20">
-              <Download className="w-5 h-5 text-orange-400" />
+            <div className={`p-2 rounded-full ${firmwareInfo.update_available ? 'bg-orange-500/20' : 'bg-status-ok/20'}`}>
+              {firmwareInfo.update_available
+                ? <Download className="w-5 h-5 text-orange-400" />
+                : <CheckCircle className="w-5 h-5 text-status-ok" />}
             </div>
             <div className="flex-1">
-              <h3 className="text-lg font-semibold text-white">{t('printers.firmwareModal.title')}</h3>
+              <h3 className="text-lg font-semibold text-white">
+                {firmwareInfo.update_available ? t('printers.firmwareModal.title') : t('printers.firmwareModal.titleUpToDate')}
+              </h3>
               <p className="text-sm text-bambu-gray mt-1">
                 {printer.name}
               </p>
@@ -4119,15 +4134,19 @@ function FirmwareUpdateModal({
           <div className="bg-bambu-dark rounded-lg p-3 mb-4">
             <div className="flex justify-between items-center text-sm">
               <span className="text-bambu-gray">{t('printers.firmwareModal.currentVersion')}</span>
-              <span className="text-white font-mono">{firmwareInfo.current_version || t('common.unknown')}</span>
-            </div>
-            <div className="flex justify-between items-center text-sm mt-1">
-              <span className="text-bambu-gray">{t('printers.firmwareModal.latestVersion')}</span>
-              <span className="text-orange-400 font-mono">{firmwareInfo.latest_version}</span>
+              <span className={`font-mono ${firmwareInfo.update_available ? 'text-white' : 'text-status-ok'}`}>
+                {firmwareInfo.current_version || t('common.unknown')}
+              </span>
             </div>
+            {firmwareInfo.update_available && (
+              <div className="flex justify-between items-center text-sm mt-1">
+                <span className="text-bambu-gray">{t('printers.firmwareModal.latestVersion')}</span>
+                <span className="text-orange-400 font-mono">{firmwareInfo.latest_version}</span>
+              </div>
+            )}
             {firmwareInfo.release_notes && (
-              <details className="mt-3 text-sm">
-                <summary className="text-orange-400 cursor-pointer hover:underline">
+              <details className="mt-3 text-sm" open={!firmwareInfo.update_available}>
+                <summary className={`cursor-pointer hover:underline ${firmwareInfo.update_available ? 'text-orange-400' : 'text-status-ok'}`}>
                   {t('printers.firmwareModal.releaseNotes')}
                 </summary>
                 <div className="mt-2 text-bambu-gray text-xs max-h-40 overflow-y-auto whitespace-pre-wrap">
@@ -4137,8 +4156,8 @@ function FirmwareUpdateModal({
             )}
           </div>
 
-          {/* Status / Progress */}
-          {isPreparing ? (
+          {/* Status / Progress (only when update available) */}
+          {!firmwareInfo.update_available ? null : isPreparing ? (
             <div className="flex items-center gap-2 text-bambu-gray text-sm mb-4">
               <Loader2 className="w-4 h-4 animate-spin" />
               {t('printers.firmwareModal.checkingPrereqs')}
@@ -4209,7 +4228,7 @@ function FirmwareUpdateModal({
             <Button variant="secondary" onClick={onClose}>
               {uploadStatus?.status === 'complete' ? t('printers.firmwareModal.done') : t('common.cancel')}
             </Button>
-            {prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && (
+            {prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && canUpdate && (
               <Button
                 onClick={handleStartUpload}
                 disabled={uploadMutation.isPending}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-Az-fQOvm.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-BAz5WA4r.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-Csb7GscS.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BJkEGpKa.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Csb7GscS.css">
+    <script type="module" crossorigin src="/assets/index-BAz5WA4r.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Az-fQOvm.css">
   </head>
   <body>
     <div id="root"></div>

Неке датотеке нису приказане због велике количине промена