فهرست منبع

feat(printers): show remaining time, ETA and layers on the size-S card (#2674)

Size S exists for one job: watching a whole fleet on a single screen. It
rendered the printer name, a status pip and a progress bar - every other
block on the card is gated behind the expanded view - so it could not
answer the question that view is for, "which printer finishes first".
Dropping to S to fit more printers meant losing the information you
dropped down to compare.

The compact card now carries one line of metrics under the progress bar
while a print is running: remaining time, ETA in the configured
12/24-hour format, and layer progress. These are the values the Medium
card already shows, rendered with the same formatters and the same ETA
styling so the two sizes read alike. Each value is omitted individually
when the printer does not report it, and the row holds its height when
nothing is printing so cards do not shift as prints start and finish.
Card dimensions and grid density are otherwise unchanged.

Frontend only. Wiki updated. Covered by tests - which required teaching
the new test file to mock localStorage.getItem, since the harness
replaces localStorage with bare vi.fn() stubs and setItem is a no-op;
without that the page falls back to its size-M default and the compact
branch never renders.
maziggy 1 ماه پیش
والد
کامیت
4e46ba071f

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2 - 0
CHANGELOG.md


+ 130 - 0
frontend/src/__tests__/pages/PrintersPageCompactMetrics.test.tsx

@@ -0,0 +1,130 @@
+/**
+ * Tests for the metrics line on the size-S (compact) printer card (#2674).
+ *
+ * Size S used to show a name, a connection pip and a progress bar — not enough
+ * to answer "which printer finishes first", which is the whole point of a
+ * wall-mounted fleet view. It now carries remaining time, ETA and layer
+ * progress, reusing the formatters the expanded card already uses.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    is_active: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const baseStatus = {
+  connected: true,
+  temperatures: { nozzle: 220, bed: 60, chamber: 35 },
+  filename: 'test_print.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+  hms_errors: [],
+};
+
+// 83 minutes remaining → formatDuration(83 * 60) === "1h 23m"
+const printingStatus = {
+  ...baseStatus,
+  state: 'RUNNING',
+  progress: 42,
+  layer_num: 120,
+  total_layers: 267,
+  remaining_time: 83,
+};
+
+const idleStatus = {
+  ...baseStatus,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  remaining_time: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+};
+
+function useStatus(status: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+  );
+}
+
+describe('PrintersPage — size S metrics line (#2674)', () => {
+  beforeEach(() => {
+    // Size S. The card size is read from localStorage on first render — and
+    // the test harness replaces localStorage with bare vi.fn() stubs
+    // (setup.ts), so setItem is a no-op and getItem has to be taught the
+    // value. Without this the page falls back to its size-M default and the
+    // compact branch never renders.
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+      key === 'printerCardSize' ? '1' : null,
+    );
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+  });
+
+  it('shows remaining time, ETA and layer progress while printing', async () => {
+    useStatus(printingStatus);
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('1h 23m')).toBeInTheDocument();
+    expect(screen.getByText('120/267')).toBeInTheDocument();
+    // ETA is rendered as "ETA <clock time>"; the clock time itself depends on
+    // when the test runs, so match the prefix.
+    expect(screen.getByText(/^ETA\s/)).toBeInTheDocument();
+  });
+
+  it('shows no metrics when the printer is idle', async () => {
+    useStatus(idleStatus);
+
+    render(<PrintersPage />);
+
+    // The progress placeholder confirms we are looking at the compact card.
+    expect(await screen.findByText('---%')).toBeInTheDocument();
+    expect(screen.queryByText(/^ETA\s/)).not.toBeInTheDocument();
+    expect(screen.queryByText('0/0')).not.toBeInTheDocument();
+  });
+
+  it('omits the ETA but keeps layers when the printer reports no remaining time', async () => {
+    useStatus({ ...printingStatus, remaining_time: 0 });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('120/267')).toBeInTheDocument();
+    expect(screen.queryByText(/^ETA\s/)).not.toBeInTheDocument();
+  });
+
+  it('omits layers when the printer reports no total layer count', async () => {
+    useStatus({ ...printingStatus, layer_num: 0, total_layers: 0 });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('1h 23m')).toBeInTheDocument();
+    expect(screen.queryByText('0/0')).not.toBeInTheDocument();
+  });
+});

+ 47 - 10
frontend/src/pages/PrintersPage.tsx

@@ -3678,18 +3678,55 @@ function PrinterCard({
                     ? 'bg-status-warning'
                     ? 'bg-status-warning'
                     : 'bg-bambu-green';
                     : 'bg-bambu-green';
 
 
+                const hasCompactEta = isActiveCompactPrint && status.remaining_time != null && status.remaining_time > 0;
+                const hasCompactLayers =
+                  isActiveCompactPrint &&
+                  status.layer_num != null &&
+                  status.total_layers != null &&
+                  status.total_layers > 0;
+
                 return (
                 return (
-                  <div className="relative mt-2 flex items-center gap-2">
-                    <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-bambu-dark-tertiary">
-                      <div
-                        className={`${compactProgressClass} h-1.5 rounded-full transition-all`}
-                        style={{ width: `${compactProgress}%` }}
-                      />
+                  <>
+                    <div className="relative mt-2 flex items-center gap-2">
+                      <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-bambu-dark-tertiary">
+                        <div
+                          className={`${compactProgressClass} h-1.5 rounded-full transition-all`}
+                          style={{ width: `${compactProgress}%` }}
+                        />
+                      </div>
+                      <span className={`w-9 shrink-0 text-right text-[11px] leading-none ${isActiveCompactPrint ? 'text-white' : 'text-bambu-gray'}`}>
+                        {isActiveCompactPrint ? `${Math.round(compactProgress)}%` : '---%'}
+                      </span>
                     </div>
                     </div>
-                    <span className={`w-9 shrink-0 text-right text-[11px] leading-none ${isActiveCompactPrint ? 'text-white' : 'text-bambu-gray'}`}>
-                      {isActiveCompactPrint ? `${Math.round(compactProgress)}%` : '---%'}
-                    </span>
-                  </div>
+                    {/* #2674: size S showed only a name, a pip and a progress bar — not
+                        enough to answer "which printer finishes first", which is what a
+                        wall-mounted fleet view is for. One line of the metrics the
+                        expanded card already renders, using the same formatters and the
+                        same ETA styling so S and M read alike. The row keeps its height
+                        when idle so cards don't shift as prints start and stop. */}
+                    <div className="mt-1 flex min-h-[14px] items-center gap-2 overflow-hidden text-[11px] leading-none text-bambu-gray">
+                      {hasCompactEta && (
+                        <>
+                          <span className="flex shrink-0 items-center gap-1">
+                            <Clock className="w-3 h-3" />
+                            {formatDuration(status.remaining_time! * 60)}
+                          </span>
+                          <span
+                            className="shrink-0 font-medium text-bambu-green"
+                            title={t('printers.estimatedCompletion')}
+                          >
+                            ETA {formatETA(status.remaining_time!, timeFormat, t)}
+                          </span>
+                        </>
+                      )}
+                      {hasCompactLayers && (
+                        <span className="flex min-w-0 items-center gap-1 truncate">
+                          <Layers className="w-3 h-3 shrink-0" />
+                          {status.layer_num}/{status.total_layers}
+                        </span>
+                      )}
+                    </div>
+                  </>
                 );
                 );
               })()
               })()
             ) : (
             ) : (

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


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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,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-BNeeHAqi.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
+    <script type="module" crossorigin src="/assets/index-JUS5PFis.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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