فهرست منبع

Merge branch '0.2.0b' into feature/ui_improvements

MartinNYHC 6 ماه پیش
والد
کامیت
bb4687a832

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
 - **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
 - **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
+- **Clear HMS Errors** — New "Clear Errors" button in the HMS error modal sends a `clean_print_error` MQTT command to dismiss stale `print_error` values that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated to `printers:control`. The button only appears when there are active errors.
 
 
 ### Fixed
 ### Fixed
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.

+ 1 - 1
README.md

@@ -91,7 +91,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS slot RFID re-read
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - Dual external spool support for H2D (Ext-L / Ext-R)
-- HMS error monitoring with history
+- HMS error monitoring with history and clear errors
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
 - Cost analytics & failure analysis
 - Cost analytics & failure analysis

+ 23 - 0
backend/app/api/routes/printers.py

@@ -1958,6 +1958,29 @@ async def set_chamber_light(
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
 
 
 
 
+@router.post("/{printer_id}/hms/clear")
+async def clear_hms_errors(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Clear HMS/print errors on the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.clear_hms_errors()
+    if not success:
+        raise HTTPException(500, "Failed to clear HMS errors")
+
+    return {"success": True, "message": "HMS errors cleared"}
+
+
 @router.get("/{printer_id}/print/objects")
 @router.get("/{printer_id}/print/objects")
 async def get_printable_objects(
 async def get_printable_objects(
     printer_id: int,
     printer_id: int,

+ 12 - 0
backend/app/services/bambu_mqtt.py

@@ -2861,6 +2861,18 @@ class BambuMQTTClient:
         logger.info("[%s] Sent resume print command", self.serial_number)
         logger.info("[%s] Sent resume print command", self.serial_number)
         return True
         return True
 
 
+    def clear_hms_errors(self) -> bool:
+        """Clear HMS/print errors on the printer and locally."""
+        if not self._client or not self.state.connected:
+            logger.warning("[%s] Cannot clear HMS errors: not connected", self.serial_number)
+            return False
+
+        command = {"print": {"command": "clean_print_error", "sequence_id": "0"}}
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        self.state.hms_errors = []
+        logger.info("[%s] Sent clear HMS errors command", self.serial_number)
+        return True
+
     def skip_objects(self, object_ids: list[int]) -> bool:
     def skip_objects(self, object_ids: list[int]) -> bool:
         """Skip specific objects during a print.
         """Skip specific objects during a print.
 
 

+ 62 - 0
backend/tests/integration/test_printers_api.py

@@ -887,3 +887,65 @@ class TestChamberLightAPI:
 
 
             assert response.status_code == 500
             assert response.status_code == 500
             assert "failed" in response.json()["detail"].lower()
             assert "failed" in response.json()["detail"].lower()
+
+
+class TestClearHMSErrorsAPI:
+    """Integration tests for clear HMS errors endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/hms/clear")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful clear HMS errors request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.clear_hms_errors.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "cleared" in result["message"].lower()
+            mock_client.clear_hms_errors.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify error handling when clear HMS errors fails."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.clear_hms_errors.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()

+ 146 - 0
frontend/src/__tests__/components/HMSErrorModal.test.tsx

@@ -0,0 +1,146 @@
+/**
+ * Tests for the HMSErrorModal component.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { HMSErrorModal } from '../../components/HMSErrorModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import type { HMSError } from '../../api/client';
+
+// Error code 0300_400C = "The task was canceled." (known code in the database)
+const knownError: HMSError = {
+  attr: 0x0300,
+  code: '0x400C',
+  severity: 2,
+};
+
+// Error code FFFF_FFFF = unknown (not in the database)
+const unknownError: HMSError = {
+  attr: 0xFFFF,
+  code: '0xFFFF',
+  severity: 1,
+};
+
+describe('HMSErrorModal', () => {
+  const defaultProps = {
+    printerName: 'Test Printer',
+    errors: [knownError],
+    onClose: vi.fn(),
+    printerId: 1,
+    hasPermission: vi.fn().mockReturnValue(true) as unknown as (permission: 'printers:control') => boolean,
+  };
+
+  afterEach(() => {
+    cleanup();
+    vi.clearAllMocks();
+  });
+
+  describe('rendering', () => {
+    it('renders the modal title with printer name', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('Errors - Test Printer')).toBeInTheDocument();
+    });
+
+    it('shows error description for known error codes', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
+    });
+
+    it('shows no errors message when all errors are unknown', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
+      expect(screen.getByText('No errors')).toBeInTheDocument();
+    });
+
+    it('shows no errors message when errors array is empty', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[]} />);
+      expect(screen.getByText('No errors')).toBeInTheDocument();
+    });
+  });
+
+  describe('clear errors button', () => {
+    it('shows clear button when there are known errors', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('Clear Errors')).toBeInTheDocument();
+    });
+
+    it('hides clear button when there are no known errors', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[]} />);
+      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
+    });
+
+    it('hides clear button when all errors are unknown codes', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
+      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
+    });
+
+    it('disables clear button when user lacks permission', () => {
+      const noPermission = vi.fn().mockReturnValue(false) as unknown as (permission: 'printers:control') => boolean;
+      render(<HMSErrorModal {...defaultProps} hasPermission={noPermission} />);
+      expect(screen.getByText('Clear Errors').closest('button')).toBeDisabled();
+    });
+
+    it('calls API and closes modal on successful clear', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+
+      server.use(
+        http.post('/api/v1/printers/1/hms/clear', () => {
+          return HttpResponse.json({ success: true, message: 'HMS errors cleared' });
+        })
+      );
+
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      await user.click(screen.getByText('Clear Errors'));
+
+      await waitFor(() => {
+        expect(onClose).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    it('shows error toast on failed clear', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+
+      server.use(
+        http.post('/api/v1/printers/1/hms/clear', () => {
+          return HttpResponse.json({ detail: 'Failed' }, { status: 500 });
+        })
+      );
+
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      await user.click(screen.getByText('Clear Errors'));
+
+      await waitFor(() => {
+        expect(onClose).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('interactions', () => {
+    it('calls onClose when X button is clicked', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      // The X button is the button with the X icon in the header
+      const closeButtons = screen.getAllByRole('button');
+      // First button is the X close button in the header
+      await user.click(closeButtons[0]);
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      const onClose = vi.fn();
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+  });
+});

+ 4 - 0
frontend/src/api/client.ts

@@ -2344,6 +2344,10 @@ export const api = {
       }
       }
     ),
     ),
 
 
+  // HMS Errors
+  clearHMSErrors: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/hms/clear`, { method: 'POST' }),
+
   // AMS Control
   // AMS Control
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(

+ 34 - 6
frontend/src/components/HMSErrorModal.tsx

@@ -2,13 +2,18 @@
 // Source: https://github.com/greghesp/ha-bambulab
 // Source: https://github.com/greghesp/ha-bambulab
 import { useEffect } from 'react';
 import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
-import type { HMSError } from '../api/client';
+import { useMutation } from '@tanstack/react-query';
+import { X, AlertTriangle, AlertCircle, Info, ExternalLink, Loader2, Trash2 } from 'lucide-react';
+import type { HMSError, Permission } from '../api/client';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
 
 
 interface HMSErrorModalProps {
 interface HMSErrorModalProps {
   printerName: string;
   printerName: string;
   errors: HMSError[];
   errors: HMSError[];
   onClose: () => void;
   onClose: () => void;
+  printerId: number;
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
 // Comprehensive error code database (short format: XXXX_YYYY)
 // Comprehensive error code database (short format: XXXX_YYYY)
@@ -904,11 +909,20 @@ function getHMSHomeUrl(): string {
   return `https://wiki.bambulab.com/en/hms/home`;
   return `https://wiki.bambulab.com/en/hms/home`;
 }
 }
 
 
-export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission }: HMSErrorModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { showToast } = useToast();
 
 
-  // Debug: log errors to see what data we're receiving
-  console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
+  const clearMutation = useMutation({
+    mutationFn: () => api.clearHMSErrors(printerId),
+    onSuccess: () => {
+      showToast(t('hmsErrors.clearSuccess'), 'success');
+      onClose();
+    },
+    onError: () => {
+      showToast(t('hmsErrors.clearFailed'), 'error');
+    },
+  });
 
 
   // Filter to only show errors we have descriptions for (skip unknown codes)
   // Filter to only show errors we have descriptions for (skip unknown codes)
   const knownErrors = errors.filter((error) => {
   const knownErrors = errors.filter((error) => {
@@ -994,10 +1008,24 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         </div>
         </div>
 
 
         {/* Footer */}
         {/* Footer */}
-        <div className="p-4 border-t border-bambu-dark-tertiary">
+        <div className="p-4 border-t border-bambu-dark-tertiary flex items-center justify-between gap-3">
           <p className="text-xs text-bambu-gray">
           <p className="text-xs text-bambu-gray">
             {t('hmsErrors.clearInstructions')}
             {t('hmsErrors.clearInstructions')}
           </p>
           </p>
+          {knownErrors.length > 0 && (
+            <button
+              onClick={() => clearMutation.mutate()}
+              disabled={!hasPermission('printers:control') || clearMutation.isPending}
+              className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
+            >
+              {clearMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Trash2 className="w-4 h-4" />
+              )}
+              {t('hmsErrors.clearErrors')}
+            </button>
+          )}
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 2 - 2
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -1,6 +1,6 @@
 import { Layers, Check, AlertTriangle } from 'lucide-react';
 import { Layers, Check, AlertTriangle } from 'lucide-react';
-import { formatTime } from '../../utils/amsHelpers';
 import type { PlateSelectorProps } from './types';
 import type { PlateSelectorProps } from './types';
+import { formatDuration } from '../../utils/date';
 
 
 /**
 /**
  * Plate selection grid for multi-plate 3MF files.
  * Plate selection grid for multi-plate 3MF files.
@@ -61,7 +61,7 @@ export function PlateSelector({
                   ? plate.objects.slice(0, 3).join(', ') +
                   ? plate.objects.slice(0, 3).join(', ') +
                     (plate.objects.length > 3 ? '...' : '')
                     (plate.objects.length > 3 ? '...' : '')
                   : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
                   : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                {plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
+                {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
               </p>
               </p>
             </div>
             </div>
             {selectedPlate === plate.index && (
             {selectedPlate === plate.index && (

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

@@ -86,6 +86,7 @@ export default {
     unknown: 'Unbekannt',
     unknown: 'Unbekannt',
     unknownError: 'Unbekannter Fehler',
     unknownError: 'Unbekannter Fehler',
     today: 'Heute',
     today: 'Heute',
+    tomorrow: 'Morgen',
     asap: 'Sofort',
     asap: 'Sofort',
     overdue: 'Überfällig',
     overdue: 'Überfällig',
     now: 'Jetzt',
     now: 'Jetzt',
@@ -1602,6 +1603,9 @@ export default {
     noErrors: 'Keine Fehler',
     noErrors: 'Keine Fehler',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
+    clearErrors: 'Fehler löschen',
+    clearSuccess: 'HMS-Fehler gelöscht',
+    clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1875,7 +1879,6 @@ export default {
     cameraStream: 'Kamera-Stream',
     cameraStream: 'Kamera-Stream',
     progress: 'Fortschritt',
     progress: 'Fortschritt',
     eta: 'ETA',
     eta: 'ETA',
-    tomorrow: 'Morgen',
     printerIdle: 'Drucker ist inaktiv',
     printerIdle: 'Drucker ist inaktiv',
     printerOffline: 'Drucker offline',
     printerOffline: 'Drucker offline',
     status: {
     status: {

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

@@ -86,6 +86,7 @@ export default {
     unknown: 'Unknown',
     unknown: 'Unknown',
     unknownError: 'Unknown error',
     unknownError: 'Unknown error',
     today: 'Today',
     today: 'Today',
+    tomorrow: 'Tomorrow',
     asap: 'ASAP',
     asap: 'ASAP',
     overdue: 'Overdue',
     overdue: 'Overdue',
     now: 'Now',
     now: 'Now',
@@ -1602,6 +1603,9 @@ export default {
     noErrors: 'No errors',
     noErrors: 'No errors',
     viewOnWiki: 'View on Bambu Lab Wiki',
     viewOnWiki: 'View on Bambu Lab Wiki',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
+    clearErrors: 'Clear Errors',
+    clearSuccess: 'HMS errors cleared',
+    clearFailed: 'Failed to clear HMS errors',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1875,7 +1879,6 @@ export default {
     cameraStream: 'Camera stream',
     cameraStream: 'Camera stream',
     progress: 'Progress',
     progress: 'Progress',
     eta: 'ETA',
     eta: 'ETA',
-    tomorrow: 'Tomorrow',
     printerIdle: 'Printer is idle',
     printerIdle: 'Printer is idle',
     printerOffline: 'Printer offline',
     printerOffline: 'Printer offline',
     status: {
     status: {

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

@@ -86,6 +86,7 @@ export default {
     unknown: 'Inconnu',
     unknown: 'Inconnu',
     unknownError: 'Erreur inconnue',
     unknownError: 'Erreur inconnue',
     today: 'Aujourd\'hui',
     today: 'Aujourd\'hui',
+    tomorrow: 'Demain',
     asap: 'Dès que possible',
     asap: 'Dès que possible',
     overdue: 'En retard',
     overdue: 'En retard',
     now: 'Maintenant',
     now: 'Maintenant',
@@ -1598,6 +1599,9 @@ export default {
     noErrors: 'Aucune erreur',
     noErrors: 'Aucune erreur',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
+    clearErrors: 'Effacer les erreurs',
+    clearSuccess: 'Erreurs HMS effacées',
+    clearFailed: 'Échec de l\'effacement des erreurs HMS',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1871,7 +1875,6 @@ export default {
     cameraStream: 'Flux caméra',
     cameraStream: 'Flux caméra',
     progress: 'Progression',
     progress: 'Progression',
     eta: 'Fin estimée',
     eta: 'Fin estimée',
-    tomorrow: 'Demain',
     printerIdle: 'Imprimante inactive',
     printerIdle: 'Imprimante inactive',
     printerOffline: 'Imprimante hors ligne',
     printerOffline: 'Imprimante hors ligne',
     status: {
     status: {

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

@@ -83,6 +83,7 @@ export default {
     unknown: 'Sconosciuto',
     unknown: 'Sconosciuto',
     unknownError: 'Errore sconosciuto',
     unknownError: 'Errore sconosciuto',
     today: 'Oggi',
     today: 'Oggi',
+    tomorrow: 'Domani',
     asap: 'ASAP',
     asap: 'ASAP',
     overdue: 'Scaduto',
     overdue: 'Scaduto',
     now: 'Ora',
     now: 'Ora',
@@ -1431,6 +1432,9 @@ export default {
     noErrors: 'Nessun errore',
     noErrors: 'Nessun errore',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
+    clearErrors: 'Cancella errori',
+    clearSuccess: 'Errori HMS cancellati',
+    clearFailed: 'Impossibile cancellare gli errori HMS',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1688,7 +1692,6 @@ export default {
     cameraStream: 'Stream camera',
     cameraStream: 'Stream camera',
     progress: 'Avanzamento',
     progress: 'Avanzamento',
     eta: 'ETA',
     eta: 'ETA',
-    tomorrow: 'Domani',
     printerIdle: 'Stampante inattiva',
     printerIdle: 'Stampante inattiva',
     printerOffline: 'Stampante offline',
     printerOffline: 'Stampante offline',
     status: {
     status: {

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

@@ -74,6 +74,7 @@ export default {
     unknown: '不明',
     unknown: '不明',
     unknownError: '不明なエラー',
     unknownError: '不明なエラー',
     today: '今日',
     today: '今日',
+    tomorrow: '明日',
     asap: '即時',
     asap: '即時',
     now: '今すぐ',
     now: '今すぐ',
     collapse: '折りたたむ',
     collapse: '折りたたむ',
@@ -1856,7 +1857,6 @@ export default {
     },
     },
     title: 'ストリームオーバーレイ',
     title: 'ストリームオーバーレイ',
     progress: '進捗',
     progress: '進捗',
-    tomorrow: '明日',
     printerIdle: 'プリンター待機中',
     printerIdle: 'プリンター待機中',
     printerOffline: 'プリンターオフライン',
     printerOffline: 'プリンターオフライン',
   },
   },
@@ -2941,6 +2941,9 @@ export default {
     noErrors: 'エラーなし',
     noErrors: 'エラーなし',
     viewOnWiki: 'Bambu Lab Wikiで表示',
     viewOnWiki: 'Bambu Lab Wikiで表示',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
+    clearErrors: 'エラーをクリア',
+    clearSuccess: 'HMSエラーをクリアしました',
+    clearFailed: 'HMSエラーのクリアに失敗しました',
   },
   },
   plateAlert: {
   plateAlert: {
     title: '印刷が一時停止されました!',
     title: '印刷が一時停止されました!',

+ 1 - 8
frontend/src/pages/ArchivesPage.tsx

@@ -51,7 +51,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
-import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
+import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
@@ -83,13 +83,6 @@ function formatFileSize(bytes: number): string {
   return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
   return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 }
 }
 
 
-function formatDuration(seconds: number): string {
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-}
-
 /**
 /**
  * Check if an archive filename represents a sliced/printable file.
  * Check if an archive filename represents a sliced/printable file.
  * Matches: .gcode, .gcode.3mf, .gcode.anything
  * Matches: .gcode, .gcode.3mf, .gcode.anything

+ 1 - 9
frontend/src/pages/FileManagerPage.tsx

@@ -57,6 +57,7 @@ import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { formatDuration } from '../utils/date';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -70,15 +71,6 @@ function formatFileSize(bytes: number): string {
   return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
   return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
 }
 }
 
 
-// Utility to format duration
-function formatDuration(seconds: number | null): string {
-  if (!seconds) return '-';
-  const hours = Math.floor(seconds / 3600);
-  const mins = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${mins}m`;
-  return `${mins}m`;
-}
-
 // New Folder Modal
 // New Folder Modal
 interface NewFolderModalProps {
 interface NewFolderModalProps {
   parentId: number | null;
   parentId: number | null;

+ 6 - 40
frontend/src/pages/PrintersPage.tsx

@@ -46,7 +46,7 @@ import {
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { api, discoveryApi, firmwareApi } from '../api/client';
-import { formatDateOnly } from '../utils/date';
+import { formatDateOnly, formatETA, formatDuration } from '../utils/date';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -1087,42 +1087,6 @@ function getSpoolmanFillLevel(
   ));
   ));
 }
 }
 
 
-function formatTime(seconds: number): string {
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
-}
-
-function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string {
-  const now = new Date();
-  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
-  const today = new Date();
-  today.setHours(0, 0, 0, 0);
-  const etaDay = new Date(eta);
-  etaDay.setHours(0, 0, 0, 0);
-
-  // Build time format options based on setting
-  const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
-  if (timeFormat === '12h') {
-    timeOptions.hour12 = true;
-  } else if (timeFormat === '24h') {
-    timeOptions.hour12 = false;
-  }
-  // 'system' leaves hour12 undefined, letting the browser decide
-
-  const timeStr = eta.toLocaleTimeString([], timeOptions);
-
-  // Check if it's tomorrow or later
-  const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
-  if (dayDiff === 0) {
-    return timeStr;
-  } else if (dayDiff === 1) {
-    return `Tomorrow ${timeStr}`;
-  } else {
-    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
-  }
-}
-
 function getPrinterImage(model: string | null | undefined): string {
 function getPrinterImage(model: string | null | undefined): string {
   if (!model) return '/img/printers/default.png';
   if (!model) return '/img/printers/default.png';
 
 
@@ -1348,7 +1312,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
                 />
                 />
               </div>
               </div>
               <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
               <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
-              <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
+              <span className="text-bambu-gray">({formatDuration(nextFinish.remainingMin * 60)})</span>
             </div>
             </div>
           </div>
           </div>
         </>
         </>
@@ -2455,10 +2419,10 @@ function PrinterCard({
                               <>
                               <>
                                 <span className="flex items-center gap-1">
                                 <span className="flex items-center gap-1">
                                   <Clock className="w-3 h-3" />
                                   <Clock className="w-3 h-3" />
-                                  {formatTime(status.remaining_time * 60)}
+                                  {formatDuration(status.remaining_time * 60)}
                                 </span>
                                 </span>
                                 <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
                                 <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
-                                  ETA {formatETA(status.remaining_time, timeFormat)}
+                                  ETA {formatETA(status.remaining_time, timeFormat, t)}
                                 </span>
                                 </span>
                               </>
                               </>
                             )}
                             )}
@@ -4008,6 +3972,8 @@ function PrinterCard({
           printerName={printer.name}
           printerName={printer.name}
           errors={status?.hms_errors || []}
           errors={status?.hms_errors || []}
           onClose={() => setShowHMSModal(false)}
           onClose={() => setShowHMSModal(false)}
+          printerId={printer.id}
+          hasPermission={hasPermission}
         />
         />
       )}
       )}
 
 

+ 35 - 13
frontend/src/pages/QueuePage.tsx

@@ -50,7 +50,7 @@ import {
   Weight,
   Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
+import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -59,14 +59,6 @@ import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 
 
-function formatDuration(seconds: number | null | undefined): string {
-  if (!seconds) return '--';
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-}
-
 function formatWeight(g: number, useKg = false): string {
 function formatWeight(g: number, useKg = false): string {
   if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
   if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
   return `${Math.round(g)}g`;
   return `${Math.round(g)}g`;
@@ -322,6 +314,12 @@ function SortableQueueItem({
   printerState?: string | null;
   printerState?: string | null;
   t: (key: string, options?: Record<string, unknown>) => string;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
 }) {
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', item.printer_id],
+    queryFn: () => api.getPrinterStatus(item.printer_id!),
+    refetchInterval: 30000,
+    enabled: item.printer_id != null && printerState === 'printing',
+  });
   const canReorder = hasPermission('queue:reorder');
   const canReorder = hasPermission('queue:reorder');
   const {
   const {
     attributes,
     attributes,
@@ -498,12 +496,36 @@ function SortableQueueItem({
           </div>
           </div>
 
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
-          {isPrinting && (
+          {isPrinting && status && (
             <div className="mt-3">
             <div className="mt-3">
-              <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
-                <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
+              <div className="flex items-center justify-between text-sm">
+                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+                  <div
+                    className="bg-bambu-green h-2 rounded-full transition-all"
+                    style={{ width: `${status.progress || 0}%` }}
+                  />
+                </div>
+                <span className="text-white">{Math.round(status.progress || 0)}%</span>
+              </div>
+              <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+                {status.remaining_time != null && status.remaining_time > 0 && (
+                  <>
+                    <span className="flex items-center gap-1">
+                      <Clock className="w-3 h-3" />
+                      {formatDuration(status.remaining_time * 60)}
+                    </span>
+                    <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
+                      ETA {formatETA(status.remaining_time, timeFormat, t)}
+                    </span>
+                  </>
+                )}
+                {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                  <span className="flex items-center gap-1">
+                    <Layers className="w-3 h-3" />
+                    {status.layer_num}/{status.total_layers}
+                  </span>
+                )}
               </div>
               </div>
-              <p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
             </div>
             </div>
           )}
           )}
 
 

+ 11 - 27
frontend/src/pages/StreamOverlayPage.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { PrinterStatus } from '../api/client';
 import type { PrinterStatus } from '../api/client';
+import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
 
@@ -46,31 +47,6 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
   };
   };
 }
 }
 
 
-function formatTime(seconds: number): string {
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
-}
-
-function formatETA(remainingMinutes: number, t: TFunction): string {
-  const now = new Date();
-  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
-  const today = new Date();
-  today.setHours(0, 0, 0, 0);
-  const etaDay = new Date(eta);
-  etaDay.setHours(0, 0, 0, 0);
-
-  const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-
-  if (etaDay.getTime() === today.getTime()) {
-    return timeStr;
-  } else if (etaDay.getTime() === today.getTime() + 86400000) {
-    return `${t('streamOverlay.tomorrow')} ${timeStr}`;
-  } else {
-    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
-  }
-}
-
 function getStatusText(status: PrinterStatus, t: TFunction): string {
 function getStatusText(status: PrinterStatus, t: TFunction): string {
   if (status.stg_cur_name) return status.stg_cur_name;
   if (status.stg_cur_name) return status.stg_cur_name;
 
 
@@ -146,6 +122,14 @@ export function StreamOverlayPage() {
     refetchInterval: 2000,
     refetchInterval: 2000,
   });
   });
 
 
+  // Fetch settings info
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+
   // WebSocket for real-time updates
   // WebSocket for real-time updates
   useEffect(() => {
   useEffect(() => {
     if (!id) return;
     if (!id) return;
@@ -298,14 +282,14 @@ export function StreamOverlayPage() {
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Timer className={sizes.icon} />
                     <Timer className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
                     <span className={`${sizes.text} text-white`}>
-                      {formatTime(status.remaining_time * 60)}
+                      {formatDuration(status.remaining_time * 60)}
                     </span>
                     </span>
                   </div>
                   </div>
 
 
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Clock className={sizes.icon} />
                     <Clock className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
                     <span className={`${sizes.text} text-white`}>
-                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
+                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
                     </span>
                     </span>
                   </div>
                   </div>
                 </>
                 </>

+ 0 - 11
frontend/src/utils/amsHelpers.ts

@@ -94,17 +94,6 @@ export function getGlobalTrayId(
   return amsId * 4 + trayId;
   return amsId * 4 + trayId;
 }
 }
 
 
-/**
- * Format seconds to human readable time string.
- */
-export function formatTime(seconds: number | null | undefined): string {
-  if (!seconds) return '';
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-}
-
 /**
 /**
  * Get minimum datetime for scheduling (now + 1 minute).
  * Get minimum datetime for scheduling (now + 1 minute).
  * Returns ISO string format for datetime-local input.
  * Returns ISO string format for datetime-local input.

+ 48 - 0
frontend/src/utils/date.ts

@@ -376,3 +376,51 @@ export function formatTimeOnly(
   const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
   const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
   return date.toLocaleTimeString([], finalOptions);
   return date.toLocaleTimeString([], finalOptions);
 }
 }
+
+/**
+ * Calculate and format an ETA based on remaining minutes from now.
+ *
+ * @param remainingMinutes - Minutes until completion
+ * @param timeFormat - Time format setting ('system', '12h', '24h')
+ * @param t - Optional i18n translation function
+ * @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
+ */
+export function formatETA(
+  remainingMinutes: number,
+  timeFormat: 'system' | '12h' | '24h' = 'system',
+  t?: (key: string) => string
+): string {
+  const now = new Date();
+  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
+  
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+  const etaDay = new Date(eta);
+  etaDay.setHours(0, 0, 0, 0);
+
+  const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
+  if (timeFormat === '12h') timeOptions.hour12 = true;
+  else if (timeFormat === '24h') timeOptions.hour12 = false;
+
+  const timeStr = eta.toLocaleTimeString([], timeOptions);
+  const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
+
+  if (dayDiff === 0) return timeStr;
+  if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`;
+  return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`;
+}
+
+/**
+ * Format a duration in seconds to a human-readable string, with null handling.
+ *
+ * @param seconds - Duration in seconds, or null/undefined
+ * @returns Formatted string (e.g., "2h 30m", "45m") or "--" if no value
+ */
+export function formatDuration(seconds: number | null | undefined): string {
+  if (seconds == null || seconds < 0) return '--';
+  
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  
+  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-C2PHjTQb.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-D7b3EUDG.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-DGpIc0xD.js


+ 2 - 2
static/index.html

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است