Ver código fonte

Store a failure reason in one vocabulary, not three (issue #2974)

failure_reason was written three different ways and nothing reconciled
them. derive_failure_reason wrote English display labels ("Layer shift"),
older builds of the archive editor wrote the translated label in whatever
locale that user was running, and the two stale-archive paths wrote
English prose sentences. All three reach one column -- the archive PATCH
has mirrored the field onto the latest print-log entry since #1444 -- and
the Failure Analysis widget groups on the raw value, so one real cause
occupied several buckets. On a live install before this landed:
print_log_entries held 91 rows reading "User cancelled" beside 1 reading
"userCancelled".

In an English UI those two render as the same words twice with different
counts, which is why nobody spotted it. In any other locale one of them
stays English, because a stored label has no key for t() to resolve. The
editor was worse than cosmetic about it: its reverse lookup compared the
stored value against t() in the current locale, so for a non-English user
nothing matched and the dropdown opened empty over an archive that
plainly showed a reason.

The keys were already canonical and already enforced.
_FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
with a 400 and explains why in its own comment -- the widget renders
values back through t(), so an unrecognised one surfaces as a raw string.
derive_failure_reason had simply never been held to that rule. It now
produces keys, and the cancel branch returns userCancelled.

The two "Stale - ..." sentences become one new noStatusUpdate key. Both
describe the same observation, that no end-of-print status ever arrived;
which of the two situations occurred is already carried by status --
cancelled at the stale-cleanup site, the reconciled outcome at the
reconnect site -- so collapsing them loses nothing and gives Statistics
one bucket instead of two sentences that could never be translated. It
had to enter the vocabulary rather than merely be tolerated, because the
editor discards any value it does not recognise.

Existing rows are converted by a startup migration folding 168 historical
labels onto the 12 keys across both columns. It is exact rather than a
guess: every label across all 14 locales resolves to exactly one key,
with no collisions. The map is a frozen snapshot rather than something
read from the locale files at run time -- it maps what was written
historically, so regenerating it from the current translations would
silently stop recognising the very rows it exists to convert. A value
outside the map is left alone; guessing would be worse than leaving one
honest string in its own bucket. There is no one-shot settings flag, on
purpose: the statement only matches values in the map and a key is never
a label, so it is self-terminating, and a flag would permanently skip
anyone who restores an older database.

The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
maziggy 1 semana atrás
pai
commit
5211fd4575

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


+ 6 - 0
backend/app/api/routes/print_log.py

@@ -237,6 +237,12 @@ _FAILURE_REASON_KEYS = frozenset(
         "underExtrusion",
         "powerFailure",
         "userCancelled",
+        # Written by the two stale-archive paths in main.py when no end-of-print
+        # status ever arrived (issue #2974). It has to be in the vocabulary, not
+        # just tolerated: the archive editor clears any stored value it does not
+        # recognise, so leaving it out would delete the classification on the
+        # next save of such an archive.
+        "noStatusUpdate",
         "other",
     }
 )

+ 250 - 0
backend/app/core/database.py

@@ -1317,6 +1317,251 @@ async def _migrate_add_print_archive_cost_center(conn) -> None:
     )
 
 
+# Historical failure-reason labels, mapped to the canonical key that replaced
+# them (issue #2974).
+#
+# Three writers used to put three different spellings of one cause into
+# ``failure_reason``: the backend wrote English display labels, older versions
+# of the archive editor wrote the *translated* label in whatever locale that
+# user was running, and two stale-archive paths wrote English prose sentences.
+# The Failure Analysis widget groups on the raw column, so one real cause could
+# occupy several buckets -- measured on a live install before this landed: 91
+# rows reading "User cancelled" beside 1 reading "userCancelled", which in an
+# English UI rendered as the same words twice with different counts.
+#
+# This is deliberately a FROZEN SNAPSHOT rather than something derived from the
+# locale files at run time. It maps values as they were written historically; if
+# a translation is reworded tomorrow, the old string is still what sits in the
+# database and still has to map. Regenerating it from ``en.ts`` and friends
+# would silently stop recognising the very rows it exists to convert.
+#
+# Every label here resolves to exactly one key -- verified across all 14 locales
+# with no collisions -- so the conversion is exact rather than a best guess. A
+# value that is NOT in this map (free text from an older build, a translation
+# since edited) is deliberately left alone: it already renders through the
+# ``defaultValue`` fallback in both the editor and the Statistics breakdown, and
+# guessing at it would be worse than leaving one honest string in its own bucket.
+_LEGACY_FAILURE_REASON_LABELS: dict[str, str] = {
+    "Adhesion failure": "adhesionFailure",
+    "Agotamiento del filamento": "filamentRunout",
+    "Alabeo": "warping",
+    "Altro": "other",
+    "Annullato dall'utente": "userCancelled",
+    "Annulé par l'utilisateur": "userCancelled",
+    "Aucune mise à jour d'état reçue": "noStatusUpdate",
+    "Autre": "other",
+    "Az ekstrüzyon": "underExtrusion",
+    "Bico entupido": "cloggedNozzle",
+    "Boquilla obstruida": "cloggedNozzle",
+    "Buse bouchée": "cloggedNozzle",
+    "Bükülme": "warping",
+    "Cancelada por el usuario": "userCancelled",
+    "Cancelado pelo usuário": "userCancelled",
+    "Clogged nozzle": "cloggedNozzle",
+    "Corte de corriente": "powerFailure",
+    "Coupure courant": "powerFailure",
+    "Deformazione": "warping",
+    "Deslocamento de camada": "layerShift",
+    "Desplazamiento de capa": "layerShift",
+    "Diğer": "other",
+    "Door gebruiker geannuleerd": "userCancelled",
+    "Draadvorming": "stringing",
+    "Durum güncellemesi alınmadı": "noStatusUpdate",
+    "Décalage de couche": "layerShift",
+    "Défaut d'adhésion": "adhesionFailure",
+    "Empenamento": "warping",
+    "Espagueti / Desprendido": "spaghettiDetached",
+    "Fadenziehen": "stringing",
+    "Falha de adesão": "adhesionFailure",
+    "Falha de energia": "powerFailure",
+    "Fallimento adesione": "adhesionFailure",
+    "Fallo de adhesión": "adhesionFailure",
+    "Filament aufgebraucht": "filamentRunout",
+    "Filament bitti": "filamentRunout",
+    "Filament fini": "filamentRunout",
+    "Filament op": "filamentRunout",
+    "Filament runout": "filamentRunout",
+    "Filamento": "stringing",
+    "Filamento esaurito": "filamentRunout",
+    "Fim do filamento": "filamentRunout",
+    "Fios": "stringing",
+    "Geen statusupdate ontvangen": "noStatusUpdate",
+    "Güç kesintisi": "powerFailure",
+    "Haftungsfehler": "adhesionFailure",
+    "Hechtingsprobleem": "adhesionFailure",
+    "Hilos": "stringing",
+    "Katman kayması": "layerShift",
+    "Kein Statusupdate empfangen": "noStatusUpdate",
+    "Kromtrekken": "warping",
+    "Kullanıcı iptal etti": "userCancelled",
+    "Laagverschuiving": "layerShift",
+    "Layer shift": "layerShift",
+    "Mancanza corrente": "powerFailure",
+    "Nenhuma atualização de status recebida": "noStatusUpdate",
+    "Nessun aggiornamento di stato ricevuto": "noStatusUpdate",
+    "No se recibió actualización de estado": "noStatusUpdate",
+    "No status update received": "noStatusUpdate",
+    "Onderextrusie": "underExtrusion",
+    "Other": "other",
+    "Otro": "other",
+    "Outro": "other",
+    "Overig": "other",
+    "Power failure": "powerFailure",
+    "Schichtversatz": "layerShift",
+    "Sonstiges": "other",
+    "Sotto-estrusione": "underExtrusion",
+    "Sous-extrusion": "underExtrusion",
+    "Spagetti / Ayrılmış": "spaghettiDetached",
+    "Spaghetti / Abgelöst": "spaghettiDetached",
+    "Spaghetti / Destacado": "spaghettiDetached",
+    "Spaghetti / Detached": "spaghettiDetached",
+    "Spaghetti / Détaché": "spaghettiDetached",
+    "Spaghetti / losgeraakt": "spaghettiDetached",
+    "Spaghetti / staccato": "spaghettiDetached",
+    "Spostamento layer": "layerShift",
+    "Stale - print likely cancelled or failed without status update": "noStatusUpdate",
+    "Stale - reconciled after reconnect, end time unknown": "noStatusUpdate",
+    "Stringing": "stringing",
+    "Stringing (Cheveux d'ange)": "stringing",
+    "Stromausfall": "powerFailure",
+    "Stroomuitval": "powerFailure",
+    "Subextrusión": "underExtrusion",
+    "Subextrusão": "underExtrusion",
+    "Tıkalı nozul": "cloggedNozzle",
+    "Ugello intasato": "cloggedNozzle",
+    "Under-extrusion": "underExtrusion",
+    "Unterextrusion": "underExtrusion",
+    "User cancelled": "userCancelled",
+    "Verformung": "warping",
+    "Verstopfte Düse": "cloggedNozzle",
+    "Verstopte nozzle": "cloggedNozzle",
+    "Vom Benutzer abgebrochen": "userCancelled",
+    "Warping": "warping",
+    "Warping (Déformation)": "warping",
+    "Yapışma başarısız": "adhesionFailure",
+    "İplik oluşumu": "stringing",
+    "Биття філаменту": "filamentRunout",
+    "Викривлення": "warping",
+    "Другое": "other",
+    "Закончился филамент": "filamentRunout",
+    "Засмічене сопло": "cloggedNozzle",
+    "Засор сопла": "cloggedNozzle",
+    "Збій живлення": "powerFailure",
+    "Зсув шару": "layerShift",
+    "Користувач скасовано": "userCancelled",
+    "Коробление": "warping",
+    "Нанизування": "stringing",
+    "Недоэкструзия": "underExtrusion",
+    "Обновление статуса не получено": "noStatusUpdate",
+    "Оновлення статусу не отримано": "noStatusUpdate",
+    "Отменено пользователем": "userCancelled",
+    "Плохая адгезия к столу": "adhesionFailure",
+    "Порушення адгезії": "adhesionFailure",
+    "Підвидавлювання": "underExtrusion",
+    "Сбой питания": "powerFailure",
+    "Сдвиг слоёв": "layerShift",
+    "Спагетти / отрыв детали": "spaghettiDetached",
+    "Спагетті / Відр": "spaghettiDetached",
+    "Стрингинг": "stringing",
+    "інше": "other",
+    "その他": "other",
+    "ステータス更新を受信できませんでした": "noStatusUpdate",
+    "スパゲッティ / 剥離": "spaghettiDetached",
+    "ノズル詰まり": "cloggedNozzle",
+    "フィラメント切れ": "filamentRunout",
+    "ユーザーによるキャンセル": "userCancelled",
+    "レイヤーシフト": "layerShift",
+    "使用者取消": "userCancelled",
+    "其他": "other",
+    "反り": "warping",
+    "喷嘴堵塞": "cloggedNozzle",
+    "噴嘴堵塞": "cloggedNozzle",
+    "定着不良": "adhesionFailure",
+    "层偏移": "layerShift",
+    "層偏移": "layerShift",
+    "押出不足": "underExtrusion",
+    "拉丝": "stringing",
+    "拉丝 / 脱落": "spaghettiDetached",
+    "拉絲": "stringing",
+    "拉絲 / 脫落": "spaghettiDetached",
+    "挤出不足": "underExtrusion",
+    "擠出不足": "underExtrusion",
+    "断电": "powerFailure",
+    "斷電": "powerFailure",
+    "未收到状态更新": "noStatusUpdate",
+    "未收到狀態更新": "noStatusUpdate",
+    "用户取消": "userCancelled",
+    "糸引き": "stringing",
+    "翘曲": "warping",
+    "翹曲": "warping",
+    "耗材用完": "filamentRunout",
+    "附着力失败": "adhesionFailure",
+    "附著力失敗": "adhesionFailure",
+    "電源障害": "powerFailure",
+    "기타": "other",
+    "노즐 막힘": "cloggedNozzle",
+    "레이어 시프트": "layerShift",
+    "사용자 취소": "userCancelled",
+    "상태 업데이트를 받지 못함": "noStatusUpdate",
+    "스트링": "stringing",
+    "스파게티 / 분리": "spaghettiDetached",
+    "압출 부족": "underExtrusion",
+    "전원 실패": "powerFailure",
+    "접착 실패": "adhesionFailure",
+    "필라멘트 소진": "filamentRunout",
+    "휨": "warping",
+}
+
+
+async def _migrate_failure_reason_vocabulary(conn):
+    """Fold historical failure-reason labels onto the canonical keys (#2974).
+
+    ``print_archives.failure_reason`` and ``print_log_entries.failure_reason``
+    accumulated three spellings of the same cause -- see
+    ``_LEGACY_FAILURE_REASON_LABELS`` for who wrote what. The PATCH route in
+    ``api/routes/print_log.py`` has enforced the key vocabulary for a while and
+    ``derive_failure_reason`` now produces it too, so this is the one-time pass
+    that brings existing rows in line.
+
+    Deliberately NOT gated behind a settings flag, unlike the #2614 backfill.
+    The statement is self-terminating -- it only matches values in the map, and
+    a key is never a label, so a second run updates nothing -- which makes the
+    flag pure overhead. It would also be actively wrong: a user who restores an
+    older database, or upgrades through this version twice, would carry the flag
+    with none of the conversion, and their legacy rows would never be touched
+    again. Cheap and repeatable beats one-shot here.
+    """
+    from collections import defaultdict
+
+    from sqlalchemy import bindparam, text
+
+    # Invert the map before issuing anything: 168 labels collapse onto 12 keys,
+    # so one UPDATE per key with an IN list is 24 statements rather than 336
+    # single-value ones on every boot. Identity rows (an en.ts label that is
+    # spelled the same as its own key) are dropped -- they would match and
+    # rewrite themselves to the value they already hold.
+    by_key: dict[str, list[str]] = defaultdict(list)
+    for label, key in _LEGACY_FAILURE_REASON_LABELS.items():
+        if label != key:
+            by_key[key].append(label)
+
+    total = 0
+    async with conn.begin_nested():
+        for table in ("print_archives", "print_log_entries"):
+            for key, labels in by_key.items():
+                result = await conn.execute(
+                    text(
+                        f"UPDATE {table} SET failure_reason = :key "  # noqa: S608 - table name is a literal
+                        "WHERE failure_reason IN :labels"
+                    ).bindparams(bindparam("key"), bindparam("labels", expanding=True)),
+                    {"key": key, "labels": labels},
+                )
+                total += result.rowcount or 0
+
+    if total:
+        logger.info("[#2974] converted %d failure_reason value(s) to the canonical vocabulary", total)
+
+
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
 
@@ -1336,6 +1581,11 @@ async def run_migrations(conn):
     # them. Fresh installs remain idempotent because create_all() runs first.
     await _migrate_create_finance_tables(conn)
 
+    # Data migration: one vocabulary for failure_reason (#2974). Runs early so
+    # the Failure Analysis widget and the archive editor never observe a
+    # half-converted column.
+    await _migrate_failure_reason_vocabulary(conn)
+
     # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
     # Links a retry-failed run back to its parent so the dashboard can show
     # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.

+ 44 - 29
backend/app/main.py

@@ -521,33 +521,42 @@ _PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0
 #      alone caused user-cancellations to be archived as "Layer shift" failures.
 # We now match by full short code only — anything not in this map leaves
 # failure_reason=None rather than guessing.
+# Values are the canonical camelCase failure-reason keys, NOT display labels
+# (issue #2974). The vocabulary is enforced on writes by
+# ``_FAILURE_REASON_KEYS`` in ``api/routes/print_log.py`` and rendered through
+# ``t('editArchive.failureReasons.<key>')`` on both the archive editor and the
+# Statistics breakdown. Storing a label here instead put a second spelling of
+# the same cause into one column: the Failure Analysis widget groups on the raw
+# value, so a print the backend classified and an identical one a user
+# classified counted as two different reasons, and the label form could never
+# be translated because there was no key for ``t()`` to resolve.
 _HMS_FAILURE_REASONS: dict[str, str] = {
     # Layer shift / step loss
-    "0300_4057": "Layer shift",
-    "0300_4068": "Layer shift",
-    "0300_800C": "Layer shift",
+    "0300_4057": "layerShift",
+    "0300_4068": "layerShift",
+    "0300_800C": "layerShift",
     # Filament runout (printer-side & per-AMS-slot)
-    "0300_8004": "Filament runout",
-    "0700_8011": "Filament runout",
-    "0701_8011": "Filament runout",
-    "0702_8011": "Filament runout",
-    "0703_8011": "Filament runout",
-    "0704_8011": "Filament runout",
-    "0705_8011": "Filament runout",
-    "0706_8011": "Filament runout",
-    "0707_8011": "Filament runout",
-    "07FF_8011": "Filament runout",
+    "0300_8004": "filamentRunout",
+    "0700_8011": "filamentRunout",
+    "0701_8011": "filamentRunout",
+    "0702_8011": "filamentRunout",
+    "0703_8011": "filamentRunout",
+    "0704_8011": "filamentRunout",
+    "0705_8011": "filamentRunout",
+    "0706_8011": "filamentRunout",
+    "0707_8011": "filamentRunout",
+    "07FF_8011": "filamentRunout",
     # Clogged nozzle / extruder
-    "0300_4006": "Clogged nozzle",
-    "0300_8016": "Clogged nozzle",
-    "0300_801C": "Clogged nozzle",
-    "0700_8003": "Clogged nozzle",
-    "0700_8007": "Clogged nozzle",
-    "0700_8013": "Clogged nozzle",
-    "0701_8003": "Clogged nozzle",
-    "0701_8007": "Clogged nozzle",
-    "0701_8013": "Clogged nozzle",
-    "0702_8003": "Clogged nozzle",
+    "0300_4006": "cloggedNozzle",
+    "0300_8016": "cloggedNozzle",
+    "0300_801C": "cloggedNozzle",
+    "0700_8003": "cloggedNozzle",
+    "0700_8007": "cloggedNozzle",
+    "0700_8013": "cloggedNozzle",
+    "0701_8003": "cloggedNozzle",
+    "0701_8007": "cloggedNozzle",
+    "0701_8013": "cloggedNozzle",
+    "0702_8003": "cloggedNozzle",
 }
 
 
@@ -569,7 +578,7 @@ def derive_failure_reason(status: str, hms_errors: list[dict] | None) -> str | N
     no HMS code matches (don't guess — null is honest).
     """
     if status in ("aborted", "cancelled"):
-        return "User cancelled"
+        return "userCancelled"
     if status != "failed":
         return None
     for err in hms_errors or []:
@@ -3873,7 +3882,13 @@ async def on_print_start(printer_id: int, data: dict):
                     f"printer progress {live_progress:.0f}%) — marking cancelled and creating new archive"
                 )
                 existing_archive.status = "cancelled"
-                existing_archive.failure_reason = "Stale - print likely cancelled or failed without status update"
+                # Canonical key, not a sentence (issue #2974). "No status update
+                # received" is what both stale paths actually observed; which of
+                # the two it was is already carried by ``status`` -- cancelled
+                # here, the reconciled outcome at the reconnect site -- so one
+                # key loses no information and gives the Statistics breakdown a
+                # single bucket instead of two untranslatable prose strings.
+                existing_archive.failure_reason = "noStatusUpdate"
                 await db.commit()
                 # Fall through to create new archive (don't return)
             else:
@@ -6808,10 +6823,10 @@ async def on_print_complete(printer_id: int, data: dict):
             if data.get("_reconciled"):
                 # A reconciled completion closes out a stale archive at
                 # reconnect — it is not a user action, so don't mislabel it
-                # "User cancelled". The "Stale" prefix matches the existing
-                # stale-cleanup convention and records that the real end time
-                # is unknown, which is also why its logged duration is 0 (#2592).
-                failure_reason = "Stale - reconciled after reconnect, end time unknown"
+                # "userCancelled". It shares the stale-cleanup path's key
+                # (issue #2974) and records that the real end time is unknown,
+                # which is also why its logged duration is 0 (#2592).
+                failure_reason = "noStatusUpdate"
             if failure_reason:
                 logger.info("[ARCHIVE] failure_reason=%r (status=%s)", failure_reason, status)
             elif status == "failed" and hms_errors:

+ 168 - 0
backend/tests/integration/test_failure_reason_vocabulary_migration.py

@@ -0,0 +1,168 @@
+"""Migration tests for issue #2974 — one vocabulary for ``failure_reason``.
+
+Three writers used to put three spellings of one cause into the column: the
+backend wrote English display labels ("Layer shift"), older builds of the
+archive editor wrote the *translated* label in whatever locale that user was
+running, and two stale-archive paths wrote English prose sentences. The Failure
+Analysis widget groups on the raw column, so one real cause occupied several
+buckets.
+
+Measured on a live install before this landed: ``print_log_entries`` held 91
+rows reading ``"User cancelled"`` beside 1 reading ``"userCancelled"``. In an
+English UI those render as the same words twice with different counts, which is
+why nobody spotted it; in any other locale one of the two stays English.
+"""
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import (
+    _LEGACY_FAILURE_REASON_LABELS,
+    _migrate_failure_reason_vocabulary,
+)
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+
+# The columns each model needs beyond ``failure_reason``. Rows stay minimal on
+# purpose -- these tests exercise the UPDATE, not the schema.
+_REQUIRED = {
+    "PrintArchive": {"filename": "x.3mf", "file_path": "/tmp/x.3mf", "file_size": 1},
+    "PrintLogEntry": {},
+}
+
+
+async def _seed(session: AsyncSession, model, values: list[str | None]) -> list[int]:
+    """Insert one row per value through the ORM and return their ids, in order."""
+    required = _REQUIRED[model.__name__]
+    rows = [model(status="failed", failure_reason=v, **required) for v in values]
+    session.add_all(rows)
+    await session.commit()
+    ids = []
+    for row in rows:
+        await session.refresh(row)
+        ids.append(row.id)
+    return ids
+
+
+async def _read(session: AsyncSession, model, ids: list[int]) -> list[str | None]:
+    """Read ``failure_reason`` back for ``ids``, in the order given."""
+    session.expire_all()
+    result = await session.execute(select(model.id, model.failure_reason).where(model.id.in_(ids)))
+    got = {row[0]: row[1] for row in result.fetchall()}
+    return [got[i] for i in ids]
+
+
+async def _run(session: AsyncSession) -> None:
+    """Drive the migration over the session's own connection.
+
+    TEST_DATABASE_URL is in-memory SQLite on a shared pool, so opening a second
+    connection would not see the seeded rows -- and production calls this with
+    an ``AsyncConnection`` inside an open transaction anyway, which is exactly
+    what ``session.connection()`` hands over.
+    """
+    await _migrate_failure_reason_vocabulary(await session.connection())
+    await session.commit()
+
+
+# ---------------------------------------------------------------------------
+# The map itself
+# ---------------------------------------------------------------------------
+
+
+def test_every_mapped_value_is_a_canonical_key() -> None:
+    """A label may only ever fold onto a key the rest of the stack accepts."""
+    from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
+
+    offenders = sorted(set(_LEGACY_FAILURE_REASON_LABELS.values()) - _FAILURE_REASON_KEYS)
+    assert not offenders, f"map targets values nothing else recognises: {offenders}"
+
+
+def test_the_map_is_unambiguous() -> None:
+    """No label may resolve to two different keys.
+
+    This is what makes the conversion exact rather than a guess, and it is the
+    property that let the migration be written at all -- the reporter's open
+    question was what to do with a value matching no key.
+    """
+    assert len(_LEGACY_FAILURE_REASON_LABELS) == len(set(_LEGACY_FAILURE_REASON_LABELS))
+
+
+def test_the_map_covers_every_writer_that_ever_existed() -> None:
+    """The three historical vocabularies, by example."""
+    m = _LEGACY_FAILURE_REASON_LABELS
+    # 1. Backend English display labels.
+    assert m["Layer shift"] == "layerShift"
+    assert m["Filament runout"] == "filamentRunout"
+    assert m["Clogged nozzle"] == "cloggedNozzle"
+    assert m["User cancelled"] == "userCancelled"
+    # 2. Legacy archive-editor writes of a *translated* label. Not English --
+    #    that is the whole reason a locale-dependent reverse lookup could not
+    #    fix this on read.
+    assert m["Schichtversatz"] == "layerShift"
+    assert m["Сдвиг слоёв"] == "layerShift"
+    # 3. The two stale-path prose sentences.
+    assert m["Stale - print likely cancelled or failed without status update"] == "noStatusUpdate"
+    assert m["Stale - reconciled after reconnect, end time unknown"] == "noStatusUpdate"
+
+
+# ---------------------------------------------------------------------------
+# The migration
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
+async def test_labels_fold_onto_keys(db_session: AsyncSession, model) -> None:
+    ids = await _seed(db_session, model, ["Layer shift", "Schichtversatz", "layerShift"])
+    await _run(db_session)
+    assert await _read(db_session, model, ids) == ["layerShift", "layerShift", "layerShift"]
+
+
+@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
+async def test_the_live_split_collapses(db_session: AsyncSession, model) -> None:
+    """The exact shape measured on the maintainer's instance."""
+    ids = await _seed(db_session, model, ["User cancelled"] * 3 + ["userCancelled"])
+    await _run(db_session)
+    assert set(await _read(db_session, model, ids)) == {"userCancelled"}
+
+
+@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
+async def test_both_stale_sentences_become_one_key(db_session: AsyncSession, model) -> None:
+    ids = await _seed(
+        db_session,
+        model,
+        [
+            "Stale - print likely cancelled or failed without status update",
+            "Stale - reconciled after reconnect, end time unknown",
+        ],
+    )
+    await _run(db_session)
+    assert await _read(db_session, model, ids) == ["noStatusUpdate", "noStatusUpdate"]
+
+
+@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
+async def test_unrecognised_values_are_left_alone(db_session: AsyncSession, model) -> None:
+    """Free text and NULL survive untouched.
+
+    Guessing at a value the map does not know would be worse than leaving one
+    honest string in its own bucket -- it still renders through the
+    ``defaultValue`` fallback in the editor and the Statistics breakdown.
+    """
+    ids = await _seed(db_session, model, ["Custom legacy reason", None, ""])
+    await _run(db_session)
+    assert await _read(db_session, model, ids) == ["Custom legacy reason", None, ""]
+
+
+@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
+async def test_running_twice_changes_nothing(db_session: AsyncSession, model) -> None:
+    """Self-terminating, which is why it carries no one-shot settings flag.
+
+    A user restoring an older database, or upgrading through this version
+    twice, must still get their legacy rows converted -- a flag would skip them
+    forever.
+    """
+    ids = await _seed(db_session, model, ["Layer shift", "Custom legacy reason"])
+    await _run(db_session)
+    first = await _read(db_session, model, ids)
+    await _run(db_session)
+    assert await _read(db_session, model, ids) == first == ["layerShift", "Custom legacy reason"]

+ 63 - 9
backend/tests/unit/test_failure_reason_derivation.py

@@ -1,6 +1,6 @@
 """Regression tests for derive_failure_reason in backend.app.main.
 
-Ensures user-cancelled prints don't get archived as "Layer shift" — the bug
+Ensures user-cancelled prints don't get archived as "layerShift" — the bug
 seen on H2D where the firmware's cancel-sequence module-0x0C HMS was being
 matched by the old broad heuristic (`module == 0x0C → Layer shift`).
 """
@@ -18,8 +18,8 @@ from backend.app.main import derive_failure_reason
 
 @pytest.mark.parametrize("status", ["aborted", "cancelled"])
 def test_user_cancel_status_yields_user_cancelled(status: str) -> None:
-    assert derive_failure_reason(status, None) == "User cancelled"
-    assert derive_failure_reason(status, []) == "User cancelled"
+    assert derive_failure_reason(status, None) == "userCancelled"
+    assert derive_failure_reason(status, []) == "userCancelled"
 
 
 def test_completed_status_returns_none() -> None:
@@ -27,7 +27,7 @@ def test_completed_status_returns_none() -> None:
 
 
 # ---------------------------------------------------------------------------
-# H2D regression: cancel-sequence HMS must not be labelled "Layer shift"
+# H2D regression: cancel-sequence HMS must not be labelled "layerShift"
 # ---------------------------------------------------------------------------
 
 
@@ -59,19 +59,19 @@ def test_unknown_module_0x0c_code_returns_none() -> None:
 def test_real_layer_shift_short_code_detected() -> None:
     """0300_4057 ("Z-axis step loss") is a real layer-shift code from the wiki."""
     hms = [{"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
-    assert derive_failure_reason("failed", hms) == "Layer shift"
+    assert derive_failure_reason("failed", hms) == "layerShift"
 
 
 def test_real_filament_runout_short_code_detected() -> None:
     """07FF_8011 = external filament runout."""
     hms = [{"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}]
-    assert derive_failure_reason("failed", hms) == "Filament runout"
+    assert derive_failure_reason("failed", hms) == "filamentRunout"
 
 
 def test_real_clogged_nozzle_short_code_detected() -> None:
     """0300_4006 = "The nozzle is clogged"."""
     hms = [{"code": "0x4006", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
-    assert derive_failure_reason("failed", hms) == "Clogged nozzle"
+    assert derive_failure_reason("failed", hms) == "cloggedNozzle"
 
 
 def test_first_matching_code_wins() -> None:
@@ -80,7 +80,7 @@ def test_first_matching_code_wins() -> None:
         {"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1},  # layer shift
         {"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2},  # filament runout
     ]
-    assert derive_failure_reason("failed", hms) == "Layer shift"
+    assert derive_failure_reason("failed", hms) == "layerShift"
 
 
 def test_failed_with_no_hms_returns_none() -> None:
@@ -96,4 +96,58 @@ def test_failed_with_no_hms_returns_none() -> None:
 def test_int_code_field_accepted() -> None:
     """The MQTT parser sometimes leaves `code` as an int rather than a hex string."""
     hms = [{"code": 0x4057, "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
-    assert derive_failure_reason("failed", hms) == "Layer shift"
+    assert derive_failure_reason("failed", hms) == "layerShift"
+
+
+# ---------------------------------------------------------------------------
+# One vocabulary in storage (issue #2974)
+# ---------------------------------------------------------------------------
+
+
+def test_every_derived_reason_is_a_canonical_key() -> None:
+    """The map may only hold values the rest of the stack agrees are reasons.
+
+    Three writers used to put three spellings of one cause into
+    ``failure_reason``. The whole point of #2974 is that there is now exactly
+    one, so a display label sneaking back into the map -- which is what shipped
+    for months -- has to fail here rather than in a user's Statistics panel.
+    """
+    from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
+    from backend.app.main import _HMS_FAILURE_REASONS
+
+    offenders = sorted(set(_HMS_FAILURE_REASONS.values()) - _FAILURE_REASON_KEYS)
+    assert not offenders, f"not canonical failure-reason keys: {offenders}"
+
+
+@pytest.mark.parametrize("status", ["aborted", "cancelled", "failed"])
+def test_derived_reason_is_always_a_canonical_key(status: str) -> None:
+    """Covers the status branch too, not just the HMS table."""
+    from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
+    from backend.app.main import _HMS_FAILURE_REASONS
+
+    for code in _HMS_FAILURE_REASONS:
+        attr = int(code.split("_")[0], 16) << 16
+        reason = derive_failure_reason(status, [{"attr": attr, "code": int(code.split("_")[1], 16)}])
+        assert reason is None or reason in _FAILURE_REASON_KEYS, reason
+
+
+def test_the_stale_paths_write_a_key_the_editor_will_not_discard() -> None:
+    """Both stale writers in main.py store ``noStatusUpdate``.
+
+    Read from the source rather than by calling them: they sit deep inside the
+    MQTT archive paths and need a printer, a session and a live status. What
+    matters is the value, and that the archive editor recognises it -- an
+    unrecognised value opens the dropdown empty and the next save clears the
+    classification outright.
+    """
+    from pathlib import Path
+
+    from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
+
+    source = Path(__file__).resolve().parents[3] / "backend" / "app" / "main.py"
+    text = source.read_text(encoding="utf-8")
+
+    assert "noStatusUpdate" in _FAILURE_REASON_KEYS
+    assert text.count('failure_reason = "noStatusUpdate"') == 2
+    assert "Stale - print likely cancelled" not in text
+    assert "Stale - reconciled after reconnect" not in text

+ 56 - 0
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -252,6 +252,62 @@ describe('EditArchiveModal', () => {
         expect(patched?.failure_reason).toBe('cloggedNozzle');
       });
     });
+
+    // A value outside the vocabulary used to initialise the dropdown to '',
+    // and saving from that state wrote the empty selection over the stored
+    // text -- opening the editor and pressing Save destroyed the
+    // classification. The startup migration folds every known spelling onto a
+    // key, so what reaches here is genuinely unrecognisable text; it has to
+    // survive rather than be silently discarded (issue #2974).
+    const freeTextArchive = {
+      ...mockArchive,
+      status: 'failed',
+      failure_reason: 'Custom legacy reason',
+    };
+
+    it('keeps a stored value it cannot map, as its own option', () => {
+      render(<EditArchiveModal archive={freeTextArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
+      expect(select.value).toBe('Custom legacy reason');
+      expect(
+        screen.getByRole('option', { name: 'Custom legacy reason' }),
+      ).toBeInTheDocument();
+    });
+
+    it('does not clear an unmappable reason on an untouched save', async () => {
+      const user = userEvent.setup();
+      let patched: { failure_reason?: string } | undefined;
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          patched = (await request.json()) as { failure_reason?: string };
+          return HttpResponse.json({ ...freeTextArchive, ...patched });
+        }),
+      );
+
+      render(<EditArchiveModal archive={freeTextArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(patched?.failure_reason).toBe('Custom legacy reason');
+      });
+    });
+
+    it('offers the stale-path reason the backend now writes', () => {
+      // Both stale writers in main.py store `noStatusUpdate`. If it were
+      // missing from the dropdown the editor would treat it as unmappable and
+      // show the raw key to the user instead of a translated label.
+      const staleArchive = {
+        ...mockArchive,
+        status: 'failed',
+        failure_reason: 'noStatusUpdate',
+      };
+      render(<EditArchiveModal archive={staleArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
+      expect(select.value).toBe('noStatusUpdate');
+      expect(
+        screen.getByRole('option', { name: 'No status update received' }),
+      ).toBeInTheDocument();
+    });
   });
 
   describe('filament grams (#1820)', () => {

+ 26 - 5
frontend/src/components/EditArchiveModal.tsx

@@ -24,6 +24,7 @@ export const FAILURE_REASON_KEYS = [
   'underExtrusion',
   'powerFailure',
   'userCancelled',
+  'noStatusUpdate',
   'other',
 ] as const;
 
@@ -59,10 +60,18 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
   const [notes, setNotes] = useState(archive.notes || '');
   const [tags, setTags] = useState(archive.tags || '');
-  // Failure reason is stored as a camelCase key (`filamentRunout`), but earlier
-  // versions of this modal saved the translated label as the value. Reverse-
-  // lookup any legacy translated text against the current locale so the
-  // dropdown pre-selects the right option, then any save converts it forward.
+  // Failure reason is stored as a camelCase key (`filamentRunout`). Three older
+  // writers stored other spellings -- English display labels from the backend,
+  // this modal's own translated labels, and two prose sentences from the stale
+  // archive paths -- and a startup migration folds all of them onto keys
+  // (issue #2974). This reverse lookup is the belt to that migration's braces,
+  // for a frontend running against a backend that has not restarted yet.
+  //
+  // A value it cannot resolve is kept rather than dropped. It used to fall back
+  // to '', which did not merely look wrong: the empty selection was then saved
+  // over the stored text, so opening the editor on an archive whose reason was
+  // free text and pressing Save silently destroyed the classification. Anything
+  // unrecognised now shows up as its own option (see `unmappedReason` below).
   const [failureReason, setFailureReason] = useState(() => {
     const raw = archive.failure_reason || '';
     if (!raw) return '';
@@ -70,8 +79,15 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     const match = FAILURE_REASON_KEYS.find(
       (k) => t(`editArchive.failureReasons.${k}`) === raw,
     );
-    return match || '';
+    return match || raw;
   });
+
+  // The stored value when it is not part of the vocabulary, so the dropdown can
+  // offer it verbatim instead of appearing empty over a reason that exists.
+  const unmappedReason =
+    failureReason && !(FAILURE_REASON_KEYS as readonly string[]).includes(failureReason)
+      ? failureReason
+      : null;
   const [status, setStatus] = useState(archive.status);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   // Kept as a string so the field can be genuinely empty: a print archived
@@ -525,6 +541,11 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                     {t(`editArchive.failureReasons.${reasonKey}`)}
                   </option>
                 ))}
+                {/* A stored reason outside the vocabulary keeps its own option
+                    so it stays visible and survives a save (issue #2974). */}
+                {unmappedReason && (
+                  <option value={unmappedReason}>{unmappedReason}</option>
+                )}
               </select>
             </div>
           )}

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

@@ -5432,6 +5432,7 @@ export default {
       underExtrusion: 'Unterextrusion',
       powerFailure: 'Stromausfall',
       userCancelled: 'Vom Benutzer abgebrochen',
+      noStatusUpdate: 'Kein Statusupdate empfangen',
       other: 'Sonstiges',
     },
     // Archive statuses

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

@@ -5483,6 +5483,7 @@ export default {
       underExtrusion: 'Under-extrusion',
       powerFailure: 'Power failure',
       userCancelled: 'User cancelled',
+      noStatusUpdate: 'No status update received',
       other: 'Other',
     },
     // Archive statuses

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

@@ -5440,6 +5440,7 @@ export default {
       underExtrusion: 'Subextrusión',
       powerFailure: 'Corte de corriente',
       userCancelled: 'Cancelada por el usuario',
+      noStatusUpdate: 'No se recibió actualización de estado',
       other: 'Otro',
     },
     // Archive statuses

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

@@ -5422,6 +5422,7 @@ export default {
       underExtrusion: 'Sous-extrusion',
       powerFailure: 'Coupure courant',
       userCancelled: 'Annulé par l\'utilisateur',
+      noStatusUpdate: 'Aucune mise à jour d\'état reçue',
       other: 'Autre',
     },
     // Archive statuses

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

@@ -5421,6 +5421,7 @@ export default {
       underExtrusion: 'Sotto-estrusione',
       powerFailure: 'Mancanza corrente',
       userCancelled: 'Annullato dall\'utente',
+      noStatusUpdate: 'Nessun aggiornamento di stato ricevuto',
       other: 'Altro',
     },
     // Archive statuses

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

@@ -5433,6 +5433,7 @@ export default {
       underExtrusion: '押出不足',
       powerFailure: '電源障害',
       userCancelled: 'ユーザーによるキャンセル',
+      noStatusUpdate: 'ステータス更新を受信できませんでした',
       other: 'その他',
     },
     // Archive statuses

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

@@ -5181,6 +5181,7 @@ export default {
       underExtrusion: '압출 부족',
       powerFailure: '전원 실패',
       userCancelled: '사용자 취소',
+      noStatusUpdate: '상태 업데이트를 받지 못함',
       other: '기타'
     },
     statuses: {

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -5483,6 +5483,7 @@ export default {
       underExtrusion: 'Onderextrusie',
       powerFailure: 'Stroomuitval',
       userCancelled: 'Door gebruiker geannuleerd',
+      noStatusUpdate: 'Geen statusupdate ontvangen',
       other: 'Overig',
     },
     // Archive statuses

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

@@ -5421,6 +5421,7 @@ export default {
       underExtrusion: 'Subextrusão',
       powerFailure: 'Falha de energia',
       userCancelled: 'Cancelado pelo usuário',
+      noStatusUpdate: 'Nenhuma atualização de status recebida',
       other: 'Outro',
     },
     // Archive statuses

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

@@ -5169,6 +5169,7 @@ export default {
       underExtrusion: "Недоэкструзия",
       powerFailure: "Сбой питания",
       userCancelled: "Отменено пользователем",
+      noStatusUpdate: "Обновление статуса не получено",
       other: "Другое",
     },
     statuses: {

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

@@ -5405,6 +5405,7 @@ export default {
       underExtrusion: 'Az ekstrüzyon',
       powerFailure: 'Güç kesintisi',
       userCancelled: 'Kullanıcı iptal etti',
+      noStatusUpdate: 'Durum güncellemesi alınmadı',
       other: 'Diğer',
     },
     statuses: {

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

@@ -5475,6 +5475,7 @@ export default {
       underExtrusion: "Підвидавлювання",
       powerFailure: "Збій живлення",
       userCancelled: "Користувач скасовано",
+      noStatusUpdate: "Оновлення статусу не отримано",
       other: "інше",
     },
     // Archive statuses

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

@@ -5421,6 +5421,7 @@ export default {
       underExtrusion: '挤出不足',
       powerFailure: '断电',
       userCancelled: '用户取消',
+      noStatusUpdate: '未收到状态更新',
       other: '其他',
     },
     // Archive statuses

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

@@ -5421,6 +5421,7 @@ export default {
       underExtrusion: '擠出不足',
       powerFailure: '斷電',
       userCancelled: '使用者取消',
+      noStatusUpdate: '未收到狀態更新',
       other: '其他',
     },
     // Archive statuses

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-4PE719xk.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-BzPiBXm_.js"></script>
+    <script type="module" crossorigin src="/assets/index-4PE719xk.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-q2IPtdZB.css">
   </head>
   <body>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff