mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
This commit is contained in:
parent
e95c42c021
commit
a08d3e62f3
23 changed files with 1203 additions and 282 deletions
|
|
@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
## [1.2.6b1] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests.
|
||||
- **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test.
|
||||
- **Chamber temperature can now be set up to 65 °C, not 60 (reported on Discord)** — Every field in Bambuddy that takes a chamber target stopped at 60 °C: the per-filament chamber map and the per-print chamber override in **Preheat & Heat Soak**, the chamber quick-select presets, and the chamber temperature control on the printer card. 60 is the ceiling for the X1E, which was the only heated-chamber model when that limit was written; the H2 series (H2C, H2D, H2D Pro, H2S) and the X2D heat to 65, so the top of their range was simply unreachable — an ABS or PA profile calling for 65 had to be run at 60. The ceiling is now 65 everywhere, held in one constant on each side rather than repeated as a literal at every call site, so the four surfaces cannot drift apart again. X1E owners are unaffected: its firmware clamps a higher request to its own maximum. Wiki updated. Covered by backend tests.
|
||||
- **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests.
|
||||
- **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests.
|
||||
|
|
@ -16,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
|
||||
|
||||
### Added
|
||||
- **The Print Log shows how much filament a run used, and lets you choose its columns (#2636, reporter @ajbastien)** — The log view listed which filament a print used — a colour dot and "PLA" — but never how much, which is the number you actually want when reading back a month of prints. The amount was already on every row the API returned; the table was simply hardcoded to seven columns. **Filament Used** now has a column of its own, on by default. **Cost**, **Energy**, **Energy Cost** and **Finished** join it as columns you can switch on via a new **Columns** picker above the table, which also reorders them by drag or arrow keys and remembers the layout per browser. Every column header now sorts, too — click to sort, click again to reverse, with dates and amounts opening largest-first and text A-Z. The sort runs over the whole log rather than the page on screen, since a table that pages server-side would otherwise answer "the most expensive print among these 25"; changing it returns you to page 1, and rows with nothing in the sorted column are held at the end in both directions so sorting by cost or energy never opens on a screenful of blanks. That last part is explicit rather than left to the database: Postgres sorts empties high and SQLite sorts them low, so the same click would otherwise land differently depending on which one you deployed. These are per-run figures rather than the file's estimate: a print that failed part-way is scaled to the progress it reached, tracked spools win over estimates, and a multi-plate project dispatched a plate at a time counts only the plate that ran — so a row can legitimately differ from the whole-file total on the archive card. Energy arrives from a background task a moment after the print ends, so a just-finished run shows a dash until the measurement lands, which is the honest answer rather than a zero. Note the per-archive **Print Log** button opens a different, smaller table that has shown grams all along; this brings the two into agreement. Translated in all locales; wiki updated. Covered by frontend tests.
|
||||
- **Auto-orient and auto-arrange when slicing server-side (#2548, reporter @ceokingcobra)** — A slice in Bambuddy always kept the placement the file arrived with, so several parts dropped onto one plate could come back overlapping, and a model resting on a face that needs supports stayed on it. Bambu Studio's two layout buttons simply had no counterpart here. Two checkboxes below the build-plate override now run the same passes before the slice: **Auto-orient objects** turns each object onto the side that prints best — the slicer scores candidate rotations on overhang area, contour and unprintability, and a test part here came out 8% faster with nothing else changed — and **Auto-arrange on the plate** lays the objects out so they no longer overlap, which is also the fix for a source file whose coordinates fall off the edge of a smaller target bed. Both are per-slice and off by default, and neither is stored on a preset or a pipeline: they rewrite placement an author may have chosen deliberately, so they are something you ask for rather than something that happens to you. They stay available on the "Slice as designed" path, unlike the preset dropdowns and bed type — these act on the geometry rather than the print config, so where the settings came from is irrelevant to them — and they carry across the automatic retry that falls back to a file's embedded settings after a slicer crash, which would otherwise hand back an un-arranged result you had in fact asked for. Arrange is project-wide inside the slicer, so pairing it with **Slice all plates** would collapse every plate's objects onto a single bed; Bambuddy takes the same per-plate loop it already used for cross-class re-slices and merges the outputs, since that hazard belongs to the flag and not to the one case that first ran into it — including on the "Slice as designed" path, and with the crash-retry suppressed there, because a retry is a single call that would have handed back one consolidated plate for a job you asked to slice as several. Orient needs no such handling — it rotates objects where they stand and never moves one between plates. A cross-class re-slice still arranges whether or not the box is ticked, because that pass is what keeps it clear of the H2D's per-nozzle dead zones. An unticked box is sent by leaving the field off the request entirely: the sidecar treats any value that is present as true, so a literal "false" would have auto-arranged every slice in Bambuddy. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
- **One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek)** — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one that is wedged. The set is validated as a set: one file per printer model (two slices for the same machine are not alternatives, and picking between them arbitrarily would look like a bug the first time it chose your draft profile), every file gated against the model it is offered as, and at least one model that actually has a printer — grouping the H2C slice before the H2C arrives is fine, queueing a job nothing can ever run is not. A cross-model item deliberately holds no file of its own, so deleting one alternative leaves the job and its sibling intact; deleting or trashing every candidate holds it with an explanation instead of failing deep in the upload. Filament overrides offer everything loaded across **all** the candidate models rather than just the first — a spool loaded on only one of them is still a legitimate choice, it simply narrows which candidates can match — while AMS slot mapping is absent exactly as it is on an ordinary "Any [model]" job, because no printer has been picked yet and the scheduler derives the mapping against whichever one it takes. In the queue the job reads **Any H2D / X1C**, naming every model it is waiting on rather than filing itself under one it may never run on, and its waiting reason is given per model (`H2D: Busy: H2D-1; X1C: No matching material/color`), collapsing to a plain busy message — and no notification — when every model is merely printing. The alternatives are fixed once queued: the schedule, quantity and print options stay editable, but assigning a specific printer or narrowing to one model is refused by both the dialog and the API, since an item holding alternatives *and* a printer would dispatch down the fixed-printer path with no file to send. Cancel and re-queue to change the set. Files can also be grouped permanently with **Group as versions**, after which printing any one of them offers the others without re-selecting — this is the grouping and the print-time file matching asked for in #2570, minus its nested File Manager listing. An existing library arrives with its groups already built, from slice provenance Bambuddy has been recording since the Slice button shipped and had never read back. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
- **Uploaded archives can be named after the filename you sent (#2610, contributor @Person2099)** — A 3MF carries a `print_name` inside its metadata, and that is the name an archive gets. It is often the wrong one: whoever originally sliced the file baked their own title into it, and that title survives every rename afterwards, so a file you deliberately named after the job it belongs to still lands in the archive list under a stranger's label. Bambuddy could already prefer the filename — the FTP review flow and virtual-printer dispatch both do, driven by the virtual printer's **Archive name source** setting — but the upload endpoints could not, so anything sent through the API was stuck with the embedded title. `POST /archives/upload` and `POST /archives/upload-bulk` now take an optional `prefer_filename_for_name` query parameter that names the archive after the uploaded file instead; on the bulk route it applies to every file in the batch. It is off by default, so nothing changes for existing callers or for the Web UI's own upload, which does not set it. A per-request parameter rather than a setting, because the caller — typically an integration naming files after its own jobs — is the only party that knows whether the filename it sent is the meaningful one. Both parameters are documented in the OpenAPI schema, so they show up in `/docs`. Covered by backend tests.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1029,6 +1029,13 @@ export default {
|
|||
status: 'ステータス',
|
||||
duration: '所要時間',
|
||||
filament: 'フィラメント',
|
||||
filamentUsed: '使用フィラメント量',
|
||||
cost: 'コスト',
|
||||
energy: '電力量',
|
||||
energyCost: '電力コスト',
|
||||
completedAt: '終了',
|
||||
columns: '列',
|
||||
sortBy: '{{column}} で並べ替え',
|
||||
allPrinters: '全プリンター',
|
||||
allUsers: '全ユーザー',
|
||||
allStatuses: '全ステータス',
|
||||
|
|
|
|||
|
|
@ -966,6 +966,13 @@ export default {
|
|||
status: '상태',
|
||||
duration: '소요 시간',
|
||||
filament: '필라멘트',
|
||||
filamentUsed: '사용 필라멘트량',
|
||||
cost: '비용',
|
||||
energy: '전력량',
|
||||
energyCost: '전력 비용',
|
||||
completedAt: '완료',
|
||||
columns: '열',
|
||||
sortBy: '{{column}} 기준 정렬',
|
||||
allPrinters: '모든 프린터',
|
||||
allUsers: '모든 사용자',
|
||||
allStatuses: '모든 상태',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -986,6 +986,13 @@ export default {
|
|||
status: "Состояние",
|
||||
duration: "Длительность",
|
||||
filament: "Филамент",
|
||||
filamentUsed: 'Израсходовано филамента',
|
||||
cost: 'Стоимость',
|
||||
energy: 'Энергия',
|
||||
energyCost: 'Стоимость энергии',
|
||||
completedAt: 'Завершено',
|
||||
columns: 'Столбцы',
|
||||
sortBy: 'Сортировать по: {{column}}',
|
||||
allPrinters: "Все принтеры",
|
||||
allUsers: "Все пользователи",
|
||||
allStatuses: "Все состояния",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1034,6 +1034,13 @@ export default {
|
|||
status: "Статус",
|
||||
duration: "Тривалість",
|
||||
filament: "Філамент",
|
||||
filamentUsed: 'Витрачено філаменту',
|
||||
cost: 'Вартість',
|
||||
energy: 'Енергія',
|
||||
energyCost: 'Вартість енергії',
|
||||
completedAt: 'Завершено',
|
||||
columns: 'Стовпці',
|
||||
sortBy: 'Сортувати за: {{column}}',
|
||||
allPrinters: "Усі принтери",
|
||||
allUsers: "Усі користувачі",
|
||||
allStatuses: "Усі статуси",
|
||||
|
|
|
|||
|
|
@ -1030,6 +1030,13 @@ export default {
|
|||
status: '状态',
|
||||
duration: '时长',
|
||||
filament: '耗材',
|
||||
filamentUsed: '耗材用量',
|
||||
cost: '成本',
|
||||
energy: '电量',
|
||||
energyCost: '电费',
|
||||
completedAt: '完成时间',
|
||||
columns: '列',
|
||||
sortBy: '按{{column}}排序',
|
||||
allPrinters: '所有打印机',
|
||||
allUsers: '所有用户',
|
||||
allStatuses: '所有状态',
|
||||
|
|
|
|||
|
|
@ -1030,6 +1030,13 @@ export default {
|
|||
status: '狀態',
|
||||
duration: '時長',
|
||||
filament: '耗材',
|
||||
filamentUsed: '耗材用量',
|
||||
cost: '成本',
|
||||
energy: '電量',
|
||||
energyCost: '電費',
|
||||
completedAt: '完成時間',
|
||||
columns: '欄位',
|
||||
sortBy: '依{{column}}排序',
|
||||
allPrinters: '所有印表機',
|
||||
allUsers: '所有使用者',
|
||||
allStatuses: '所有狀態',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue