Kaynağa Gözat

fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import

  Two-part fix for the reporter's "removed profiles still show on the slice
  menu" symptom.

  Local half (real bug). LocalProfilesView's import and delete mutations
  invalidated ['localPresets'] (the management view's own query) but not
  ['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
  A freshly-deleted preset kept rendering in the slice dropdown until that
  staleTime elapsed plus a refocus/remount. Both mutations now also call
  queryClient.invalidateQueries({queryKey: ['slicerPresets']}).

  Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
  per-(user, token) in-process cache (slicer_presets.py:69, balances
  "users see freshly-saved presets quickly" against "busy install doesn't
  hit Bambu Cloud once per modal open"). Users delete cloud presets in
  Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
  to invalidate on. Rather than shorten the TTL globally, the listing
  endpoint gains an opt-in ?refresh=true query param that bypasses both
  the cloud cache AND the 1-hour bundled-preset cache for that one call;
  the fresh result is still written back so subsequent normal callers
  keep hitting the cache.

  New SliceModal "Refresh" button. Lives in the preset section header
  next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
  and writes the fresh slots into the ['slicerPresets'] cache via
  queryClient.setQueryData so the spinner stops immediately rather than
  triggering a second refetch. RefreshCw icon spins while in-flight;
  disabled during slice enqueue to prevent double-fire.
maziggy 1 gün önce
ebeveyn
işleme
171848a0fc

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CHANGELOG.md


+ 32 - 10
backend/app/api/routes/slicer_presets.py

@@ -16,7 +16,7 @@ import json
 import logging
 import time
 
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -88,7 +88,9 @@ def _empty_slots() -> dict[str, list[UnifiedPreset]]:
     return {"printer": [], "process": [], "filament": []}
 
 
-async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dict[str, list[UnifiedPreset]], str]:
+async def _fetch_cloud_presets(
+    db: AsyncSession, user: User | None, *, refresh: bool = False
+) -> tuple[dict[str, list[UnifiedPreset]], str]:
     """Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
 
     Defence-in-depth: even if a stored cloud_token survived a permission
@@ -96,6 +98,12 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
     treated as not-authenticated for this endpoint — the cloud tier never
     surfaces for them. This keeps the per-tier visibility consistent with the
     /cloud/* endpoint suite that already gates on CLOUD_AUTH.
+
+    ``refresh=True`` skips the in-process cache for this call (used by the
+    SliceModal's manual Refresh button so a user who just deleted a preset
+    in Bambu Studio / Handy can pick up the change without waiting for the
+    5-minute TTL to expire). The fresh result is still written back to the
+    cache so subsequent non-refresh callers benefit.
     """
     if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
         return _empty_slots(), "not_authenticated"
@@ -107,9 +115,10 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
     user_key = user.id if user is not None else 0
     cache_key = (user_key, _token_fingerprint(token))
     now = time.monotonic()
-    cached = _cloud_cache.get(cache_key)
-    if cached and now - cached[0] < _CLOUD_TTL_S:
-        return cached[1], "ok"
+    if not refresh:
+        cached = _cloud_cache.get(cache_key)
+        if cached and now - cached[0] < _CLOUD_TTL_S:
+            return cached[1], "ok"
 
     cloud = BambuCloudService(region=region)
     cloud.set_token(token)
@@ -227,11 +236,15 @@ def _first_scalar(value: object) -> str | None:
     return None
 
 
-async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
-    """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
+async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> dict[str, list[UnifiedPreset]]:
+    """Standard slicer-bundled profiles via the sidecar's /profiles/bundled.
+
+    ``refresh=True`` skips the in-process cache; see _fetch_cloud_presets for
+    the same shape and rationale.
+    """
     global _bundled_cache
     now = time.monotonic()
-    if _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
+    if not refresh and _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
         return _bundled_cache[1]
 
     api_url = await _resolve_slicer_api_url(db)
@@ -383,6 +396,15 @@ async def list_unified_presets(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    refresh: bool = Query(
+        False,
+        description=(
+            "Bypass the in-process cloud and bundled-preset caches for this "
+            "request. The SliceModal's Refresh button sets this so users who "
+            "deleted a preset in Bambu Studio or Bambu Handy don't have to "
+            "wait for the 5-minute cloud-cache TTL to expire."
+        ),
+    ),
 ) -> UnifiedPresetsResponse:
     """List slicer presets across cloud / local / standard tiers, deduped by name.
 
@@ -399,9 +421,9 @@ async def list_unified_presets(
     too — matching the slice route (#1182 follow-up).
     """
     cloud_token_user = current_user or api_key_cloud_owner
-    cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user)
+    cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
     local = await _fetch_local_presets(db)
-    standard = await _fetch_bundled_presets(db)
+    standard = await _fetch_bundled_presets(db, refresh=refresh)
 
     cloud, local, standard = _dedupe_by_name(cloud, local, standard)
 

+ 103 - 2
backend/tests/unit/test_no_unsafe_path_joins.py

@@ -109,9 +109,26 @@ def _rhs_is_attacker_shape(node: ast.AST) -> bool:
     return isinstance(node, ast.Name)
 
 
+_CONTINUATION_TOKENS = (")", "]", "}", ",")
+
+
 def _line_has_marker(source_lines: list[str], lineno: int, end_lineno: int | None) -> bool:
-    # Walk every line spanned by the BinOp — the marker can sit anywhere in the
-    # expression (start, end, or any continuation line of a multi-line join).
+    """Check whether a ``# SEC-PATH-OK:`` marker covers this join.
+
+    Looks at every line spanned by the BinOp itself, plus one line past
+    ``end_lineno`` IF that line begins with a continuation token (``)``,
+    ``]``, ``}``, ``,``). The peek captures the project's convention of
+    wrapping a BinOp in parens and placing the marker on the closing line:
+
+        file_path = (
+            library_dir / filename
+        )  # SEC-PATH-OK: filename validated above
+
+    The BinOp itself ends on the inner line, but the marker reads more
+    naturally on the closing line. Restricting the peek to continuation
+    lines prevents giving a free pass to a marker that happens to sit on
+    a wholly unrelated follow-on statement.
+    """
     start = max(1, lineno)
     end = max(start, end_lineno or lineno)
     for i in range(start, end + 1):
@@ -119,6 +136,13 @@ def _line_has_marker(source_lines: list[str], lineno: int, end_lineno: int | Non
             continue
         if _MARKER in source_lines[i - 1]:
             return True
+    # Project convention: marker often sits on the closing-paren line, one
+    # line past the BinOp's `end_lineno`. Only accept it when the line is a
+    # continuation of the wrapping expression.
+    if end < len(source_lines):
+        trailing = source_lines[end].lstrip()
+        if trailing.startswith(_CONTINUATION_TOKENS) and _MARKER in source_lines[end]:
+            return True
     return False
 
 
@@ -174,6 +198,83 @@ def _scan_file(py_file: Path) -> list[str]:
     return findings
 
 
+def _scan_source(source: str, tmp_path: Path) -> list[str]:
+    """Drop ``source`` into a temp .py file and run ``_scan_file`` against it.
+    Returns the findings list."""
+    f = tmp_path / "candidate.py"
+    f.write_text(source)
+    return _scan_file(f)
+
+
+class TestMarkerDetection:
+    """Pins the contract for where a ``# SEC-PATH-OK:`` marker is accepted.
+
+    Markers must sit either (a) somewhere within the BinOp's own source
+    lines, or (b) on the immediately-following line when that line is a
+    continuation token (closing paren / bracket / brace / comma). The
+    second case is the project's convention of wrapping the BinOp in
+    parens and placing the marker on the closing-paren line.
+    """
+
+    def test_marker_on_binop_line_recognised(self, tmp_path):
+        source = (
+            "from pathlib import Path\nbase_dir = Path('.')\ndef f(x): return base_dir / x  # SEC-PATH-OK: trusted x\n"
+        )
+        assert _scan_source(source, tmp_path) == []
+
+    def test_marker_on_closing_paren_line_recognised(self, tmp_path):
+        # The exact convention used across api/routes/ and services/: the
+        # BinOp lives inside a parenthesised expression and the marker sits
+        # on the closing-paren line, one past the BinOp's `end_lineno`.
+        source = (
+            "from pathlib import Path\n"
+            "base_dir = Path('.')\n"
+            "def f(x):\n"
+            "    return (\n"
+            "        base_dir / x\n"
+            "    )  # SEC-PATH-OK: trusted x\n"
+        )
+        assert _scan_source(source, tmp_path) == []
+
+    def test_unrelated_marker_below_does_not_silence(self, tmp_path):
+        # A second join below carries a marker; the first does not. Only
+        # the first must be flagged — markers don't cross statement
+        # boundaries.
+        source = (
+            "from pathlib import Path\n"
+            "base_dir = Path('.')\n"
+            "def f(x, y):\n"
+            "    p = base_dir / x\n"
+            "    q = base_dir / y  # SEC-PATH-OK: trusted y\n"
+        )
+        findings = _scan_source(source, tmp_path)
+        assert len(findings) == 1
+        assert "base_dir / x" in findings[0]
+
+    def test_marker_on_non_continuation_line_does_not_silence(self, tmp_path):
+        # A SEC-PATH-OK comment on the line right after the BinOp counts
+        # only when that line is a continuation of the wrapping expression.
+        # An unrelated next statement's marker must not free-pass the join.
+        source = (
+            "from pathlib import Path\n"
+            "base_dir = Path('.')\n"
+            "def f(x):\n"
+            "    a = base_dir / x\n"
+            "    b = 1  # SEC-PATH-OK: unrelated, for a different statement\n"
+        )
+        findings = _scan_source(source, tmp_path)
+        assert len(findings) == 1
+        assert "base_dir / x" in findings[0]
+
+    def test_no_marker_anywhere_is_flagged(self, tmp_path):
+        # Pin the negative path so a future refactor can't accidentally turn
+        # marker detection into "always returns True".
+        source = "from pathlib import Path\nbase_dir = Path('.')\ndef f(x): return base_dir / x\n"
+        findings = _scan_source(source, tmp_path)
+        assert len(findings) == 1
+        assert "base_dir / x" in findings[0]
+
+
 def test_route_path_arithmetic_is_safe_joined_or_marked():
     """Every ``<dir-like> / <non-constant>`` join in a route handler must
     either route through ``safe_join_under(...)`` or carry a

+ 89 - 0
backend/tests/unit/test_slicer_presets.py

@@ -286,6 +286,60 @@ class TestFetchCloudPresets:
         assert first["printer"][0].name == "OldAccountX1C"
         assert second["printer"][0].name == "NewAccountX1C"
 
+    @pytest.mark.asyncio
+    async def test_refresh_bypasses_cloud_cache(self):
+        """``refresh=True`` must skip an otherwise-warm cache entry and hit
+        Bambu Cloud again — wiring for the SliceModal's Refresh button so a
+        user who deletes a cloud preset in Bambu Studio / Handy doesn't have
+        to wait for the 5-minute TTL to expire (#1581)."""
+        sp._cloud_cache.clear()
+        cloud_mock = MagicMock()
+        cloud_mock.set_token = MagicMock()
+        cloud_mock.get_slicer_settings = AsyncMock(
+            return_value={
+                "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
+                "print": {"private": [], "public": []},
+                "filament": {"private": [], "public": []},
+            }
+        )
+        cloud_mock.close = AsyncMock()
+        user = _user_with_cloud_auth(user_id=99)
+        with (
+            patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
+            patch.object(sp, "BambuCloudService", return_value=cloud_mock),
+        ):
+            await sp._fetch_cloud_presets(MagicMock(), user)
+            # Without refresh, the second call hits cache (covered by
+            # test_cache_hit_skips_cloud_call). With refresh=True it MUST
+            # re-fetch.
+            await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
+        assert cloud_mock.get_slicer_settings.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_refresh_writes_back_to_cache(self):
+        """A refresh call must still update the cache so a subsequent normal
+        call doesn't re-hit the cloud immediately afterwards."""
+        sp._cloud_cache.clear()
+        cloud_mock = MagicMock()
+        cloud_mock.set_token = MagicMock()
+        cloud_mock.get_slicer_settings = AsyncMock(
+            return_value={
+                "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
+                "print": {"private": [], "public": []},
+                "filament": {"private": [], "public": []},
+            }
+        )
+        cloud_mock.close = AsyncMock()
+        user = _user_with_cloud_auth(user_id=101)
+        with (
+            patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
+            patch.object(sp, "BambuCloudService", return_value=cloud_mock),
+        ):
+            await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
+            await sp._fetch_cloud_presets(MagicMock(), user)
+        # Two calls — first refresh, second a normal cache hit.
+        assert cloud_mock.get_slicer_settings.await_count == 1
+
 
 class TestFetchBundledPresets:
     """Standard tier reaches out to the slicer-api sidecar; tolerate the
@@ -356,6 +410,41 @@ class TestFetchBundledPresets:
             slots = await sp._fetch_bundled_presets(MagicMock())
         assert slots["printer"][0].name == "Cached"
 
+    @pytest.mark.asyncio
+    async def test_refresh_bypasses_bundled_cache(self):
+        """``refresh=True`` must re-hit the sidecar even when the in-process
+        cache is warm — paired with the cloud-cache refresh, this is what
+        powers the SliceModal's Refresh button (#1581)."""
+        sp._bundled_cache = (
+            time.monotonic(),
+            {
+                "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
+                "process": [],
+                "filament": [],
+            },
+        )
+        svc_mock = MagicMock()
+        svc_mock.list_bundled_profiles = AsyncMock(
+            return_value={
+                "printer": [{"name": "Fresh", "base_id": None}],
+                "process": [],
+                "filament": [],
+            }
+        )
+        svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
+        svc_mock.__aexit__ = AsyncMock(return_value=False)
+        with (
+            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
+            patch.object(sp, "SlicerApiService", return_value=svc_mock),
+        ):
+            slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
+        svc_mock.list_bundled_profiles.assert_awaited_once()
+        assert [p.name for p in slots["printer"]] == ["Fresh"]
+        # The fresh result must also be written back to the cache so a
+        # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
+        assert sp._bundled_cache is not None
+        assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
+
 
 class TestResolveSlicerApiUrl:
     """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`

+ 66 - 2
frontend/src/__tests__/components/LocalProfilesView.test.tsx

@@ -1,11 +1,17 @@
 /**
  * Tests for LocalProfilesView component.
  */
-import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, fireEvent, render as rawRender } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
 import { server } from '../mocks/server';
 import { render } from '../utils';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
 import { LocalProfilesView } from '../../components/LocalProfilesView';
 
 const mockLocalPresets = {
@@ -209,4 +215,62 @@ describe('LocalProfilesView', () => {
 
     expect(screen.getByText(/\.bbscfg/i)).toBeInTheDocument();
   });
+
+  it('invalidates the slicerPresets query after a delete (#1581)', async () => {
+    // Without this invalidation a preset deleted in Local Profiles still
+    // shows in the SliceModal until the modal's ['slicerPresets'] query
+    // staleTime (60s) expires + a refocus / remount. The bug report:
+    // "Removed local profiles still show on the slice menu even tho they
+    // have been deleted." We mirror the production provider tree but inject
+    // our own QueryClient so we can spy on invalidateQueries.
+    const queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false, gcTime: 0 },
+        mutations: { retry: false },
+      },
+    });
+    const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+    rawRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
+                <LocalProfilesView />
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    // Open the delete confirmation, then confirm. The card-level delete
+    // icon buttons and the modal's Delete button both expose the accessible
+    // name "Delete"; the modal button is the last one to mount, so it's the
+    // tail of the findAllByRole result.
+    const deleteButtons = screen.getAllByTitle(/delete/i);
+    fireEvent.click(deleteButtons[0]);
+    await screen.findByText(/are you sure/i);
+    const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
+    fireEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(
+        invalidateSpy.mock.calls.some(
+          ([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'slicerPresets',
+        ),
+      ).toBe(true);
+    });
+    // Sanity: the local-only invalidation is still there too.
+    expect(
+      invalidateSpy.mock.calls.some(
+        ([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'localPresets',
+      ),
+    ).toBe(true);
+  });
 });

+ 7 - 2
frontend/src/api/client.ts

@@ -5796,8 +5796,13 @@ export const api = {
   // Unified slicer-preset listing — cloud + local + standard, deduped by name.
   // Used by the SliceModal; see UnifiedPresetsResponse for the shape and
   // backend/app/api/routes/slicer_presets.py for the priority rules.
-  getSlicerPresets: () =>
-    request<UnifiedPresetsResponse>('/slicer/presets'),
+  // `refresh` bypasses the in-process cloud and bundled-preset caches on the
+  // backend; the SliceModal's Refresh button passes true so a preset deleted
+  // in Bambu Studio or Bambu Handy shows up without the 5-min TTL wait.
+  getSlicerPresets: (options?: { refresh?: boolean }) =>
+    request<UnifiedPresetsResponse>(
+      options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
+    ),
 
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the

+ 10 - 0
frontend/src/components/LocalProfilesView.tsx

@@ -245,6 +245,11 @@ export function LocalProfilesView() {
     },
     onSuccess: (results) => {
       queryClient.invalidateQueries({ queryKey: ['localPresets'] });
+      // The SliceModal reads from a separate `slicerPresets` query that lists
+      // cloud + local + standard in one shot. Without this second invalidation
+      // freshly-imported profiles wouldn't appear in the SliceModal dropdown
+      // until that query's staleTime elapsed plus a refocus / remount (#1581).
+      queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
       let totalImported = 0;
       let totalSkipped = 0;
       let totalErrors = 0;
@@ -273,6 +278,11 @@ export function LocalProfilesView() {
     mutationFn: (id: number) => api.deleteLocalPreset(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['localPresets'] });
+      // Match the import path: the SliceModal's `slicerPresets` query needs
+      // to be invalidated too, otherwise the deleted preset keeps appearing
+      // in the slice dropdown until its 60s staleTime expires plus a
+      // refocus / remount (#1581).
+      queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
       setDeleteConfirm(null);
       showToast(t('profiles.localProfiles.toast.deleted'));
     },

+ 45 - 3
frontend/src/components/SliceModal.tsx

@@ -1,7 +1,7 @@
-import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
+import { Cloud, CloudOff, Cog, Loader2, Package, RefreshCw, X } from 'lucide-react';
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { useMutation, useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import {
   api,
   type PresetRef,
@@ -312,6 +312,7 @@ function formatElapsed(seconds: number): string {
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
+  const queryClient = useQueryClient();
 
   const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
   const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
@@ -431,6 +432,28 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     enabled: !platesQuery.isLoading && !needsPlatePicker,
   });
 
+  // Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
+  // bundled cache for one call so users who deleted a preset in Bambu
+  // Studio / Bambu Handy see the change immediately (#1581). The cache write
+  // inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
+  // fresh result so subsequent normal callers still get cached responses.
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const handleRefreshPresets = async () => {
+    if (isRefreshing) return;
+    setIsRefreshing(true);
+    try {
+      const fresh = await api.getSlicerPresets({ refresh: true });
+      queryClient.setQueryData(['slicerPresets'], fresh);
+    } catch {
+      // Fall through to invalidate so React Query retries via its normal
+      // path on the next render — surfacing the failure through the existing
+      // presetsQuery.isError banner instead of duplicating error UI here.
+      queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
   // Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
   // configured / no bundles imported yet; the bundle picker hides itself
   // in that case so users without bundles see the original modal layout.
@@ -723,7 +746,26 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
 
           {presetsQuery.data && (
             <>
-              <CloudStatusBanner status={presetsQuery.data.cloud_status} />
+              <div className="flex items-start justify-between gap-2">
+                <div className="flex-1">
+                  <CloudStatusBanner status={presetsQuery.data.cloud_status} />
+                </div>
+                <button
+                  type="button"
+                  onClick={handleRefreshPresets}
+                  disabled={isRefreshing || isEnqueuing}
+                  className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+                  title={t('slice.refreshPresetsTitle')}
+                  aria-label={t('slice.refreshPresets')}
+                >
+                  <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
+                  {t('slice.refreshPresets')}
+                </button>
+              </div>
+              {/* CloudStatusBanner above is hidden via flex-1 wrapper when
+                  status === 'ok' (returns null in that case), but the Refresh
+                  button stays visible regardless so users can pick up cloud /
+                  bundled changes even when sign-in is healthy. */}
               {/* Bundle picker — only renders when at least one .bbscfg has
                   been imported via Settings → Slicer Bundles. Lets the user
                   trade the cloud/local/standard tier for a single curated

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

@@ -3481,6 +3481,8 @@ export default {
     noPresetsForSlot: 'Keine Profile verfügbar',
     otherPrinters: 'Andere Drucker',
     presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
+    refreshPresets: 'Aktualisieren',
+    refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     bundle: 'Slicer-Bundle',
     bundleNone: '— Keines (Profile einzeln auswählen) —',

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

@@ -3484,6 +3484,8 @@ export default {
     noPresetsForSlot: 'No presets available',
     otherPrinters: 'Other printers',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    refreshPresets: 'Refresh',
+    refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
     allPresetsRequired: 'All presets must be selected',
     bundle: 'Slicer bundle',
     bundleNone: '— None (pick presets individually) —',

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

@@ -3484,6 +3484,8 @@ export default {
     noPresetsForSlot: 'No hay preajustes disponibles',
     otherPrinters: 'Otras impresoras',
     presetsLoadFailed: 'Error al cargar los preajustes. Abra Ajustes → Perfiles para importarlos primero.',
+    refreshPresets: 'Actualizar',
+    refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     bundle: 'Paquete del laminador',
     bundleNone: '— Ninguno (elegir preajustes individualmente) —',

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

@@ -3470,6 +3470,8 @@ export default {
     noPresetsForSlot: 'Aucun préréglage disponible',
     otherPrinters: 'Autres imprimantes',
     presetsLoadFailed: 'Échec du chargement des préréglages. Ouvrez Paramètres → Profils pour les importer d\'abord.',
+    refreshPresets: 'Actualiser',
+    refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     bundle: 'Pack de découpage',
     bundleNone: '— Aucun (choisir les préréglages individuellement) —',

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

@@ -3469,6 +3469,8 @@ export default {
     noPresetsForSlot: 'Nessun preset disponibile',
     otherPrinters: 'Altre stampanti',
     presetsLoadFailed: 'Caricamento preset fallito. Apri Impostazioni → Profili per importarli prima.',
+    refreshPresets: 'Aggiorna',
+    refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     bundle: 'Bundle slicer',
     bundleNone: '— Nessuno (scegli i preset singolarmente) —',

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

@@ -3481,6 +3481,8 @@ export default {
     noPresetsForSlot: 'プリセットなし',
     otherPrinters: '他のプリンター',
     presetsLoadFailed: 'プリセットの読み込みに失敗。先に設定 → プロファイルからインポートしてください。',
+    refreshPresets: '再読み込み',
+    refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     bundle: 'スライサーバンドル',
     bundleNone: '— なし(プリセットを個別に選択)—',

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

@@ -3469,6 +3469,8 @@ export default {
     noPresetsForSlot: 'Nenhuma predefinição disponível',
     otherPrinters: 'Outras impressoras',
     presetsLoadFailed: 'Falha ao carregar predefinições. Abra Configurações → Perfis para importá-las primeiro.',
+    refreshPresets: 'Atualizar',
+    refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     bundle: 'Pacote do fatiador',
     bundleNone: '— Nenhum (escolher predefinições individualmente) —',

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

@@ -3469,6 +3469,8 @@ export default {
     noPresetsForSlot: '无可用预设',
     otherPrinters: '其他打印机',
     presetsLoadFailed: '加载预设失败。请先打开设置 → 配置文件以导入。',
+    refreshPresets: '刷新',
+    refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
     allPresetsRequired: '必须选择所有预设',
     bundle: '切片器套装',
     bundleNone: '— 无(单独选择预设)—',

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

@@ -3469,6 +3469,8 @@ export default {
     noPresetsForSlot: '無可用預設',
     otherPrinters: '其他印表機',
     presetsLoadFailed: '載入預設失敗。請先開啟設定 → 設定檔以匯入。',
+    refreshPresets: '重新整理',
+    refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
     allPresetsRequired: '必須選擇所有預設',
     bundle: '切片器套裝',
     bundleNone: '— 無(單獨選擇預設)—',

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-DqHz1llA.js


+ 1 - 1
static/index.html

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

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor