Jelajahi Sumber

Merge pull request #239 from maziggy/0.1.7

v0.1.7
MartinNYHC 3 bulan lalu
induk
melakukan
230aac59a6

+ 4 - 3
backend/app/api/routes/printers.py

@@ -672,10 +672,11 @@ async def get_printer_cover(
         # Extract thumbnail from 3MF (which is a ZIP file)
         try:
             zf = zipfile.ZipFile(temp_path, "r")
-        except zipfile.BadZipFile as e:
-            raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
+        except zipfile.BadZipFile:
+            raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
         except Exception as e:
-            raise HTTPException(500, f"Failed to open 3MF file: {e}")
+            logger.error(f"Failed to open 3MF file: {e}", exc_info=True)
+            raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
 
         try:
             # Try common thumbnail paths in 3MF files

+ 83 - 29
backend/app/api/routes/settings.py

@@ -1,4 +1,5 @@
 import io
+import logging
 import zipfile
 from datetime import datetime
 from pathlib import Path
@@ -16,6 +17,8 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter(prefix="/settings", tags=["settings"])
 
 # Default settings
@@ -311,6 +314,7 @@ async def restore_backup(
     from fastapi import HTTPException
 
     from backend.app.core.database import close_all_connections
+    from backend.app.services.virtual_printer import virtual_printer_manager
 
     base_dir = app_settings.base_dir
     db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
@@ -336,35 +340,84 @@ async def restore_backup(
         if not backup_db.exists():
             raise HTTPException(400, "Invalid backup: missing bambuddy.db")
 
-        # 3. Close current database connections
-        await close_all_connections()
-
-        # 4. Replace database
-        shutil.copy2(backup_db, db_path)
-
-        # 5. Replace data directories
-        dirs_to_restore = [
-            ("archive", base_dir / "archive"),
-            ("virtual_printer", base_dir / "virtual_printer"),
-            ("plate_calibration", app_settings.plate_calibration_dir),
-            ("icons", base_dir / "icons"),
-            ("projects", base_dir / "projects"),
-        ]
-
-        for name, dest_dir in dirs_to_restore:
-            src_dir = temp_path / name
-            if src_dir.exists():
-                if dest_dir.exists():
-                    shutil.rmtree(dest_dir)
-                shutil.copytree(src_dir, dest_dir)
-
-        # 6. Note: Database connection will be reinitialized on restart
-        # The application should be restarted after restore
+        try:
+            import asyncio
+
+            # 3. Stop virtual printer if running (releases file locks)
+            try:
+                if virtual_printer_manager.is_enabled:
+                    logger.info("Stopping virtual printer for restore...")
+                    await virtual_printer_manager.configure(enabled=False)
+                    # Give it time to fully release file handles
+                    await asyncio.sleep(1)
+            except Exception as e:
+                logger.warning(f"Failed to stop virtual printer: {e}")
+
+            # 4. Close current database connections
+            logger.info("Closing database connections...")
+            await close_all_connections()
+
+            # 5. Replace database
+            logger.info("Restoring database from backup...")
+            shutil.copy2(backup_db, db_path)
+
+            # 6. Replace data directories
+            # For Docker compatibility: clear contents then copy (don't delete mount points)
+            dirs_to_restore = [
+                ("archive", base_dir / "archive"),
+                ("virtual_printer", base_dir / "virtual_printer"),
+                ("plate_calibration", app_settings.plate_calibration_dir),
+                ("icons", base_dir / "icons"),
+                ("projects", base_dir / "projects"),
+            ]
+
+            skipped_dirs = []
+            for name, dest_dir in dirs_to_restore:
+                src_dir = temp_path / name
+                if src_dir.exists():
+                    logger.info(f"Restoring {name} directory...")
+                    try:
+                        # Clear destination contents (not the dir itself - may be Docker mount)
+                        if dest_dir.exists():
+                            for item in dest_dir.iterdir():
+                                try:
+                                    if item.is_dir():
+                                        shutil.rmtree(item)
+                                    else:
+                                        item.unlink()
+                                except OSError as e:
+                                    logger.warning(f"Could not delete {item}: {e}")
+                        else:
+                            dest_dir.mkdir(parents=True, exist_ok=True)
+                        # Copy contents from backup
+                        for item in src_dir.iterdir():
+                            dest_item = dest_dir / item.name
+                            if item.is_dir():
+                                shutil.copytree(item, dest_item)
+                            else:
+                                shutil.copy2(item, dest_item)
+                    except OSError as e:
+                        logger.warning(f"Could not restore {name} directory: {e}")
+                        skipped_dirs.append(name)
+
+            # 7. Note: Virtual printer and database will be reinitialized on restart
+            # Do NOT try to restart services here - the database session is closed
+
+            logger.info("Restore complete - restart required")
+            message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
+            if skipped_dirs:
+                message += f" Note: Some directories could not be restored ({', '.join(skipped_dirs)})."
+            return {
+                "success": True,
+                "message": message,
+            }
 
-        return {
-            "success": True,
-            "message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.",
-        }
+        except Exception as e:
+            logger.error(f"Restore failed: {e}", exc_info=True)
+            return JSONResponse(
+                status_code=500,
+                content={"success": False, "message": "Restore failed. Check server logs for details."},
+            )
 
 
 @router.get("/virtual-printer/models")
@@ -539,9 +592,10 @@ async def update_virtual_printer_settings(
             content={"detail": str(e)},
         )
     except Exception as e:
+        logger.error(f"Failed to configure virtual printer: {e}", exc_info=True)
         return JSONResponse(
             status_code=500,
-            content={"detail": f"Failed to configure virtual printer: {e}"},
+            content={"detail": "Failed to configure virtual printer. Check server logs for details."},
         )
 
     return await get_virtual_printer_settings(db)

+ 2 - 2
backend/app/api/routes/support.py

@@ -308,8 +308,8 @@ async def clear_logs(
             logger.info("Log file cleared by user")
             return {"message": "Logs cleared successfully"}
         except Exception as e:
-            logger.error(f"Error clearing log file: {e}")
-            raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
+            logger.error(f"Error clearing log file: {e}", exc_info=True)
+            raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
 
     return {"message": "Log file does not exist"}
 

+ 123 - 1
frontend/src/api/client.ts

@@ -2063,6 +2063,32 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(
+      `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+      { headers }
+    );
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const filename = filenameMatch?.[1] || path.split('/').pop() || 'download';
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) {
@@ -2241,6 +2267,29 @@ export const api = {
   getArchivePlateThumbnail: (id: number, plateIndex: number) =>
     `${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
+  downloadArchive: async (id: number, filename?: string): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/${id}/download`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const downloadFilename = filenameMatch?.[1] || filename || `archive_${id}.3mf`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = downloadFilename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
@@ -2359,6 +2408,29 @@ export const api = {
   // Source 3MF (original slicer project file)
   getSource3mfDownloadUrl: (archiveId: number) =>
     `${API_BASE}/archives/${archiveId}/source`,
+  downloadSource3mf: async (archiveId: number): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const filename = filenameMatch?.[1] || `source_${archiveId}.3mf`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   getSource3mfForSlicer: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
@@ -2386,6 +2458,29 @@ export const api = {
   // F3D (Fusion 360 design file)
   getF3dDownloadUrl: (archiveId: number) =>
     `${API_BASE}/archives/${archiveId}/f3d`,
+  downloadF3d: async (archiveId: number): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const filename = filenameMatch?.[1] || `archive_${archiveId}.f3d`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -2574,7 +2669,11 @@ export const api = {
   exportBackup: async (): Promise<{ blob: Blob; filename: string }> => {
     // New simplified backup - complete database + all files
     const url = `${API_BASE}/settings/backup`;
-    const response = await fetch(url);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(url, { headers });
 
     // Check for errors
     if (!response.ok) {
@@ -3392,6 +3491,29 @@ export const api = {
   deleteLibraryFile: (id: number) =>
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
+  downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/library/files/${id}/download`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const downloadFilename = filenameMatch?.[1] || filename || `file_${id}`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = downloadFilename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
   getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
     `${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,

+ 4 - 2
frontend/src/components/FileManagerModal.tsx

@@ -175,8 +175,10 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
     const paths = Array.from(selectedFiles);
 
     if (paths.length === 1) {
-      // Single file - direct download
-      window.open(api.getPrinterFileDownloadUrl(printerId, paths[0]), '_blank');
+      // Single file - direct download with auth
+      api.downloadPrinterFile(printerId, paths[0]).catch((err) => {
+        console.error('Printer file download failed:', err);
+      });
       setSelectedFiles(new Set());
       return;
     }

+ 7 - 2
frontend/src/components/GitHubBackupSettings.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   Github,
@@ -96,6 +97,7 @@ function formatRelativeTime(dateStr: string | null): string {
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { t } = useTranslation();
 
   // Local state for form
   const [repoUrl, setRepoUrl] = useState('');
@@ -762,9 +764,12 @@ export function GitHubBackupSettings() {
             {/* Import */}
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
               <div>
-                <p className="text-white">Restore Backup</p>
+                <p className="text-white">{t('backup.restoreBackup')}</p>
                 <p className="text-sm text-bambu-gray">
-                  Replace all data from a backup file
+                  {t('backup.restoreDescription')}
+                </p>
+                <p className="text-xs text-bambu-gray-light mt-1">
+                  {t('backup.restoreNote')}
                 </p>
               </div>
               <input

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

@@ -2239,6 +2239,7 @@ export default {
     title: 'Sichern & Wiederherstellen',
     createBackup: 'Sicherung erstellen',
     restoreBackup: 'Sicherung wiederherstellen',
+    restoreDescription: 'Alle Daten aus einer Sicherungsdatei ersetzen',
     downloadBackup: 'Sicherung herunterladen',
     uploadBackup: 'Sicherung hochladen',
     lastBackup: 'Letzte Sicherung',
@@ -2252,6 +2253,7 @@ export default {
     restoreSuccess: 'Sicherung erfolgreich wiederhergestellt',
     backupFailed: 'Sicherung fehlgeschlagen',
     restoreFailed: 'Wiederherstellung fehlgeschlagen',
+    restoreNote: 'Virtueller Drucker wird während der Wiederherstellung gestoppt',
   },
 
   // Tags

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

@@ -2239,6 +2239,7 @@ export default {
     title: 'Backup & Restore',
     createBackup: 'Create Backup',
     restoreBackup: 'Restore Backup',
+    restoreDescription: 'Replace all data from a backup file',
     downloadBackup: 'Download Backup',
     uploadBackup: 'Upload Backup',
     lastBackup: 'Last Backup',
@@ -2252,6 +2253,7 @@ export default {
     restoreSuccess: 'Backup restored successfully',
     backupFailed: 'Backup failed',
     restoreFailed: 'Restore failed',
+    restoreNote: 'Virtual Printer will be stopped during restore',
   },
 
   // Tags

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

@@ -1784,6 +1784,9 @@ export default {
     projectsDescription: 'プロジェクト、BOMアイテム、添付ファイル',
     pendingUploadsDescription: 'レビュー待ちの仮想プリンターアップロード',
     apiKeysDescription: 'Webhook APIキー(インポート時に新しいキーが生成されます)',
+    restoreBackup: 'バックアップの復元',
+    restoreDescription: 'バックアップファイルからすべてのデータを置き換える',
+    restoreNote: '復元中、仮想プリンターは停止されます',
   },
 
   // Restore modal

+ 27 - 33
frontend/src/pages/ArchivesPage.tsx

@@ -356,10 +356,9 @@ function ArchiveCard({
       icon: <FileCode className="w-4 h-4" />,
       onClick: () => {
         if (archive.source_3mf_path) {
-          const link = document.createElement('a');
-          link.href = api.getSource3mfDownloadUrl(archive.id);
-          link.download = `${archive.print_name || archive.filename}_source.3mf`;
-          link.click();
+          api.downloadSource3mf(archive.id).catch((err) => {
+            console.error('Source 3MF download failed:', err);
+          });
         } else {
           source3mfInputRef.current?.click();
         }
@@ -393,10 +392,9 @@ function ArchiveCard({
       label: t('archives.menu.downloadF3d'),
       icon: <Download className="w-4 h-4" />,
       onClick: () => {
-        const link = document.createElement('a');
-        link.href = api.getF3dDownloadUrl(archive.id);
-        link.download = `${archive.print_name || archive.filename}.f3d`;
-        link.click();
+        api.downloadF3d(archive.id).catch((err) => {
+          console.error('F3D download failed:', err);
+        });
       },
     },
     {
@@ -412,10 +410,9 @@ function ArchiveCard({
       label: t('archives.menu.download'),
       icon: <Download className="w-4 h-4" />,
       onClick: () => {
-        const link = document.createElement('a');
-        link.href = api.getArchiveDownload(archive.id);
-        link.download = `${archive.print_name || archive.filename}.3mf`;
-        link.click();
+        api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
+          console.error('Archive download failed:', err);
+        });
       },
       disabled: !hasPermission('archives:read'),
       title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined,
@@ -709,7 +706,9 @@ function ArchiveCard({
             onClick={(e) => {
               e.stopPropagation();
               // Download F3D file
-              window.location.href = api.getF3dDownloadUrl(archive.id);
+              api.downloadF3d(archive.id).catch((err) => {
+                console.error('F3D download failed:', err);
+              });
             }}
             title={t('archives.card.downloadF3d')}
           >
@@ -997,10 +996,9 @@ function ArchiveCard({
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => {
-              const link = document.createElement('a');
-              link.href = api.getArchiveDownload(archive.id);
-              link.download = `${archive.print_name || archive.filename}.3mf`;
-              link.click();
+              api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
+                console.error('Archive download failed:', err);
+              });
             }}
             title={t('archives.card.download')}
           >
@@ -1490,10 +1488,9 @@ function ArchiveListRow({
       icon: <FileCode className="w-4 h-4" />,
       onClick: () => {
         if (archive.source_3mf_path) {
-          const link = document.createElement('a');
-          link.href = api.getSource3mfDownloadUrl(archive.id);
-          link.download = `${archive.print_name || archive.filename}_source.3mf`;
-          link.click();
+          api.downloadSource3mf(archive.id).catch((err) => {
+            console.error('Source 3MF download failed:', err);
+          });
         } else {
           source3mfInputRef.current?.click();
         }
@@ -1527,10 +1524,9 @@ function ArchiveListRow({
       label: t('archives.menu.downloadF3d'),
       icon: <Download className="w-4 h-4" />,
       onClick: () => {
-        const link = document.createElement('a');
-        link.href = api.getF3dDownloadUrl(archive.id);
-        link.download = `${archive.print_name || archive.filename}.f3d`;
-        link.click();
+        api.downloadF3d(archive.id).catch((err) => {
+          console.error('F3D download failed:', err);
+        });
       },
     },
     {
@@ -1546,10 +1542,9 @@ function ArchiveListRow({
       label: t('archives.menu.download'),
       icon: <Download className="w-4 h-4" />,
       onClick: () => {
-        const link = document.createElement('a');
-        link.href = api.getArchiveDownload(archive.id);
-        link.download = `${archive.print_name || archive.filename}.3mf`;
-        link.click();
+        api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
+          console.error('Archive download failed:', err);
+        });
       },
       disabled: !hasPermission('archives:read'),
       title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined,
@@ -1785,10 +1780,9 @@ function ArchiveListRow({
             variant="ghost"
             size="sm"
             onClick={() => {
-              const link = document.createElement('a');
-              link.href = api.getArchiveDownload(archive.id);
-              link.download = `${archive.print_name || archive.filename}.3mf`;
-              link.click();
+              api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
+                console.error('Archive download failed:', err);
+              });
             }}
             title={t('archives.card.download')}
           >

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

@@ -1466,7 +1466,9 @@ export function FileManagerPage() {
   };
 
   const handleDownload = (id: number) => {
-    window.open(api.getLibraryFileDownloadUrl(id), '_blank');
+    api.downloadLibraryFile(id).catch((err) => {
+      console.error('Library file download failed:', err);
+    });
   };
 
   const handleDeleteConfirm = () => {

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-CKG31ANB.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C_9mu6sD.js"></script>
+    <script type="module" crossorigin src="/assets/index-CKG31ANB.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DME4t7XG.css">
   </head>
   <body>

+ 3 - 0
test_all.sh

@@ -0,0 +1,3 @@
+#!/bin/bash
+
+./test_frontend.sh && ./test_backend.sh && ./test_docker.sh

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini