Просмотр исходного кода

feat(diagnostic, archives): install step 4 — proactive check + reactive banner

  Two complementary surfaces for the most-missed install step ("Store sent
  files on external storage"):

  1. Connection diagnostic check (printer-side variant)
     - Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
     - Pass / fail / skip; instant, no I/O.
     - Catches the newer-firmware variant where the toggle moved onto the
       printer itself (P2S 01.02 / Studio 2.6+).

     An FTP upload-and-verify probe was tried first and rejected. /cache
     is always writable from Bambuddy regardless of the slicer setting;
     only BambuStudio's own behaviour changes when the toggle flips.
     Empirically confirmed against X1C + H2D with the slicer option
     toggled off: probe still succeeded, home_flag bit 11 stayed True.

  2. Archives-page banner (slicer-side variant)
     - The slicer-side toggle is invisible to the printer — older
       BambuStudio doesn't push the change to the printer. The diagnostic
       can't see it.
     - Symptom is deterministic: archiver creates rows with
       extra_data.no_3mf_available=True (main.py:2770) when it can't pull
       the 3MF from /cache after a slicer-initiated print.
     - New endpoint GET /archives/no-3mf-warning returns whether any
       archive in the last 30 days has the flag (excluding soft-deleted).
     - Amber dismissible banner at the top of /archives; one-shot
       localStorage dismissal (matches Layout.tsx update-banner pattern,
       but persistent across sessions).
     - React-Query disabled after dismissal so the endpoint isn't polled
       once the user has been told.
maziggy 2 месяцев назад
Родитель
Сommit
f87b5bb3b8

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 33 - 0
backend/app/api/routes/archives.py

@@ -427,6 +427,39 @@ async def list_archives(
     return result
 
 
+@router.get("/no-3mf-warning")
+async def no_3mf_warning(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Whether to nudge the user about install step 4 ("Store sent files on
+    external storage"). True iff any archive in the last 30 days was created
+    via the no-3MF fallback path — that's the deterministic symptom of the
+    slicer-side variant of the setting being off.
+
+    Complements the connection-diagnostic ``external_storage`` check, which
+    only catches the printer-side variant of the setting. On older slicers
+    where the toggle lives only in BambuStudio, the printer never reports it
+    and the diagnostic passes — this endpoint surfaces the symptom instead.
+
+    Dismissal is handled client-side via localStorage (one-shot): once the
+    user has been told, no further nudge until they clear browser storage.
+    The backend stays stateless.
+    """
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    result = await db.execute(
+        select(PrintArchive.extra_data).where(
+            PrintArchive.created_at >= cutoff,
+            PrintArchive.deleted_at.is_(None),
+            PrintArchive.extra_data.isnot(None),
+        )
+    )
+    for (extra_data,) in result.all():
+        if extra_data and extra_data.get("no_3mf_available"):
+            return {"has_fallback": True}
+    return {"has_fallback": False}
+
+
 @router.get("/slim", response_model=list[ArchiveSlim])
 async def list_archives_slim(
     date_from: date | None = Query(None),

+ 90 - 0
backend/tests/integration/test_archives_api.py

@@ -534,6 +534,96 @@ class TestArchivesAPI:
         assert "successful_prints" in result
 
 
+class TestNo3MFWarning:
+    """`GET /archives/no-3mf-warning` — install step 4 reactive nudge.
+
+    The connection diagnostic's external_storage check only catches the
+    printer-side variant of the setting (newer firmware). For older slicers
+    where the toggle lives only in BambuStudio, the printer never reports
+    it. The fallback path in main.py creates the archive with
+    extra_data.no_3mf_available=True; this endpoint exposes that as a
+    boolean so the frontend can surface a one-time banner.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_true_when_recent_fallback_exists(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": True}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_false_when_no_archives(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": False}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_false_when_only_normal_archives(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        # extra_data has other keys but no_3mf_available is absent — normal
+        # archives must not trigger the nudge.
+        await archive_factory(printer.id, extra_data={"makerworld_url": "https://example"})
+        await archive_factory(printer.id, extra_data=None)
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": False}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ignores_archives_older_than_30_days(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from datetime import datetime, timedelta, timezone
+
+        from backend.app.models.archive import PrintArchive
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+        # Backdate past the 30-day window — old fallbacks are forgiven.
+        archive.created_at = datetime.now(timezone.utc) - timedelta(days=45)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": False}
+        # Sanity: row really is in the DB, we just don't surface it.
+        assert (await db_session.get(PrintArchive, archive.id)) is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ignores_soft_deleted_fallbacks(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+        archive.deleted_at = datetime.now(timezone.utc)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        # Soft-deleted fallbacks have been actioned (user clearing the
+        # evidence). Stop nudging.
+        assert response.json() == {"has_fallback": False}
+
+
 class TestPrintLogEntryDelete:
     """#1687: per-row delete on the Print Log page.
 

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

@@ -3771,6 +3771,7 @@ export const api = {
     return request<Archive[]>(`/archives/search?${params}`);
   },
   rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }),
+  getNo3MFWarning: () => request<{ has_fallback: boolean }>('/archives/no-3mf-warning'),
   updateArchive: (id: number, data: {
     printer_id?: number | null;
     project_id?: number | null;

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

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: 'Druckarchiv',
+    no3mfBanner: {
+      title: 'Einige kürzliche Drucke konnten nicht mit Vorschaubild archiviert werden',
+      body: 'Der Slicer hat die .gcode.3mf-Datei nicht auf der SD-Karte des Druckers hinterlegt, daher konnte Bambuddy weder Vorschaubild noch Slicer-Metadaten abrufen. Üblicherweise liegt das daran, dass "Gesendete Dateien auf externem Speicher speichern" im Slicer (Geräte-Tab in Bambu Studio / OrcaSlicer) deaktiviert ist.',
+      docsLink: 'Installationsschritt 4 anzeigen',
+      dismissLabel: 'Hinweis schließen',
+    },
     searchPlaceholder: 'Archiv durchsuchen...',
     filterByPrinter: 'Nach Drucker filtern',
     filterByStatus: 'Nach Status filtern',

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

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: 'Print Archives',
+    no3mfBanner: {
+      title: 'Some recent prints couldn\'t be archived with thumbnails',
+      body: 'The slicer didn\'t leave the .gcode.3mf on the printer\'s SD card, so Bambuddy couldn\'t pull the thumbnail or slicer metadata. This is usually because "Store sent files on external storage" is off in the slicer (Bambu Studio / OrcaSlicer Device tab).',
+      docsLink: 'See install step 4',
+      dismissLabel: 'Dismiss this notice',
+    },
     searchPlaceholder: 'Search archives...',
     filterByPrinter: 'Filter by printer',
     filterByStatus: 'Filter by status',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: 'Archivos de impresión',
+    no3mfBanner: {
+      title: 'Algunas impresiones recientes no se pudieron archivar con miniaturas',
+      body: 'El slicer no dejó el archivo .gcode.3mf en la tarjeta SD de la impresora, así que Bambuddy no pudo recuperar ni la miniatura ni los metadatos del slicer. Esto suele deberse a que "Almacenar archivos enviados en almacenamiento externo" está desactivado en el slicer (pestaña Dispositivo de Bambu Studio / OrcaSlicer).',
+      docsLink: 'Ver paso 4 de instalación',
+      dismissLabel: 'Descartar este aviso',
+    },
     searchPlaceholder: 'Buscar archivos...',
     filterByPrinter: 'Filtrar por impresora',
     filterByStatus: 'Filtrar por estado',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: 'Archives d\'impression',
+    no3mfBanner: {
+      title: 'Certaines impressions récentes n\'ont pas pu être archivées avec leur miniature',
+      body: 'Le slicer n\'a pas laissé le fichier .gcode.3mf sur la carte SD de l\'imprimante, donc Bambuddy n\'a pas pu récupérer la miniature ni les métadonnées slicer. C\'est généralement parce que « Stocker les fichiers envoyés sur stockage externe » est désactivé dans le slicer (onglet Périphérique de Bambu Studio / OrcaSlicer).',
+      docsLink: 'Voir l\'étape 4 de l\'installation',
+      dismissLabel: 'Ignorer ce message',
+    },
     searchPlaceholder: 'Chercher dans les archives...',
     filterByPrinter: 'Par imprimante',
     filterByStatus: 'Par statut',

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

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: 'Archivi di stampa',
+    no3mfBanner: {
+      title: 'Alcune stampe recenti non sono state archiviate con la miniatura',
+      body: 'Lo slicer non ha lasciato il file .gcode.3mf sulla scheda SD della stampante, quindi Bambuddy non ha potuto recuperare né la miniatura né i metadati dello slicer. Solitamente accade perché "Memorizza file inviati su archiviazione esterna" è disattivato nello slicer (scheda Dispositivo di Bambu Studio / OrcaSlicer).',
+      docsLink: 'Vedi passo 4 dell\'installazione',
+      dismissLabel: 'Chiudi questo avviso',
+    },
     searchPlaceholder: 'Cerca archivi...',
     filterByPrinter: 'Filtra per stampante',
     filterByStatus: 'Filtra per stato',

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

@@ -634,6 +634,12 @@ export default {
   // Archives page
   archives: {
     title: '印刷アーカイブ',
+    no3mfBanner: {
+      title: '最近の一部の印刷でサムネイル付きのアーカイブができませんでした',
+      body: 'スライサーがプリンターのSDカードに.gcode.3mfファイルを残さなかったため、Bambuddyはサムネイルもスライサーメタデータも取得できませんでした。これは通常、スライサー (Bambu Studio / OrcaSlicer のデバイスタブ) で「送信ファイルを外部ストレージに保存」がオフになっていることが原因です。',
+      docsLink: 'インストール手順4を参照',
+      dismissLabel: 'この通知を閉じる',
+    },
     searchPlaceholder: 'アーカイブを検索...',
     filterByPrinter: 'プリンターで絞り込み',
     filterByStatus: 'ステータスで絞り込み',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -592,6 +592,12 @@ export default {
   },
   archives: {
     title: '인쇄 아카이브',
+    no3mfBanner: {
+      title: '최근 일부 인쇄가 썸네일과 함께 아카이브되지 않았습니다',
+      body: '슬라이서가 프린터의 SD 카드에 .gcode.3mf 파일을 남기지 않아서 Bambuddy가 썸네일이나 슬라이서 메타데이터를 가져올 수 없었습니다. 일반적으로 슬라이서 (Bambu Studio / OrcaSlicer 장치 탭)에서 "전송된 파일을 외부 저장소에 저장"이 꺼져 있을 때 발생합니다.',
+      docsLink: '설치 단계 4 보기',
+      dismissLabel: '이 알림 닫기'
+    },
     searchPlaceholder: '아카이브 검색...',
     filterByPrinter: '프린터별 필터',
     filterByStatus: '상태별 필터',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: 'Arquivos de Impressão',
+    no3mfBanner: {
+      title: 'Algumas impressões recentes não puderam ser arquivadas com miniaturas',
+      body: 'O fatiador não deixou o arquivo .gcode.3mf no cartão SD da impressora, então o Bambuddy não conseguiu obter nem a miniatura nem os metadados do fatiador. Normalmente isso acontece porque "Armazenar arquivos enviados no armazenamento externo" está desligado no fatiador (aba Dispositivo do Bambu Studio / OrcaSlicer).',
+      docsLink: 'Ver passo 4 da instalação',
+      dismissLabel: 'Dispensar este aviso',
+    },
     searchPlaceholder: 'Pesquisar arquivos...',
     filterByPrinter: 'Filtrar por impressora',
     filterByStatus: 'Filtrar por status',

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -635,6 +635,12 @@ export default {
   // Arşivler sayfası
   archives: {
     title: 'Baskı Arşivleri',
+    no3mfBanner: {
+      title: 'Bazı son baskılar küçük resimlerle birlikte arşivlenemedi',
+      body: 'Dilimleyici yazıcının SD kartına .gcode.3mf dosyasını bırakmadı, bu nedenle Bambuddy küçük resmi veya dilimleyici meta verilerini alamadı. Bu genellikle dilimleyicide (Bambu Studio / OrcaSlicer\'in Cihaz sekmesi) "Gönderilen dosyaları harici depolamada sakla" seçeneğinin kapalı olmasından kaynaklanır.',
+      docsLink: 'Kurulum adımı 4\'ü görüntüle',
+      dismissLabel: 'Bu bildirimi kapat',
+    },
     searchPlaceholder: 'Arşivlerde ara...',
     filterByPrinter: 'Yazıcıya göre filtrele',
     filterByStatus: 'Duruma göre filtrele',

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: '打印归档',
+    no3mfBanner: {
+      title: '最近的一些打印未能附带缩略图归档',
+      body: '切片机没有在打印机的 SD 卡上留下 .gcode.3mf 文件,因此 Bambuddy 无法获取缩略图或切片机元数据。这通常是因为切片机 (Bambu Studio / OrcaSlicer 的设备选项卡) 中"将发送的文件存储在外部存储中"已关闭。',
+      docsLink: '查看安装步骤 4',
+      dismissLabel: '关闭此通知',
+    },
     searchPlaceholder: '搜索归档...',
     filterByPrinter: '按打印机筛选',
     filterByStatus: '按状态筛选',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -635,6 +635,12 @@ export default {
   // Archives page
   archives: {
     title: '列印歸檔',
+    no3mfBanner: {
+      title: '最近的一些列印未能附帶縮圖封存',
+      body: '切片機沒有在印表機的 SD 卡上留下 .gcode.3mf 檔案,因此 Bambuddy 無法取得縮圖或切片機中繼資料。這通常是因為切片機 (Bambu Studio / OrcaSlicer 的裝置分頁) 中「將傳送的檔案儲存在外部儲存中」已關閉。',
+      docsLink: '檢視安裝步驟 4',
+      dismissLabel: '關閉此通知',
+    },
     searchPlaceholder: '搜尋歸檔...',
     filterByPrinter: '按印表機篩選',
     filterByStatus: '按狀態篩選',

+ 48 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -2563,6 +2563,23 @@ export function ArchivesPage() {
   const [uploadFiles, setUploadFiles] = useState<File[]>([]);
   const [isDraggingOver, setIsDraggingOver] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  // Install-step-4 nudge — covers the slicer-side variant of "Store sent files
+  // on external storage" that the connection diagnostic can't detect (printer
+  // never hears about it). Symptom: archive created via no-3MF fallback. Once
+  // dismissed, never shown again — fixing step 4 stops new fallbacks anyway.
+  const [no3MFWarningDismissed, setNo3MFWarningDismissed] = useState(
+    () => localStorage.getItem('archiveNo3MFWarningDismissed') === 'true',
+  );
+  const { data: no3MFWarning } = useQuery({
+    queryKey: ['archives', 'no-3mf-warning'],
+    queryFn: api.getNo3MFWarning,
+    staleTime: 5 * 60 * 1000,
+    enabled: !no3MFWarningDismissed,
+  });
+  const dismissNo3MFWarning = () => {
+    localStorage.setItem('archiveNo3MFWarningDismissed', 'true');
+    setNo3MFWarningDismissed(true);
+  };
   const [isSelectionMode, setIsSelectionMode] = useState(false);
   const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
   const [showBatchTag, setShowBatchTag] = useState(false);
@@ -3200,6 +3217,37 @@ export function ArchivesPage() {
         </div>
       )}
 
+      {no3MFWarning?.has_fallback && !no3MFWarningDismissed && (
+        <div className="mb-6 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 flex items-start gap-3">
+          <AlertCircle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
+          <div className="flex-1 min-w-0">
+            <div className="text-sm font-medium text-amber-200">
+              {t('archives.no3mfBanner.title')}
+            </div>
+            <div className="text-xs text-amber-200/80 mt-1">
+              {t('archives.no3mfBanner.body')}{' '}
+              <a
+                href="https://bambuddy.cool/wiki/getting-started/#step-4-enable-store-sent-files-on-external-storage"
+                target="_blank"
+                rel="noreferrer"
+                className="underline hover:text-amber-100 inline-flex items-center gap-1"
+              >
+                {t('archives.no3mfBanner.docsLink')}
+                <ExternalLink className="w-3 h-3" />
+              </a>
+            </div>
+          </div>
+          <button
+            onClick={dismissNo3MFWarning}
+            className="text-amber-200/60 hover:text-amber-200 flex-shrink-0 p-1 -m-1"
+            title={t('archives.no3mfBanner.dismissLabel')}
+            aria-label={t('archives.no3mfBanner.dismissLabel')}
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+      )}
+
       <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
         <div>
           <div className="flex items-start gap-3">

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-7s3X35pi.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BvmIMSUd.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-kb-CgWpZ.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DInAXQkE.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
+    <script type="module" crossorigin src="/assets/index-kb-CgWpZ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-7s3X35pi.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов