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

Show the Print Log's per-run cost and energy, and let users pick columns (#2636)

The list and update endpoints serialised field by field and never named
cost / energy_kwh / energy_cost, so values Bambuddy had been recording
all along went out as nulls. Both now validate from the ORM row, which
removes the chance to omit a field rather than patching the three that
were missing.

Adds a Filament Used column plus a Columns picker for Cost, Energy,
Energy Cost and Finished, persisted per browser.

Also fixes the log view being unreachable with zero archives: the empty
state ran before the view check, hiding a log that outlives the archives
it refers to.

---

Sort the Print Log by any column (#2636)

Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
column headers. Server-side because paging is: ordering the rows the
client holds would sort one page rather than the log.

Empty values are held last in both directions — Postgres sorts NULLs
high and SQLite low, so the same click would otherwise open on blanks
on one backend and values on the other. id DESC breaks ties so paging
through a low-cardinality sort can't repeat or skip a row.
maziggy 1 месяц назад
Родитель
Сommit
a08d3e62f3

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


+ 57 - 47
backend/app/api/routes/print_log.py

@@ -3,7 +3,7 @@ from datetime import datetime
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -22,6 +22,30 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/print-log", tags=["print-log"])
 
+# Sortable columns, keyed by the id the Print Log table uses for its columns
+# (#2636). An explicit map rather than getattr on a caller-supplied string:
+# the client picks the key, so anything else would let a request order by any
+# attribute it can name.
+#
+# ``date`` coalesces because the column renders ``started_at or created_at`` —
+# sorting on started_at alone would scatter the rows that have no start time
+# (queue-skipped entries) instead of interleaving them where the user sees
+# them.
+_SORTABLE_COLUMNS = {
+    "date": func.coalesce(PrintLogEntry.started_at, PrintLogEntry.created_at),
+    "print_name": PrintLogEntry.print_name,
+    "printer": PrintLogEntry.printer_name,
+    "user": PrintLogEntry.created_by_username,
+    "status": PrintLogEntry.status,
+    "duration": PrintLogEntry.duration_seconds,
+    "completed_at": PrintLogEntry.completed_at,
+    "filament": PrintLogEntry.filament_type,
+    "filament_used": PrintLogEntry.filament_used_grams,
+    "cost": PrintLogEntry.cost,
+    "energy": PrintLogEntry.energy_kwh,
+    "energy_cost": PrintLogEntry.energy_cost,
+}
+
 
 @router.get("/", response_model=PrintLogResponse)
 async def get_print_log(
@@ -33,6 +57,8 @@ async def get_print_log(
     date_to: datetime | None = None,
     limit: int = Query(default=50, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
+    sort_by: str = Query(default="date"),
+    sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -72,37 +98,36 @@ async def get_print_log(
     total_result = await db.execute(count_query)
     total = total_result.scalar() or 0
 
-    # Get paginated results
-    query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
+    # Sorting happens here rather than in the browser because the table is
+    # paginated server-side: ordering the 25 rows the client happens to hold
+    # would answer "the most expensive print on this page", which is not what
+    # clicking a column header means.
+    sort_column = _SORTABLE_COLUMNS.get(sort_by)
+    if sort_column is None:
+        raise HTTPException(400, f"Cannot sort by {sort_by!r}")
+    ordering = sort_column.asc() if sort_dir == "asc" else sort_column.desc()
+    # NULLs last in both directions, so a column that is empty for half the
+    # rows (cost before a spool is priced, energy without a smart plug) never
+    # buries the rows that do have values. Left to the database this differs
+    # per backend — Postgres sorts NULLs high, SQLite sorts them low — so the
+    # same click would give two different first pages depending on deployment.
+    query = query.order_by(nullslast(ordering), PrintLogEntry.id.desc())
+    # id.desc() above is the tiebreaker: without it, rows sharing a value
+    # (every "completed" when sorting by status) come back in whatever order
+    # the planner picks, which can differ between pages and duplicate or drop
+    # a row as the user pages through.
+    query = query.offset(offset).limit(limit)
     result = await db.execute(query)
     entries = result.scalars().all()
 
+    # Validate straight off the ORM rows rather than naming each field: the
+    # hand-written version dropped whatever it forgot to mention, and a
+    # forgotten field is indistinguishable from a NULL column on the wire.
+    # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
+    # energy_cost, which were written to the table but never sent — so the
+    # Print Log's cost and energy columns read empty for every run (#2636).
     return PrintLogResponse(
-        items=[
-            PrintLogEntrySchema(
-                id=e.id,
-                archive_id=e.archive_id,
-                print_name=e.print_name,
-                printer_name=e.printer_name,
-                printer_id=e.printer_id,
-                status=e.status,
-                started_at=e.started_at,
-                completed_at=e.completed_at,
-                duration_seconds=e.duration_seconds,
-                filament_type=e.filament_type,
-                filament_color=e.filament_color,
-                filament_used_grams=e.filament_used_grams,
-                # failure_reason was silently dropped by the GET serialiser
-                # before #1687 part 4 — without it the Print Log table couldn't
-                # surface what the Failure Analysis widget already groups by.
-                failure_reason=e.failure_reason,
-                thumbnail_path=e.thumbnail_path,
-                created_by_id=e.created_by_id,
-                created_by_username=e.created_by_username,
-                created_at=e.created_at,
-            )
-            for e in entries
-        ],
+        items=[PrintLogEntrySchema.model_validate(e) for e in entries],
         total=total,
     )
 
@@ -285,22 +310,7 @@ async def update_print_log_entry(
         entry.status,
     )
 
-    return PrintLogEntrySchema(
-        id=entry.id,
-        archive_id=entry.archive_id,
-        print_name=entry.print_name,
-        printer_name=entry.printer_name,
-        printer_id=entry.printer_id,
-        status=entry.status,
-        started_at=entry.started_at,
-        completed_at=entry.completed_at,
-        duration_seconds=entry.duration_seconds,
-        filament_type=entry.filament_type,
-        filament_color=entry.filament_color,
-        filament_used_grams=entry.filament_used_grams,
-        failure_reason=entry.failure_reason,
-        thumbnail_path=entry.thumbnail_path,
-        created_by_id=entry.created_by_id,
-        created_by_username=entry.created_by_username,
-        created_at=entry.created_at,
-    )
+    # Same field-by-field trap as the list route: this one also omitted cost
+    # and the energy pair, so the row the client merged back after an edit
+    # blanked whichever columns it was showing for them.
+    return PrintLogEntrySchema.model_validate(entry)

+ 9 - 1
backend/app/schemas/print_log.py

@@ -1,9 +1,17 @@
 from datetime import datetime
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
 
 
 class PrintLogEntrySchema(BaseModel):
+    # from_attributes lets the routes build this straight off the ORM row.
+    # The GET serialiser used to name every field by hand, and each field it
+    # forgot came back as its default — a silent null rather than an error.
+    # That cost the log its failure_reason (#1687 part 4) and then its cost /
+    # energy_kwh / energy_cost (#2636). Validating from the row removes the
+    # chance to forget one.
+    model_config = ConfigDict(from_attributes=True)
+
     id: int
     archive_id: int | None = None
     print_name: str | None = None

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

@@ -1952,3 +1952,128 @@ class TestSoftDeletedArchivesAreExcluded:
 
         assert response.status_code == 200
         assert response.json()["failed_prints"] == 1
+
+
+class TestPrintLogSorting:
+    """#2636: the Print Log's column headers sort the whole log.
+
+    Sorting is server-side because paging is: ordering the rows the browser
+    happens to hold would answer "the most expensive print on this page",
+    not "the most expensive print". These pin the ordering contract the
+    headers depend on, including the two cases that differ per database
+    backend or per query plan if left implicit.
+    """
+
+    @staticmethod
+    async def _seed(db_session, printer_id: int, rows: list[dict]):
+        from datetime import datetime
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        created = []
+        for i, row in enumerate(rows):
+            entry = PrintLogEntry(
+                printer_id=printer_id,
+                status=row.get("status", "completed"),
+                print_name=row.get("print_name", f"job-{i}"),
+                started_at=datetime(2026, 1, 1 + i, 12, 0, 0),
+                created_at=datetime(2026, 1, 1 + i, 12, 0, 0),
+                filament_used_grams=row.get("grams"),
+                cost=row.get("cost"),
+                energy_kwh=row.get("kwh"),
+            )
+            db_session.add(entry)
+            created.append(entry)
+        await db_session.commit()
+        return created
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sorts_by_filament_used_in_both_directions(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await self._seed(
+            db_session,
+            printer.id,
+            [{"grams": 5.0}, {"grams": 120.0}, {"grams": 30.0}],
+        )
+
+        asc = await async_client.get("/api/v1/print-log/?sort_by=filament_used&sort_dir=asc")
+        assert asc.status_code == 200
+        assert [e["filament_used_grams"] for e in asc.json()["items"]] == [5.0, 30.0, 120.0]
+
+        desc = await async_client.get("/api/v1/print-log/?sort_by=filament_used&sort_dir=desc")
+        assert [e["filament_used_grams"] for e in desc.json()["items"]] == [120.0, 30.0, 5.0]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_values_sort_last_whichever_direction(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Cost is NULL until a spool is priced and energy until a smart plug
+        reports, so these columns are half-empty for most people. Postgres
+        sorts NULLs high and SQLite sorts them low, so without an explicit
+        NULLS LAST the same click gives a different first page depending on
+        which database the user deployed — and on one of them, a screenful
+        of blanks."""
+        printer = await printer_factory()
+        await self._seed(
+            db_session,
+            printer.id,
+            [{"cost": None}, {"cost": 2.5}, {"cost": None}, {"cost": 0.75}],
+        )
+
+        for direction, expected in (("asc", [0.75, 2.5]), ("desc", [2.5, 0.75])):
+            resp = await async_client.get(f"/api/v1/print-log/?sort_by=cost&sort_dir={direction}")
+            costs = [e["cost"] for e in resp.json()["items"]]
+            assert costs[:2] == expected, (direction, costs)
+            assert costs[2:] == [None, None], (direction, costs)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ties_are_broken_stably_across_pages(self, async_client: AsyncClient, printer_factory, db_session):
+        """Sorting by a column where every row shares a value (status) leaves
+        the order to the planner unless a tiebreaker is added — and an
+        unstable order can show the same row on two pages while another never
+        appears at all."""
+        printer = await printer_factory()
+        await self._seed(db_session, printer.id, [{"status": "completed"} for _ in range(6)])
+
+        first = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=0")
+        second = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=3")
+        page_1 = [e["id"] for e in first.json()["items"]]
+        page_2 = [e["id"] for e in second.json()["items"]]
+
+        assert len(set(page_1) & set(page_2)) == 0, "a row appeared on both pages"
+        assert len(set(page_1) | set(page_2)) == 6, "a row was never returned"
+        # Repeating the same request must give the same page back.
+        again = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=0")
+        assert [e["id"] for e in again.json()["items"]] == page_1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_sort_column_is_rejected(self, async_client: AsyncClient):
+        """The client picks the sort key, so the column list is a whitelist —
+        anything else would let a request order by any attribute it can name."""
+        resp = await async_client.get("/api/v1/print-log/?sort_by=created_by_id")
+        assert resp.status_code == 400
+        resp = await async_client.get("/api/v1/print-log/?sort_by=1;DROP")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_direction_is_rejected(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/print-log/?sort_by=date&sort_dir=sideways")
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_newest_first(self, async_client: AsyncClient, printer_factory, db_session):
+        """No sort params — the pre-#2636 behaviour, which existing clients
+        and the first page load both rely on."""
+        printer = await printer_factory()
+        await self._seed(db_session, printer.id, [{"print_name": "oldest"}, {"print_name": "newest"}])
+
+        resp = await async_client.get("/api/v1/print-log/")
+        assert [e["print_name"] for e in resp.json()["items"]] == ["newest", "oldest"]

+ 65 - 0
backend/tests/unit/test_print_log.py

@@ -145,3 +145,68 @@ class TestWriteLogEntryReconciledDuration:
     async def test_non_reconciled_missing_times_is_none(self):
         entry = await self._write(status="completed", started_at=datetime(2026, 7, 15, 10, 0, 0))
         assert entry.duration_seconds is None
+
+
+class TestSchemaValidatesFromOrmRow:
+    """#2636: the Print Log's cost and energy columns read empty for every
+    run because both routes built the response field-by-field and simply
+    never mentioned ``cost`` / ``energy_kwh`` / ``energy_cost``. Pydantic
+    filled the gap with each field's default, so a dropped field looked
+    exactly like a NULL column on the wire — no error, no log line. The
+    same trap had already eaten ``failure_reason`` once (#1687 part 4).
+
+    Validating from the ORM row is what removes the chance to forget one, so
+    these tests pin the mechanism rather than any particular field list.
+    """
+
+    @staticmethod
+    def _row(**overrides):
+        row = MagicMock()
+        row.id = 7
+        row.archive_id = 3
+        row.print_name = "Benchy"
+        row.printer_name = "X1C-01"
+        row.printer_id = 1
+        row.status = "completed"
+        row.started_at = datetime(2026, 7, 24, 18, 35, 0)
+        row.completed_at = datetime(2026, 7, 24, 19, 24, 0)
+        row.duration_seconds = 2940
+        row.filament_type = "PLA"
+        row.filament_color = "#000000"
+        row.filament_used_grams = 15.5
+        row.cost = 0.42
+        row.energy_kwh = 0.31
+        row.energy_cost = 0.09
+        # Non-null so `test_every_declared_field_is_carried` can assert that
+        # nothing falls back to its default.
+        row.failure_reason = "warping"
+        row.thumbnail_path = "archives/1/x/thumbnail.png"
+        row.created_by_id = 2
+        row.created_by_username = "martin"
+        row.created_at = datetime(2026, 7, 24, 18, 35, 0)
+        for k, v in overrides.items():
+            setattr(row, k, v)
+        return row
+
+    def test_money_and_energy_survive_the_round_trip(self):
+        entry = PrintLogEntrySchema.model_validate(self._row())
+        assert entry.cost == 0.42
+        assert entry.energy_kwh == 0.31
+        assert entry.energy_cost == 0.09
+        assert entry.filament_used_grams == 15.5
+
+    def test_every_declared_field_is_carried(self):
+        """Nothing on the schema may come back as its default when the row
+        has a value — that is the whole failure mode, generalised."""
+        entry = PrintLogEntrySchema.model_validate(self._row())
+        for name in PrintLogEntrySchema.model_fields:
+            assert getattr(entry, name) is not None, f"{name} was dropped in serialisation"
+
+    def test_a_genuinely_null_column_stays_null(self):
+        """The counterpart: energy is written by a background task after the
+        row, so a just-finished print really has none. That must read as
+        None, not as a fabricated zero."""
+        entry = PrintLogEntrySchema.model_validate(self._row(energy_kwh=None, energy_cost=None))
+        assert entry.energy_kwh is None
+        assert entry.energy_cost is None
+        assert entry.cost == 0.42

+ 300 - 0
frontend/src/__tests__/pages/ArchivesPagePrintLogColumns.test.tsx

@@ -0,0 +1,300 @@
+/**
+ * Print Log column configuration (#2636, reporter @ajbastien).
+ *
+ * The log view hardcoded seven columns, so four populated fields of
+ * `print_log_entries` — filament used, cost, energy, energy cost — were
+ * unreachable in the UI even though the API had always returned them. The
+ * "Filament" column showed only type and colour, which is what made the
+ * amount look missing: the per-archive Print Log modal has shown grams since
+ * it was written, so the two surfaces disagreed.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ArchivesPage } from '../../pages/ArchivesPage';
+
+/** Columns visible out of the box — the seven that shipped before, plus the
+ *  filament amount #2636 asked for. */
+const DEFAULT_VISIBLE_COUNT = 8;
+
+const LOG_ENTRIES = [
+  {
+    id: 1,
+    archive_id: 10,
+    print_name: 'Califlower Calibration',
+    printer_name: '3DP-00M-191',
+    printer_id: 1,
+    status: 'completed',
+    started_at: '2026-07-24T18:35:00Z',
+    completed_at: '2026-07-24T19:24:00Z',
+    duration_seconds: 2940,
+    filament_type: 'PLA',
+    filament_color: '#000000',
+    filament_used_grams: 15.5,
+    cost: 0.42,
+    energy_kwh: 0.31,
+    energy_cost: 0.09,
+    failure_reason: null,
+    thumbnail_path: null,
+    created_by_id: null,
+    created_by_username: null,
+    created_at: '2026-07-24T18:35:00Z',
+  },
+];
+
+/** One archive so the page isn't in its empty state; the log view doesn't
+ *  read it, but the rest of the page does. */
+const ONE_ARCHIVE = [
+  {
+    id: 10,
+    filename: 'cali.gcode.3mf',
+    print_name: 'Califlower',
+    printer_id: 1,
+    printer_name: '3DP-00M-191',
+    print_time_seconds: 2940,
+    filament_used_grams: 15.5,
+    status: 'completed',
+    started_at: '2026-07-24T18:35:00Z',
+    completed_at: '2026-07-24T19:24:00Z',
+    thumbnail_path: null,
+    notes: null,
+    rating: null,
+    project_id: null,
+    project_name: null,
+    project_color: null,
+    print_count: 1,
+    tags: '',
+    created_at: '2026-07-24T18:00:00Z',
+    updated_at: '2026-07-24T19:24:00Z',
+    has_f3d: false,
+  },
+];
+
+/** Query strings the page asked the log endpoint for, newest last. */
+const logRequests: URLSearchParams[] = [];
+
+function mockLog(entries = LOG_ENTRIES, archives: unknown[] = ONE_ARCHIVE) {
+  server.use(
+    http.get('/api/v1/archives/', () => HttpResponse.json(archives)),
+    http.get('/api/v1/archives/stats', () =>
+      HttpResponse.json({
+        total_archives: 0,
+        total_print_time_seconds: 0,
+        total_filament_grams: 0,
+        prints_this_week: 0,
+        prints_this_month: 0,
+      }),
+    ),
+    http.get('/api/v1/archives/tags', () => HttpResponse.json([])),
+    http.get('/api/v1/print-log/', ({ request }) => {
+      logRequests.push(new URL(request.url).searchParams);
+      return HttpResponse.json({ items: entries, total: entries.length });
+    }),
+  );
+}
+
+/** `setup.ts` replaces localStorage with a no-op vi.fn() stub, so a stored
+ *  config has to be handed to the component through the mock. Keyed on the
+ *  column key alone — the page reads several other keys (view mode, page
+ *  size) and answering those with column JSON would derail the whole page. */
+function stubStoredColumns(value: string | null) {
+  vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+    (key === 'bambuddy-printlog-columns' ? value : null),
+  );
+}
+
+/** Switch to the log view — the clipboard icon beside grid / list / calendar. */
+async function openLogView() {
+  render(<ArchivesPage />);
+  await waitFor(() => expect(screen.getByTitle('Print Log')).toBeInTheDocument());
+  fireEvent.click(screen.getByTitle('Print Log'));
+  await waitFor(() => expect(screen.getByText('All Statuses')).toBeInTheDocument());
+  await waitFor(() => expect(screen.queryByText('No print log entries found')).toBeNull());
+}
+
+describe('Print Log columns', () => {
+  beforeEach(() => {
+    logRequests.length = 0;
+    stubStoredColumns(null);
+    vi.mocked(localStorage.setItem).mockClear();
+    mockLog();
+  });
+
+  it('shows how much filament the run used, not just which filament', async () => {
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    // The pre-existing "Filament" column: colour swatch + type.
+    expect(within(table).getByText('PLA')).toBeInTheDocument();
+    // The amount, which is what the issue asked for.
+    expect(within(table).getByText('Filament Used')).toBeInTheDocument();
+    expect(within(table).getByText('15.5 g')).toBeInTheDocument();
+  });
+
+  it('keeps the optional columns hidden until asked for', async () => {
+    await openLogView();
+
+    // Cost / energy exist on every row but would widen the table for everyone,
+    // so they ship off. Scoped to the table: "Cost" also appears elsewhere.
+    const table = screen.getByRole('table');
+    expect(within(table).queryByText('Cost')).not.toBeInTheDocument();
+    expect(within(table).queryByText('Energy')).not.toBeInTheDocument();
+    expect(within(table).queryByText('$0.42')).not.toBeInTheDocument();
+  });
+
+  it('renders a column the user switches on, and remembers it', async () => {
+    await openLogView();
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Columns/ }));
+    expect(await screen.findByText('Configure Columns')).toBeInTheDocument();
+
+    // Turn on the first hidden column, then apply.
+    await user.click(screen.getAllByTitle('Show column')[0]);
+    await user.click(screen.getByRole('button', { name: 'Apply Changes' }));
+
+    await waitFor(() => {
+      expect(localStorage.setItem).toHaveBeenCalledWith(
+        'bambuddy-printlog-columns',
+        expect.any(String),
+      );
+    });
+    const [, written] = vi.mocked(localStorage.setItem).mock.calls.at(-1)!;
+    const stored = JSON.parse(written as string) as Array<Record<string, unknown>>;
+    // Labels are deliberately NOT persisted — they are re-derived from t() on
+    // every render, so a config stored before a language switch can't pin the
+    // picker to the old language.
+    expect(Object.keys(stored[0]).sort()).toEqual(['id', 'visible']);
+    // Whatever was off is now on: the picker's first hidden entry.
+    expect(stored.filter((c) => c.visible).length).toBeGreaterThan(
+      DEFAULT_VISIBLE_COUNT,
+    );
+  });
+
+  it('falls back to defaults when the stored config is unusable', async () => {
+    // A hand-edited or truncated value must not take the page down with it.
+    stubStoredColumns('{not json');
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    expect(within(table).getByText('Filament Used')).toBeInTheDocument();
+    expect(within(table).getByText('15.5 g')).toBeInTheDocument();
+  });
+
+  it('drops columns that no longer exist and adopts ones added since', async () => {
+    // An upgrade must neither crash on a removed id nor silently hide a new
+    // column: the stored order wins, unknown ids go, new defaults append.
+    stubStoredColumns(
+      JSON.stringify([
+        { id: 'date', visible: true },
+        { id: 'gone_in_a_later_version', visible: true },
+      ]),
+    );
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    expect(within(table).getByText('Date')).toBeInTheDocument();
+    // Appended from the defaults, with its default visibility.
+    expect(within(table).getByText('Filament Used')).toBeInTheDocument();
+  });
+
+  it('shows a dash for energy that the background task has not written yet', async () => {
+    // energy_kwh / energy_cost land via a background task after the row is
+    // created, so a just-finished print genuinely has none.
+    stubStoredColumns(
+      JSON.stringify([
+        { id: 'print_name', visible: true },
+        { id: 'energy', visible: true },
+      ]),
+    );
+    // Every other visible cell has a value, so a lone dash can only be energy.
+    mockLog([{ ...LOG_ENTRIES[0], energy_kwh: null, created_by_username: 'martin' }]);
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    expect(within(table).getByText('Energy')).toBeInTheDocument();
+    expect(within(table).getByText('—')).toBeInTheDocument();
+    expect(within(table).queryByText('0.31 kWh')).not.toBeInTheDocument();
+  });
+
+  it('reaches the log even when there are no archives left', async () => {
+    // `print_log_entries` outlives the archives it refers to — deleting an
+    // archive only NULLs the FK, and clearing the log is a separate action.
+    // The page's "no archives yet" branch used to short-circuit before the
+    // log branch, so purging archives made the whole Print Log unreachable
+    // while its rows were still in the database.
+    mockLog(LOG_ENTRIES, []);
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    expect(within(table).getByText('Califlower Calibration')).toBeInTheDocument();
+    expect(within(table).getByText('15.5 g')).toBeInTheDocument();
+  });
+
+  it('sorts on the server, not just the page the browser is holding', async () => {
+    // The table is paginated server-side, so ordering the 25 rows in hand
+    // would answer "the priciest print on this page". The click has to reach
+    // the query.
+    await openLogView();
+    logRequests.length = 0;
+
+    await userEvent.setup().click(screen.getByRole('button', { name: /Filament Used/ }));
+
+    await waitFor(() => expect(logRequests.length).toBeGreaterThan(0));
+    const last = logRequests[logRequests.length - 1];
+    expect(last.get('sort_by')).toBe('filament_used');
+    // Amounts open biggest-first; that is what someone clicking them wants.
+    expect(last.get('sort_dir')).toBe('desc');
+    // Back to page 1 — re-sorting from page 3 would otherwise drop the user
+    // into the middle of a freshly ordered list with no explanation.
+    expect(last.get('offset')).toBe('0');
+  });
+
+  it('flips direction on a second click of the same column', async () => {
+    await openLogView();
+    const user = userEvent.setup();
+
+    await user.click(screen.getByRole('button', { name: /Filament Used/ }));
+    await waitFor(() =>
+      expect(logRequests[logRequests.length - 1].get('sort_dir')).toBe('desc'),
+    );
+
+    await user.click(screen.getByRole('button', { name: /Filament Used/ }));
+    await waitFor(() =>
+      expect(logRequests[logRequests.length - 1].get('sort_dir')).toBe('asc'),
+    );
+    expect(logRequests[logRequests.length - 1].get('sort_by')).toBe('filament_used');
+  });
+
+  it('opens text columns A-Z and marks the active header for screen readers', async () => {
+    await openLogView();
+
+    await userEvent.setup().click(screen.getByRole('button', { name: /Printer/ }));
+
+    await waitFor(() =>
+      expect(logRequests[logRequests.length - 1].get('sort_by')).toBe('printer'),
+    );
+    expect(logRequests[logRequests.length - 1].get('sort_dir')).toBe('asc');
+
+    const header = screen.getByRole('columnheader', { name: /Printer/ });
+    expect(header).toHaveAttribute('aria-sort', 'ascending');
+    // Only the active column claims a sort.
+    expect(screen.getByRole('columnheader', { name: /Status/ })).toHaveAttribute('aria-sort', 'none');
+  });
+
+  it('starts from the stored sort and ignores one naming a dropped column', async () => {
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => {
+      if (key === 'bambuddy-printlog-sort') return JSON.stringify({ column: 'not_a_column', direction: 'asc' });
+      return null;
+    });
+    await openLogView();
+
+    // Falls back to the default rather than sending a key the API rejects.
+    expect(logRequests[0].get('sort_by')).toBe('date');
+    expect(logRequests[0].get('sort_dir')).toBe('desc');
+  });
+});

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

@@ -4836,6 +4836,10 @@ export const api = {
     dateTo?: string;
     limit?: number;
     offset?: number;
+    // Sorting is server-side because paging is: ordering only the rows the
+    // client holds would sort one page, not the log (#2636).
+    sortBy?: string;
+    sortDir?: 'asc' | 'desc';
   }) => {
     const searchParams = new URLSearchParams();
     if (params?.search) searchParams.set('search', params.search);
@@ -4846,6 +4850,8 @@ export const api = {
     if (params?.dateTo) searchParams.set('date_to', params.dateTo);
     if (params?.limit) searchParams.set('limit', String(params.limit));
     if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
+    if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
+    if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
     return request<PrintLogResponse>(`/print-log/?${searchParams}`);
   },
   getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),

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

@@ -1030,6 +1030,13 @@ export default {
       status: 'Status',
       duration: 'Dauer',
       filament: 'Filament',
+      filamentUsed: 'Filamentverbrauch',
+      cost: 'Kosten',
+      energy: 'Energie',
+      energyCost: 'Energiekosten',
+      completedAt: 'Beendet',
+      columns: 'Spalten',
+      sortBy: 'Nach {{column}} sortieren',
       allPrinters: 'Alle Drucker',
       allUsers: 'Alle Benutzer',
       allStatuses: 'Alle Status',

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

@@ -1034,6 +1034,13 @@ export default {
       status: 'Status',
       duration: 'Duration',
       filament: 'Filament',
+      filamentUsed: 'Filament Used',
+      cost: 'Cost',
+      energy: 'Energy',
+      energyCost: 'Energy Cost',
+      completedAt: 'Finished',
+      columns: 'Columns',
+      sortBy: 'Sort by {{column}}',
       allPrinters: 'All Printers',
       allUsers: 'All Users',
       allStatuses: 'All Statuses',

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

@@ -1030,6 +1030,13 @@ export default {
       status: 'Estado',
       duration: 'Duración',
       filament: 'Filamento',
+      filamentUsed: 'Filamento usado',
+      cost: 'Coste',
+      energy: 'Energía',
+      energyCost: 'Coste de energía',
+      completedAt: 'Finalizado',
+      columns: 'Columnas',
+      sortBy: 'Ordenar por {{column}}',
       allPrinters: 'Todas las impresoras',
       allUsers: 'Todos los usuarios',
       allStatuses: 'Todos los estados',

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

@@ -1030,6 +1030,13 @@ export default {
       status: 'Statut',
       duration: 'Durée',
       filament: 'Filament',
+      filamentUsed: 'Filament utilisé',
+      cost: 'Coût',
+      energy: 'Énergie',
+      energyCost: 'Coût énergétique',
+      completedAt: 'Terminé',
+      columns: 'Colonnes',
+      sortBy: 'Trier par {{column}}',
       allPrinters: 'Toutes les imprimantes',
       allUsers: 'Tous les utilisateurs',
       allStatuses: 'Tous les statuts',

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

@@ -1030,6 +1030,13 @@ export default {
       status: 'Stato',
       duration: 'Durata',
       filament: 'Filamento',
+      filamentUsed: 'Filamento usato',
+      cost: 'Costo',
+      energy: 'Energia',
+      energyCost: 'Costo energia',
+      completedAt: 'Completato',
+      columns: 'Colonne',
+      sortBy: 'Ordina per {{column}}',
       allPrinters: 'Tutte le stampanti',
       allUsers: 'Tutti gli utenti',
       allStatuses: 'Tutti gli stati',

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

@@ -1029,6 +1029,13 @@ export default {
       status: 'ステータス',
       duration: '所要時間',
       filament: 'フィラメント',
+      filamentUsed: '使用フィラメント量',
+      cost: 'コスト',
+      energy: '電力量',
+      energyCost: '電力コスト',
+      completedAt: '終了',
+      columns: '列',
+      sortBy: '{{column}} で並べ替え',
       allPrinters: '全プリンター',
       allUsers: '全ユーザー',
       allStatuses: '全ステータス',

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

@@ -966,6 +966,13 @@ export default {
       status: '상태',
       duration: '소요 시간',
       filament: '필라멘트',
+      filamentUsed: '사용 필라멘트량',
+      cost: '비용',
+      energy: '전력량',
+      energyCost: '전력 비용',
+      completedAt: '완료',
+      columns: '열',
+      sortBy: '{{column}} 기준 정렬',
       allPrinters: '모든 프린터',
       allUsers: '모든 사용자',
       allStatuses: '모든 상태',

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

@@ -1030,6 +1030,13 @@ export default {
       status: 'Status',
       duration: 'Duração',
       filament: 'Filamento',
+      filamentUsed: 'Filamento usado',
+      cost: 'Custo',
+      energy: 'Energia',
+      energyCost: 'Custo de energia',
+      completedAt: 'Concluído',
+      columns: 'Colunas',
+      sortBy: 'Ordenar por {{column}}',
       allPrinters: 'Todas as Impressoras',
       allUsers: 'Todos os Usuários',
       allStatuses: 'Todos os Status',

+ 7 - 0
frontend/src/i18n/locales/ru.ts

@@ -986,6 +986,13 @@ export default {
       status: "Состояние",
       duration: "Длительность",
       filament: "Филамент",
+      filamentUsed: 'Израсходовано филамента',
+      cost: 'Стоимость',
+      energy: 'Энергия',
+      energyCost: 'Стоимость энергии',
+      completedAt: 'Завершено',
+      columns: 'Столбцы',
+      sortBy: 'Сортировать по: {{column}}',
       allPrinters: "Все принтеры",
       allUsers: "Все пользователи",
       allStatuses: "Все состояния",

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

@@ -1030,6 +1030,13 @@ export default {
       status: 'Durum',
       duration: 'Süre',
       filament: 'Filament',
+      filamentUsed: 'Kullanılan filament',
+      cost: 'Maliyet',
+      energy: 'Enerji',
+      energyCost: 'Enerji maliyeti',
+      completedAt: 'Bitti',
+      columns: 'Sütunlar',
+      sortBy: '{{column}} ölçütüne göre sırala',
       allPrinters: 'Tüm Yazıcılar',
       allUsers: 'Tüm Kullanıcılar',
       allStatuses: 'Tüm Durumlar',

+ 7 - 0
frontend/src/i18n/locales/uk.ts

@@ -1034,6 +1034,13 @@ export default {
       status: "Статус",
       duration: "Тривалість",
       filament: "Філамент",
+      filamentUsed: 'Витрачено філаменту',
+      cost: 'Вартість',
+      energy: 'Енергія',
+      energyCost: 'Вартість енергії',
+      completedAt: 'Завершено',
+      columns: 'Стовпці',
+      sortBy: 'Сортувати за: {{column}}',
       allPrinters: "Усі принтери",
       allUsers: "Усі користувачі",
       allStatuses: "Усі статуси",

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

@@ -1030,6 +1030,13 @@ export default {
       status: '状态',
       duration: '时长',
       filament: '耗材',
+      filamentUsed: '耗材用量',
+      cost: '成本',
+      energy: '电量',
+      energyCost: '电费',
+      completedAt: '完成时间',
+      columns: '列',
+      sortBy: '按{{column}}排序',
       allPrinters: '所有打印机',
       allUsers: '所有用户',
       allStatuses: '所有状态',

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

@@ -1030,6 +1030,13 @@ export default {
       status: '狀態',
       duration: '時長',
       filament: '耗材',
+      filamentUsed: '耗材用量',
+      cost: '成本',
+      energy: '電量',
+      energyCost: '電費',
+      completedAt: '完成時間',
+      columns: '欄位',
+      sortBy: '依{{column}}排序',
       allPrinters: '所有印表機',
       allUsers: '所有使用者',
       allStatuses: '所有狀態',

+ 386 - 73
frontend/src/pages/ArchivesPage.tsx

@@ -57,6 +57,9 @@ import {
   Archive as ArchiveIcon,
   History,
   CheckCircle2,
+  Columns,
+  ChevronUp,
+  ChevronDown,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
@@ -77,6 +80,7 @@ import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal, FAILURE_REASON_KEYS } from '../components/EditArchiveModal';
 import { PrintLogModal } from '../components/PrintLogModal';
+import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { BatchTagModal } from '../components/BatchTagModal';
 import { BatchProjectModal } from '../components/BatchProjectModal';
@@ -96,6 +100,126 @@ import { formatFileSize } from '../utils/file';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
+// ---------------------------------------------------------------------------
+// Print Log column configuration (#2636, reporter @ajbastien)
+//
+// The log view used to hardcode seven columns, which left four populated
+// columns of `print_log_entries` — filament used, cost, energy, energy cost —
+// unreachable in the UI even though the API has always returned them. They are
+// per-run actuals (partial prints scaled to progress, multi-plate archives
+// scoped to the printed plate), so they are exactly what a log is for.
+//
+// Persisted per browser like the Inventory table's config, under its own key.
+// Labels are NOT read back from storage: they are re-derived from `t()` on
+// every render so a stored config from before a language switch doesn't pin
+// the column picker to the old language.
+// ---------------------------------------------------------------------------
+const LOG_COLUMN_CONFIG_KEY = 'bambuddy-printlog-columns';
+
+/** Column id -> i18n key. Also the authoritative list of valid ids. */
+const LOG_COLUMN_LABEL_KEYS: Record<string, string> = {
+  date: 'archives.log.date',
+  print_name: 'archives.log.printName',
+  printer: 'archives.log.printer',
+  user: 'archives.log.user',
+  status: 'archives.log.status',
+  duration: 'archives.log.duration',
+  completed_at: 'archives.log.completedAt',
+  filament: 'archives.log.filament',
+  filament_used: 'archives.log.filamentUsed',
+  cost: 'archives.log.cost',
+  energy: 'archives.log.energy',
+  energy_cost: 'archives.log.energyCost',
+};
+
+// Defaults reproduce the previous seven columns in the same order, plus the
+// filament amount the issue actually asked for. The rest ship hidden: they are
+// available to anyone who wants them without widening the table for everyone
+// who doesn't.
+const DEFAULT_LOG_COLUMNS: Array<{ id: string; visible: boolean }> = [
+  { id: 'date', visible: true },
+  { id: 'print_name', visible: true },
+  { id: 'printer', visible: true },
+  { id: 'user', visible: true },
+  { id: 'status', visible: true },
+  { id: 'duration', visible: true },
+  { id: 'filament', visible: true },
+  { id: 'filament_used', visible: true },
+  { id: 'completed_at', visible: false },
+  { id: 'cost', visible: false },
+  { id: 'energy', visible: false },
+  { id: 'energy_cost', visible: false },
+];
+
+/** Stored config merged with the defaults: unknown ids (removed columns) are
+ *  dropped and ids added by a later Bambuddy version are appended with their
+ *  default visibility, so an upgrade never silently hides a new column or
+ *  crashes on one that no longer exists. */
+function loadLogColumnConfig(): Array<{ id: string; visible: boolean }> {
+  try {
+    const stored = localStorage.getItem(LOG_COLUMN_CONFIG_KEY);
+    if (stored) {
+      const parsed = JSON.parse(stored) as Array<{ id: string; visible: boolean }>;
+      const known = new Set(Object.keys(LOG_COLUMN_LABEL_KEYS));
+      const storedIds = new Set(parsed.map((c) => c.id));
+      const valid = parsed
+        .filter((c) => known.has(c.id))
+        .map((c) => ({ id: c.id, visible: !!c.visible }));
+      const added = DEFAULT_LOG_COLUMNS.filter((c) => !storedIds.has(c.id));
+      if (valid.length > 0) return [...valid, ...added];
+    }
+  } catch {
+    // Corrupt or unavailable storage falls back to defaults.
+  }
+  return DEFAULT_LOG_COLUMNS.map((c) => ({ ...c }));
+}
+
+const LOG_SORT_KEY = 'bambuddy-printlog-sort';
+
+type LogSortState = { column: string; direction: 'asc' | 'desc' };
+
+/** Column ids the backend will order by. Kept in step with
+ *  ``_SORTABLE_COLUMNS`` in ``routes/print_log.py`` — an id missing there
+ *  comes back as a 400, so a header is only made clickable if it is listed
+ *  in both. */
+const SORTABLE_LOG_COLUMNS = new Set(Object.keys(LOG_COLUMN_LABEL_KEYS));
+
+const DEFAULT_LOG_SORT: LogSortState = { column: 'date', direction: 'desc' };
+
+function loadLogSort(): LogSortState {
+  try {
+    const stored = localStorage.getItem(LOG_SORT_KEY);
+    if (stored) {
+      const parsed = JSON.parse(stored) as LogSortState;
+      if (SORTABLE_LOG_COLUMNS.has(parsed?.column) && (parsed.direction === 'asc' || parsed.direction === 'desc')) {
+        return parsed;
+      }
+    }
+  } catch {
+    // Fall through to the default.
+  }
+  return { ...DEFAULT_LOG_SORT };
+}
+
+function saveLogSort(state: LogSortState) {
+  try {
+    localStorage.setItem(LOG_SORT_KEY, JSON.stringify(state));
+  } catch {
+    // Private-mode / quota failures shouldn't break the page.
+  }
+}
+
+function saveLogColumnConfig(config: Array<{ id: string; visible: boolean }>) {
+  try {
+    localStorage.setItem(
+      LOG_COLUMN_CONFIG_KEY,
+      JSON.stringify(config.map((c) => ({ id: c.id, visible: c.visible }))),
+    );
+  } catch {
+    // Private-mode / quota failures shouldn't break the page.
+  }
+}
+
 /**
  * Check if an archive represents a sliced/printable file.
  * Uses filename (.gcode, .gcode.3mf) as primary check, then falls back to
@@ -2744,6 +2868,16 @@ export function ArchivesPage() {
     return saved ? Number(saved) : 25;
   });
 
+  // Print Log column configuration (#2636). Stored without labels; the picker
+  // gets freshly translated ones below.
+  const [logColumns, setLogColumns] = useState(loadLogColumnConfig);
+  const [showLogColumnModal, setShowLogColumnModal] = useState(false);
+  const [logSort, setLogSort] = useState<LogSortState>(loadLogSort);
+  const visibleLogColumns = useMemo(
+    () => logColumns.filter((c) => c.visible).map((c) => c.id),
+    [logColumns],
+  );
+
   const handleNavigateToArchive = useCallback((archiveId: number) => {
     setPendingNavigationArchiveId(archiveId);
     setHighlightedArchiveId(archiveId);
@@ -2801,7 +2935,7 @@ export function ArchivesPage() {
   });
 
   const { data: printLogData, isLoading: isLogLoading } = useQuery({
-    queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
+    queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize, logSort.column, logSort.direction],
     queryFn: () => api.getPrintLog({
       search: search || undefined,
       printerId: filterPrinter || undefined,
@@ -2811,6 +2945,8 @@ export function ArchivesPage() {
       dateTo: logFilterDateTo || undefined,
       limit: logPageSize,
       offset: logOffset,
+      sortBy: logSort.column,
+      sortDir: logSort.direction,
     }),
     enabled: viewMode === 'log',
   });
@@ -2824,6 +2960,177 @@ export function ArchivesPage() {
   const useSlicerApi = settings?.use_slicer_api ?? false;
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 
+  // Print Log columns, with labels translated at render time (#2636).
+  const logColumnConfig: ColumnConfig[] = useMemo(
+    () =>
+      logColumns.map((c) => ({
+        ...c,
+        label: t(LOG_COLUMN_LABEL_KEYS[c.id] ?? c.id),
+      })),
+    [logColumns, t],
+  );
+  const defaultLogColumnConfig: ColumnConfig[] = useMemo(
+    () =>
+      DEFAULT_LOG_COLUMNS.map((c) => ({
+        ...c,
+        label: t(LOG_COLUMN_LABEL_KEYS[c.id] ?? c.id),
+      })),
+    [t],
+  );
+  // Click a header to sort by it; click again to flip. Dates, durations and
+  // amounts open on descending — "newest / biggest first" is what someone
+  // reaching for those wants — while text opens A-Z.
+  const handleLogSort = useCallback((colId: string) => {
+    if (!SORTABLE_LOG_COLUMNS.has(colId)) return;
+    setLogSort((prev) => {
+      const numericFirstDesc = ['date', 'completed_at', 'duration', 'filament_used', 'cost', 'energy', 'energy_cost'];
+      const next: LogSortState =
+        prev.column === colId
+          ? { column: colId, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
+          : { column: colId, direction: numericFirstDesc.includes(colId) ? 'desc' : 'asc' };
+      saveLogSort(next);
+      return next;
+    });
+    // Page 1 of the new order, not whatever offset the old one was on —
+    // otherwise sorting by cost from page 3 lands you in the middle of the
+    // re-sorted list with no indication why.
+    setLogOffset(0);
+  }, []);
+
+  const handleLogColumnSave = useCallback((config: ColumnConfig[]) => {
+    const stripped = config.map((c) => ({ id: c.id, visible: c.visible }));
+    setLogColumns(stripped);
+    saveLogColumnConfig(stripped);
+  }, []);
+
+  // Columns that hold a number and read better right-aligned. Kept as data so
+  // the header and the body can't drift apart.
+  const LOG_NUMERIC_COLUMNS = useMemo(
+    () => new Set(['duration', 'filament_used', 'cost', 'energy', 'energy_cost']),
+    [],
+  );
+
+  const renderLogCell = useCallback(
+    (colId: string, entry: PrintLogEntry) => {
+      switch (colId) {
+        case 'date':
+          return (
+            <span className="text-white whitespace-nowrap">
+              {formatDateTime(entry.started_at || entry.created_at, timeFormat)}
+            </span>
+          );
+        case 'completed_at':
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap">
+              {entry.completed_at ? formatDateTime(entry.completed_at, timeFormat) : '—'}
+            </span>
+          );
+        case 'print_name':
+          return (
+            <div className="flex items-center gap-2">
+              {entry.thumbnail_path && (
+                <img
+                  src={api.getPrintLogThumbnail(entry.id)}
+                  alt=""
+                  className="w-8 h-8 rounded object-cover flex-shrink-0"
+                  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
+                />
+              )}
+              <span className="text-white break-words" title={entry.print_name || ''}>
+                {entry.print_name || '—'}
+              </span>
+            </div>
+          );
+        case 'printer':
+          return <span className="text-bambu-gray-light">{entry.printer_name || '—'}</span>;
+        case 'user':
+          return <span className="text-bambu-gray-light">{entry.created_by_username || '—'}</span>;
+        case 'status':
+          return (
+            <>
+              <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+                entry.status === 'completed' ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400' :
+                entry.status === 'failed' ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' :
+                entry.status === 'stopped' ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' :
+                entry.status === 'cancelled' ? 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400' :
+                entry.status === 'skipped' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' :
+                'bg-gray-500/20 text-gray-400'
+              }`}>
+                {entry.status}
+              </span>
+              {entry.failure_reason && (
+                <span className="block text-[10px] text-bambu-gray mt-0.5">
+                  {t(`editArchive.failureReasons.${entry.failure_reason}`, { defaultValue: entry.failure_reason })}
+                </span>
+              )}
+            </>
+          );
+        case 'duration':
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap">
+              {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
+            </span>
+          );
+        case 'filament':
+          return (
+            <div className="flex items-center gap-1.5">
+              {entry.filament_color && (
+                <div className="flex items-center gap-0.5 flex-wrap">
+                  {entry.filament_color.split(',').map((color, i) => {
+                    const trimmed = color.trim();
+                    return (
+                      <span
+                        key={i}
+                        className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
+                        style={{ backgroundColor: trimmed.startsWith('#') ? trimmed : undefined }}
+                        title={trimmed}
+                      />
+                    );
+                  })}
+                </div>
+              )}
+              <span className="text-bambu-gray-light text-xs">
+                {entry.filament_type || '—'}
+              </span>
+            </div>
+          );
+        case 'filament_used':
+          // Per-run actual, not the archive's estimate: partial prints are
+          // scaled to progress and multi-plate archives are scoped to the
+          // plate that printed. One decimal matches the per-archive log table.
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap tabular-nums">
+              {entry.filament_used_grams != null ? `${entry.filament_used_grams.toFixed(1)} g` : '—'}
+            </span>
+          );
+        case 'cost':
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap tabular-nums">
+              {entry.cost != null ? `${currency}${entry.cost.toFixed(2)}` : '—'}
+            </span>
+          );
+        case 'energy':
+          // Written by the energy background task after the row itself, so a
+          // just-finished print shows "—" for a moment. That is accurate: the
+          // measurement genuinely isn't in yet.
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap tabular-nums">
+              {entry.energy_kwh != null ? `${entry.energy_kwh.toFixed(2)} kWh` : '—'}
+            </span>
+          );
+        case 'energy_cost':
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap tabular-nums">
+              {entry.energy_cost != null ? `${currency}${entry.energy_cost.toFixed(2)}` : '—'}
+            </span>
+          );
+        default:
+          return null;
+      }
+    },
+    [currency, t, timeFormat],
+  );
+
   const bulkDeleteMutation = useMutation({
     mutationFn: async (ids: number[]) => {
       await Promise.all(ids.map((id) => api.deleteArchive(id)));
@@ -3718,7 +4025,12 @@ export function ArchivesPage() {
       {/* Archives */}
       {isLoading ? (
         <div className="text-center py-12 text-bambu-gray">{t('archives.loadingArchives')}</div>
-      ) : filteredArchives?.length === 0 ? (
+      ) : filteredArchives?.length === 0 && viewMode !== 'log' ? (
+        // The log view is exempt: `print_log_entries` is an independent table
+        // that outlives the archives it refers to (deleting an archive only
+        // NULLs the FK), so "no archives" says nothing about whether there is
+        // a log to show. Without the guard, purging archives made the whole
+        // Print Log unreachable — the view has its own empty state below.
         <Card>
           <CardContent className="text-center py-12">
             <p className="text-bambu-gray">
@@ -3888,8 +4200,19 @@ export function ArchivesPage() {
                     onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
                   />
                 </div>
-                {/* Clear log button */}
-                <div className="ml-auto">
+                {/* Columns + clear log */}
+                <div className="ml-auto flex items-center gap-2">
+                  {/* #2636: the log carries more per-run data than fits on one
+                      screen, so which columns show is the user's choice. */}
+                  <button
+                    type="button"
+                    onClick={() => setShowLogColumnModal(true)}
+                    className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+                    title={t('inventory.configureColumns')}
+                  >
+                    <Columns className="w-4 h-4" />
+                    <span className="hidden sm:inline">{t('archives.log.columns')}</span>
+                  </button>
                   <Button
                     variant="danger"
                     size="sm"
@@ -3920,81 +4243,62 @@ export function ArchivesPage() {
                   <table className="w-full text-sm">
                     <thead>
                       <tr className="border-b border-bambu-dark-tertiary text-bambu-gray text-left">
-                        <th className="px-4 py-3 font-medium">{t('archives.log.date')}</th>
-                        <th className="px-4 py-3 font-medium">{t('archives.log.printName')}</th>
-                        <th className="px-4 py-3 font-medium">{t('archives.log.printer')}</th>
-                        <th className="px-4 py-3 font-medium">{t('archives.log.user')}</th>
-                        <th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
-                        <th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
-                        <th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
+                        {visibleLogColumns.map((colId) => {
+                          const sortable = SORTABLE_LOG_COLUMNS.has(colId);
+                          const isActive = logSort.column === colId;
+                          const label = t(LOG_COLUMN_LABEL_KEYS[colId] ?? colId);
+                          return (
+                            <th
+                              key={colId}
+                              scope="col"
+                              aria-sort={
+                                isActive ? (logSort.direction === 'asc' ? 'ascending' : 'descending') : 'none'
+                              }
+                              className={`px-4 py-3 font-medium ${LOG_NUMERIC_COLUMNS.has(colId) ? 'text-right' : ''}`}
+                            >
+                              {sortable ? (
+                                <button
+                                  type="button"
+                                  onClick={() => handleLogSort(colId)}
+                                  className={`inline-flex items-center gap-1 font-medium transition-colors hover:text-bambu-green ${
+                                    isActive ? 'text-bambu-green' : ''
+                                  }`}
+                                  title={t('archives.log.sortBy', { column: label })}
+                                >
+                                  {label}
+                                  {isActive ? (
+                                    logSort.direction === 'asc' ? (
+                                      <ChevronUp className="w-3.5 h-3.5" />
+                                    ) : (
+                                      <ChevronDown className="w-3.5 h-3.5" />
+                                    )
+                                  ) : (
+                                    // Held at low opacity rather than omitted, so
+                                    // the header doesn't shift sideways when it
+                                    // becomes the active sort.
+                                    <ArrowUpDown className="w-3.5 h-3.5 opacity-30" />
+                                  )}
+                                </button>
+                              ) : (
+                                label
+                              )}
+                            </th>
+                          );
+                        })}
                         <th className="px-4 py-3 font-medium w-10" aria-label={t('common.actions')} />
                       </tr>
                     </thead>
                     <tbody className="divide-y divide-bambu-dark-tertiary">
                       {printLogData.items.map((entry) => (
                         <tr key={entry.id} className="hover:bg-bambu-dark-secondary/50">
-                          <td className="px-4 py-3 text-white whitespace-nowrap">
-                            {formatDateTime(entry.started_at || entry.created_at, timeFormat)}
-                          </td>
-                          <td className="px-4 py-3">
-                            <div className="flex items-center gap-2">
-                              {entry.thumbnail_path && (
-                                <img
-                                  src={api.getPrintLogThumbnail(entry.id)}
-                                  alt=""
-                                  className="w-8 h-8 rounded object-cover flex-shrink-0"
-                                  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
-                                />
-                              )}
-                              <span className="text-white break-words" title={entry.print_name || ''}>
-                                {entry.print_name || '—'}
-                              </span>
-                            </div>
-                          </td>
-                          <td className="px-4 py-3 text-bambu-gray-light">{entry.printer_name || '—'}</td>
-                          <td className="px-4 py-3 text-bambu-gray-light">{entry.created_by_username || '—'}</td>
-                          <td className="px-4 py-3">
-                            <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
-                              entry.status === 'completed' ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400' :
-                              entry.status === 'failed' ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' :
-                              entry.status === 'stopped' ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' :
-                              entry.status === 'cancelled' ? 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400' :
-                              entry.status === 'skipped' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' :
-                              'bg-gray-500/20 text-gray-400'
-                            }`}>
-                              {entry.status}
-                            </span>
-                            {entry.failure_reason && (
-                              <span className="block text-[10px] text-bambu-gray mt-0.5">
-                                {t(`editArchive.failureReasons.${entry.failure_reason}`, { defaultValue: entry.failure_reason })}
-                              </span>
-                            )}
-                          </td>
-                          <td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
-                            {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
-                          </td>
-                          <td className="px-4 py-3">
-                            <div className="flex items-center gap-1.5">
-                              {entry.filament_color && (
-                                <div className="flex items-center gap-0.5 flex-wrap">
-                                  {entry.filament_color.split(',').map((color, i) => {
-                                    const trimmed = color.trim();
-                                    return (
-                                      <span
-                                        key={i}
-                                        className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                                        style={{ backgroundColor: trimmed.startsWith('#') ? trimmed : undefined }}
-                                        title={trimmed}
-                                      />
-                                    );
-                                  })}
-                                </div>
-                              )}
-                              <span className="text-bambu-gray-light text-xs">
-                                {entry.filament_type || '—'}
-                              </span>
-                            </div>
-                          </td>
+                          {visibleLogColumns.map((colId) => (
+                            <td
+                              key={colId}
+                              className={`px-4 py-3 ${LOG_NUMERIC_COLUMNS.has(colId) ? 'text-right' : ''}`}
+                            >
+                              {renderLogCell(colId, entry)}
+                            </td>
+                          ))}
                           <td className="px-4 py-3 text-right">
                             <div className="inline-flex items-center gap-2">
                               <button
@@ -4144,6 +4448,15 @@ export function ArchivesPage() {
         <TagManagementModal onClose={() => setShowTagManagement(false)} />
       )}
 
+      {/* Print Log column picker (#2636) */}
+      <ColumnConfigModal
+        isOpen={showLogColumnModal}
+        onClose={() => setShowLogColumnModal(false)}
+        columns={logColumnConfig}
+        defaultColumns={defaultLogColumnConfig}
+        onSave={handleLogColumnSave}
+      />
+
       {/* Clear Log Confirmation */}
       {showClearLogConfirm && (
         <ConfirmModal

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D7Qc8rjX.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-qwJExXvN.js"></script>
+    <script type="module" crossorigin src="/assets/index-D7Qc8rjX.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

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