Browse Source

feat(print-log): per-row classification editor + fix silent-drop in GET

  (#1687 part 4, reported by @IndividualGhost1905)

  Reporter clarified after part 1 shipped that point 2 wasn't about
  archive `tags` (which describe the model — home decor, toys), but
  about failure-cause classification on the *log* row itself:
  spaghetti, jam, bed-adhesion, etc. Different surface, different
  lifetime.

  The data field he wanted already existed. PrintLogEntry.failure_reason
  is a String(100); the Failure Analysis widget already groups by it;
  the Archive Edit modal already mirrors archive.failure_reason into
  the most recent log entry (archives.py:1421, shipped with #1444).
  The only gaps were:

  1. The GET endpoint silently dropped failure_reason (and archive_id
     and created_by_id) from PrintLogEntrySchema construction even
     when set in the DB — so the Print Log table couldn't render what
     the Failure Analysis widget grouped by. Fixed independently of
     the editor; regression test added.

  2. Orphan log entries (no archive — dispatch errors, aborts before
     archive creation, manual entries) had no edit path at all because
     the Archive Edit modal cannot reach them. The new endpoint is
     the only way to classify those rows.

  Changes:

  - Backend: new PATCH /print-log/{entry_id} taking
    {failure_reason, status}, gated on require_ownership_permission(
    ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape
    as the per-row DELETE. Validates against the same 11-key failure
    vocabulary and 5-key status set the Archive Edit modal uses;
    unknown values return 400 rather than getting stored as raw text
    (the i18n layer maps the value back through the vocabulary,
    unrecognised values would render as literal strings).
    Empty-string failure_reason stores back as NULL so the column's
    nullable=True intent is preserved end-to-end. GET endpoint now
    surfaces failure_reason, archive_id, created_by_id.

  - Frontend: FAILURE_REASON_KEYS moved to an export from
    EditArchiveModal.tsx so the new editor reuses the exact same
    vocabulary — backend and frontend stay in lockstep. Pencil icon
    beside the existing trash icon on every Print Log row, opens a
    compact two-field modal (status + failure reason). Save
    invalidates print-log and archives-stats query keys so the
    Failure Analysis widget reflects the re-classification on the
    same response cycle. Failure reason rendered as a sub-label under
    the status badge, matching PrintLogTable.tsx's convention.

  - i18n: 10 new keys (editEntryTitle, editEntryDescription,
    entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus
    a 5-key statuses block) translated across all 11 locales. No
    English fallbacks.

  - Wiki: features/print-log.md gains per-row actions section,
    updated permissions table, PATCH/single-DELETE endpoint docs.
maziggy 2 months ago
parent
commit
bebdb38e41

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 119 - 1
backend/app/api/routes/print_log.py

@@ -16,7 +16,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.user import User
-from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
+from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
 
 logger = logging.getLogger(__name__)
 
@@ -72,6 +72,7 @@ async def get_print_log(
         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,
@@ -82,7 +83,12 @@ async def get_print_log(
                 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,
             )
@@ -177,3 +183,115 @@ async def delete_print_log_entry(
 
     logger.info("Print log entry %d deleted", entry_id)
     return {"status": "deleted", "id": entry_id}
+
+
+# Canonical failure-reason vocabulary. Mirrors the frontend dropdown in
+# EditArchiveModal.tsx; the empty string is the "clear classification" value.
+# The catch-all "other" is the escape hatch for failures that don't fit the
+# enumerated list. Keep these two lists in sync if the EditArchiveModal options
+# ever change.
+_FAILURE_REASON_KEYS = frozenset(
+    {
+        "",
+        "adhesionFailure",
+        "spaghettiDetached",
+        "layerShift",
+        "cloggedNozzle",
+        "filamentRunout",
+        "warping",
+        "stringing",
+        "underExtrusion",
+        "powerFailure",
+        "userCancelled",
+        "other",
+    }
+)
+
+# Same status vocabulary the print-log column already filters by.
+_STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skipped"})
+
+
+@router.patch("/{entry_id}", response_model=PrintLogEntrySchema)
+async def update_print_log_entry(
+    entry_id: int,
+    update: PrintLogEntryUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
+):
+    """Edit a single Print Log row's classification (#1687 part 4, reporter
+    IndividualGhost1905).
+
+    Lets the user set ``failure_reason`` (and optionally re-classify ``status``)
+    directly on a Print Log row — including orphan entries that have no
+    archive to edit through. The Failure Analysis widget already groups by
+    ``PrintLogEntry.failure_reason`` (see ``archives.py:1421`` for the
+    archive-side mirror); this endpoint is the missing edit affordance for the
+    log-side, mirror-less case.
+
+    Ownership semantics mirror the per-row delete: archives:update_all sees
+    everything; archives:update_own sees only rows it owns.
+    """
+    user, can_modify_all = auth_result
+
+    entry = await db.get(PrintLogEntry, entry_id)
+    if not entry:
+        raise HTTPException(404, "Print log entry not found")
+
+    if not can_modify_all:
+        if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
+            raise HTTPException(403, "You can only update your own print log entries")
+
+    payload = update.model_dump(exclude_unset=True)
+
+    # Validate against the canonical vocabularies. Reject unknown values rather
+    # than silently storing them — the Failure Analysis widget renders the
+    # values back as i18n keys, and an unrecognised value would surface as a
+    # raw string in the UI.
+    if "failure_reason" in payload:
+        new_reason = payload["failure_reason"] or ""
+        if new_reason not in _FAILURE_REASON_KEYS:
+            raise HTTPException(400, f"Unknown failure_reason: {new_reason!r}")
+        # Store empty string back as NULL so the column's nullable=True intent
+        # is preserved end-to-end.
+        entry.failure_reason = new_reason or None
+
+    if "status" in payload and payload["status"] is not None:
+        new_status = payload["status"]
+        if new_status not in _STATUS_KEYS:
+            raise HTTPException(400, f"Unknown status: {new_status!r}")
+        entry.status = new_status
+
+    await db.commit()
+    await db.refresh(entry)
+
+    logger.info(
+        "Print log entry %d updated (failure_reason=%r, status=%r)",
+        entry_id,
+        entry.failure_reason,
+        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,
+    )

+ 13 - 0
backend/app/schemas/print_log.py

@@ -29,3 +29,16 @@ class PrintLogEntrySchema(BaseModel):
 class PrintLogResponse(BaseModel):
     items: list[PrintLogEntrySchema]
     total: int
+
+
+class PrintLogEntryUpdate(BaseModel):
+    """Per-row classification edits (#1687 part 4 — IndividualGhost1905).
+
+    Lets the user set ``failure_reason`` (and re-classify ``status``) directly
+    on a Print Log row, including on orphan entries that have no archive to
+    edit through. The Failure Analysis widget already groups by
+    ``PrintLogEntry.failure_reason``, so this just plugs the editor gap.
+    """
+
+    failure_reason: str | None = None
+    status: str | None = None

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

@@ -606,6 +606,207 @@ class TestPrintLogEntryDelete:
         assert set(survivors) == {a.id, c.id}
 
 
+class TestPrintLogEntryUpdate:
+    """Tests for ``PATCH /print-log/{entry_id}`` (#1687 part 4).
+
+    Pin the route's contracts: (1) GET serialiser actually surfaces
+    ``failure_reason`` (previously it was silently dropped from the response
+    even when set in the DB); (2) PATCH persists ``failure_reason`` and
+    ``status``; (3) unknown vocabulary returns 400 rather than getting stored
+    as raw garbage; (4) missing IDs return 404.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_surfaces_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Pre-fix the GET endpoint built PrintLogEntrySchema without
+        ``failure_reason`` even though the column was populated, so the Print
+        Log table couldn't render what the Failure Analysis widget already
+        groups by. Regression guard for the silent-drop bug.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.failure_reason = "spaghettiDetached"
+        await db_session.commit()
+
+        body = (await async_client.get("/api/v1/print-log/")).json()
+        match = next(item for item in body["items"] if item["id"] == entry.id)
+        assert match["failure_reason"] == "spaghettiDetached"
+        # archive_id should also flow through so the frontend can tell orphan
+        # entries apart from archive-linked ones.
+        assert match["archive_id"] == archive.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_sets_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        assert entry.failure_reason is None
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"failure_reason": "cloggedNozzle"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["failure_reason"] == "cloggedNozzle"
+
+        await db_session.refresh(entry)
+        assert entry.failure_reason == "cloggedNozzle"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_can_clear_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Empty-string failure_reason stores back as NULL (the column's
+        nullable=True intent is preserved end-to-end)."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.failure_reason = "warping"
+        await db_session.commit()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"failure_reason": ""},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["failure_reason"] is None
+
+        await db_session.refresh(entry)
+        assert entry.failure_reason is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_unknown_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Unknown values must 400 — otherwise the UI would render raw garbage
+        because the i18n layer maps the value back through the canonical
+        vocabulary."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"failure_reason": "completely-made-up"},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_updates_status(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="completed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.status = "completed"
+        await db_session.commit()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"status": "failed", "failure_reason": "layerShift"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "failed"
+        assert resp.json()["failure_reason"] == "layerShift"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_unknown_status(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"status": "bogus-status"},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_404_when_missing(self, async_client: AsyncClient):
+        resp = await async_client.patch(
+            "/api/v1/print-log/999999",
+            json={"failure_reason": "cloggedNozzle"},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_works_on_orphan_entry(self, async_client: AsyncClient, printer_factory, db_session):
+        """Orphan log entries (no archive_id) are the actual reason this
+        endpoint exists — the Archive Edit modal can't reach them. Make sure
+        the PATCH works for those rows specifically."""
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        orphan = PrintLogEntry(
+            archive_id=None,
+            print_name="failed-before-archive-created",
+            printer_id=printer.id,
+            status="failed",
+            failure_reason=None,
+        )
+        db_session.add(orphan)
+        await db_session.commit()
+        await db_session.refresh(orphan)
+        assert orphan.archive_id is None
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{orphan.id}",
+            json={"failure_reason": "powerFailure"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["failure_reason"] == "powerFailure"
+        assert resp.json()["archive_id"] is None
+
+
 class TestArchivesSlimAPI:
     """Integration tests for /api/v1/archives/slim endpoint."""
 

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

@@ -4381,6 +4381,18 @@ export const api = {
     request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
   deletePrintLogEntry: (id: number) =>
     request<{ status: string; id: number }>(`/print-log/${id}`, { method: 'DELETE' }),
+  // Edit failure_reason / status on a single Print Log row (#1687 part 4).
+  // Distinct from updateArchive: archives describe the model, log entries
+  // describe a single print event. Orphan entries (no archive_id) have no
+  // archive to reach through and this is the only path to classify them.
+  updatePrintLogEntry: (
+    id: number,
+    body: { failure_reason?: string | null; status?: string },
+  ) =>
+    request<PrintLogEntry>(`/print-log/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(body),
+    }),
 
   // Settings
   getSettings: () => request<AppSettings>('/settings/'),

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

@@ -7,8 +7,11 @@ import type { Archive } from '../api/client';
 import { Button } from './Button';
 import { PrintLogTable } from './PrintLogTable';
 
-// Keys for failure reasons - translated at render time
-const FAILURE_REASON_KEYS = [
+// Keys for failure reasons - translated at render time.
+// Exported so the Print Log per-row classification editor (#1687 part 4)
+// can share the same vocabulary as the Archive Edit modal — the backend
+// PATCH /print-log/{id} validator gates writes against this exact list.
+export const FAILURE_REASON_KEYS = [
   'adhesionFailure',
   'spaghettiDetached',
   'layerShift',

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'Sie haben keine Berechtigung, Archive herunterzuladen',
       noCopyLink: 'Sie haben keine Berechtigung, Download-Links zu kopieren',
       noDelete: 'Sie haben keine Berechtigung, dieses Archiv zu löschen',
+      noEdit: 'Sie haben keine Berechtigung, diesen Eintrag zu bearbeiten',
       noCreate: 'Sie haben keine Berechtigung, Archive zu erstellen',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'Dieser Eintrag wird aus dem Protokoll entfernt und sein Filament-, Zeit- und Kostenbeitrag verschwindet aus den Schnellstatistiken. Das zugehörige Archiv (falls vorhanden) ist nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden.',
       entryDeleted: 'Druckprotokoll-Eintrag gelöscht',
       entryDeleteFailed: 'Druckprotokoll-Eintrag konnte nicht gelöscht werden',
+      editEntryTitle: 'Druckprotokoll-Eintrag bearbeiten',
+      editEntryDescription: 'Diesen Druckdurchlauf klassifizieren. Das Fehleranalyse-Widget gruppiert nach diesen Werten, sodass Aktualisierungen sofort in die Statistik einfließen.',
+      entryUpdated: 'Druckprotokoll-Eintrag aktualisiert',
+      entryUpdateFailed: 'Druckprotokoll-Eintrag konnte nicht aktualisiert werden',
+      statuses: {
+        completed: 'Abgeschlossen',
+        failed: 'Fehlgeschlagen',
+        stopped: 'Gestoppt',
+        cancelled: 'Abgebrochen',
+        skipped: 'Übersprungen',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'You do not have permission to download archives',
       noCopyLink: 'You do not have permission to copy download links',
       noDelete: 'You do not have permission to delete this archive',
+      noEdit: 'You do not have permission to edit this entry',
       noCreate: 'You do not have permission to create archives',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'This entry will be removed from the log and its filament, time, and cost contributions will drop out of Quick Stats. The matching archive (if any) is not affected. This action cannot be undone.',
       entryDeleted: 'Print log entry deleted',
       entryDeleteFailed: 'Failed to delete print log entry',
+      editEntryTitle: 'Edit print log entry',
+      editEntryDescription: 'Classify this print run. The Failure Analysis widget groups by these values, so updates flow through to stats immediately.',
+      entryUpdated: 'Print log entry updated',
+      entryUpdateFailed: 'Failed to update print log entry',
+      statuses: {
+        completed: 'Completed',
+        failed: 'Failed',
+        stopped: 'Stopped',
+        cancelled: 'Cancelled',
+        skipped: 'Skipped',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'No tiene permiso para descargar archivos',
       noCopyLink: 'No tiene permiso para copiar enlaces de descarga',
       noDelete: 'No tiene permiso para eliminar este archivo',
+      noEdit: 'No tiene permiso para editar esta entrada',
       noCreate: 'No tiene permiso para crear archivos',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'Esta entrada se eliminará del registro y sus contribuciones de filamento, tiempo y costo desaparecerán de las estadísticas rápidas. El archivo correspondiente (si lo hay) no se ve afectado. Esta acción no se puede deshacer.',
       entryDeleted: 'Entrada del registro de impresión eliminada',
       entryDeleteFailed: 'Error al eliminar la entrada del registro de impresión',
+      editEntryTitle: 'Editar entrada del registro de impresión',
+      editEntryDescription: 'Clasifica esta impresión. El widget de Análisis de fallos agrupa por estos valores, por lo que las actualizaciones se reflejan en las estadísticas de inmediato.',
+      entryUpdated: 'Entrada del registro actualizada',
+      entryUpdateFailed: 'No se pudo actualizar la entrada del registro',
+      statuses: {
+        completed: 'Completada',
+        failed: 'Fallida',
+        stopped: 'Detenida',
+        cancelled: 'Cancelada',
+        skipped: 'Omitida',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'Pas d\'autorisation de téléchargement',
       noCopyLink: 'Pas d\'autorisation de copie lien',
       noDelete: 'Pas d\'autorisation de suppression',
+      noEdit: 'Pas d\'autorisation de modification',
       noCreate: 'Pas d\'autorisation de création',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'Cette entrée sera retirée du journal et ses contributions en filament, temps et coût disparaîtront des statistiques rapides. L\'archive correspondante (le cas échéant) n\'est pas affectée. Cette action est irréversible.',
       entryDeleted: 'Entrée du journal d\'impression supprimée',
       entryDeleteFailed: 'Échec de la suppression de l\'entrée du journal d\'impression',
+      editEntryTitle: 'Modifier l\'entrée du journal d\'impression',
+      editEntryDescription: 'Classez cette impression. Le widget Analyse des échecs regroupe par ces valeurs, ce qui répercute immédiatement la mise à jour dans les statistiques.',
+      entryUpdated: 'Entrée du journal mise à jour',
+      entryUpdateFailed: 'Échec de la mise à jour de l\'entrée du journal',
+      statuses: {
+        completed: 'Terminée',
+        failed: 'Échouée',
+        stopped: 'Arrêtée',
+        cancelled: 'Annulée',
+        skipped: 'Ignorée',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'Non hai il permesso di scaricare archivi',
       noCopyLink: 'Non hai il permesso di copiare link download',
       noDelete: 'Non hai il permesso di eliminare questo archivio',
+      noEdit: 'Non hai il permesso di modificare questa voce',
       noCreate: 'Non hai il permesso di creare archivi',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'Questa voce verrà rimossa dal registro e i suoi contributi di filamento, tempo e costo verranno scartati dalle statistiche rapide. L\'archivio corrispondente (se presente) non è interessato. Questa azione non può essere annullata.',
       entryDeleted: 'Voce del registro stampe eliminata',
       entryDeleteFailed: 'Impossibile eliminare la voce del registro stampe',
+      editEntryTitle: 'Modifica voce del registro stampe',
+      editEntryDescription: 'Classifica questa stampa. Il widget Analisi guasti raggruppa per questi valori, quindi gli aggiornamenti vengono riflessi immediatamente nelle statistiche.',
+      entryUpdated: 'Voce del registro aggiornata',
+      entryUpdateFailed: 'Impossibile aggiornare la voce del registro',
+      statuses: {
+        completed: 'Completata',
+        failed: 'Fallita',
+        stopped: 'Fermata',
+        cancelled: 'Annullata',
+        skipped: 'Saltata',
+      },
     },
   },
 

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

@@ -767,6 +767,7 @@ export default {
       noDownload: 'アーカイブをダウンロードする権限がありません',
       noCopyLink: 'ダウンロードリンクをコピーする権限がありません',
       noDelete: 'このアーカイブを削除する権限がありません',
+      noEdit: 'このエントリを編集する権限がありません',
       noCreate: 'アーカイブを作成する権限がありません',
     },
     platePicker: {
@@ -929,6 +930,17 @@ export default {
       deleteEntryConfirm: 'このエントリはログから削除され、フィラメント、時間、コストの寄与はクイック統計から除外されます。対応するアーカイブ(ある場合)は影響を受けません。この操作は取り消せません。',
       entryDeleted: '印刷ログのエントリを削除しました',
       entryDeleteFailed: '印刷ログのエントリを削除できませんでした',
+      editEntryTitle: '印刷ログのエントリを編集',
+      editEntryDescription: 'この印刷を分類します。失敗分析ウィジェットはこれらの値でグループ化されるため、更新は即座に統計に反映されます。',
+      entryUpdated: '印刷ログのエントリを更新しました',
+      entryUpdateFailed: '印刷ログのエントリを更新できませんでした',
+      statuses: {
+        completed: '完了',
+        failed: '失敗',
+        stopped: '停止',
+        cancelled: 'キャンセル',
+        skipped: 'スキップ',
+      },
     },
   },
 

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

@@ -725,6 +725,7 @@ export default {
       noDownload: '아카이브를 다운로드할 권한이 없습니다',
       noCopyLink: '다운로드 링크를 복사할 권한이 없습니다',
       noDelete: '이 아카이브를 삭제할 권한이 없습니다',
+      noEdit: '이 항목을 편집할 권한이 없습니다',
       noCreate: '아카이브를 만들 권한이 없습니다'
     },
     platePicker: {
@@ -865,7 +866,18 @@ export default {
       deleteEntryTitle: '인쇄 로그 항목 삭제',
       deleteEntryConfirm: '이 항목이 로그에서 삭제되며 필라멘트, 시간 및 비용 기여도가 빠른 통계에서 제외됩니다. 해당 아카이브(있는 경우)는 영향을 받지 않습니다. 이 작업은 취소할 수 없습니다.',
       entryDeleted: '인쇄 로그 항목이 삭제되었습니다',
-      entryDeleteFailed: '인쇄 로그 항목 삭제 실패'
+      entryDeleteFailed: '인쇄 로그 항목 삭제 실패',
+      editEntryTitle: '인쇄 로그 항목 편집',
+      editEntryDescription: '이 인쇄를 분류합니다. 실패 분석 위젯이 이 값으로 그룹화하므로 업데이트가 통계에 즉시 반영됩니다.',
+      entryUpdated: '인쇄 로그 항목이 업데이트되었습니다',
+      entryUpdateFailed: '인쇄 로그 항목 업데이트 실패',
+      statuses: {
+        completed: '완료됨',
+        failed: '실패',
+        stopped: '중지됨',
+        cancelled: '취소됨',
+        skipped: '건너뜀'
+      }
     },
     runLog: {
       title: '인쇄 기록',

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'Você não tem permissão para baixar arquivos',
       noCopyLink: 'Você não tem permissão para copiar links de download',
       noDelete: 'Você não tem permissão para excluir este arquivo',
+      noEdit: 'Você não tem permissão para editar esta entrada',
       noCreate: 'Você não tem permissão para criar arquivos',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'Esta entrada será removida do registro e suas contribuições de filamento, tempo e custo desaparecerão das estatísticas rápidas. O arquivo correspondente (se houver) não é afetado. Esta ação não pode ser desfeita.',
       entryDeleted: 'Entrada do registro de impressão excluída',
       entryDeleteFailed: 'Falha ao excluir entrada do registro de impressão',
+      editEntryTitle: 'Editar entrada do registro de impressão',
+      editEntryDescription: 'Classifique esta impressão. O widget Análise de falhas agrupa por esses valores, então as atualizações são refletidas nas estatísticas imediatamente.',
+      entryUpdated: 'Entrada do registro atualizada',
+      entryUpdateFailed: 'Falha ao atualizar a entrada do registro',
+      statuses: {
+        completed: 'Concluída',
+        failed: 'Falhou',
+        stopped: 'Parada',
+        cancelled: 'Cancelada',
+        skipped: 'Ignorada',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: 'Arşivleri indirme izniniz yok',
       noCopyLink: 'İndirme bağlantılarını kopyalama izniniz yok',
       noDelete: 'Bu arşivi silme izniniz yok',
+      noEdit: 'Bu girişi düzenleme izniniz yok',
       noCreate: 'Arşiv oluşturma izniniz yok',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: 'Bu giriş günlükten kaldırılacak ve filament, süre ve maliyet katkıları Hızlı İstatistikler\'den çıkarılacak. İlgili arşiv (varsa) etkilenmez. Bu işlem geri alınamaz.',
       entryDeleted: 'Baskı günlüğü girişi silindi',
       entryDeleteFailed: 'Baskı günlüğü girişi silinemedi',
+      editEntryTitle: 'Baskı günlüğü girişini düzenle',
+      editEntryDescription: 'Bu baskıyı sınıflandırın. Hata Analizi widget\'ı bu değerlere göre gruplandırır, böylece güncellemeler istatistiklere anında yansır.',
+      entryUpdated: 'Baskı günlüğü girişi güncellendi',
+      entryUpdateFailed: 'Baskı günlüğü girişi güncellenemedi',
+      statuses: {
+        completed: 'Tamamlandı',
+        failed: 'Başarısız',
+        stopped: 'Durduruldu',
+        cancelled: 'İptal edildi',
+        skipped: 'Atlandı',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: '您没有下载归档的权限',
       noCopyLink: '您没有复制下载链接的权限',
       noDelete: '您没有删除此归档的权限',
+      noEdit: '您没有编辑此条目的权限',
       noCreate: '您没有创建归档的权限',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: '此条目将从日志中删除,其耗材、时间和成本贡献也将从快速统计中移除。对应的归档(如果有)不受影响。此操作无法撤销。',
       entryDeleted: '已删除打印日志条目',
       entryDeleteFailed: '删除打印日志条目失败',
+      editEntryTitle: '编辑打印日志条目',
+      editEntryDescription: '对这次打印进行分类。故障分析小部件按这些值分组,因此更新会立即反映在统计数据中。',
+      entryUpdated: '已更新打印日志条目',
+      entryUpdateFailed: '更新打印日志条目失败',
+      statuses: {
+        completed: '已完成',
+        failed: '失败',
+        stopped: '已停止',
+        cancelled: '已取消',
+        skipped: '已跳过',
+      },
     },
   },
 

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

@@ -768,6 +768,7 @@ export default {
       noDownload: '您沒有下載歸檔的權限',
       noCopyLink: '您沒有複製下載連結的權限',
       noDelete: '您沒有刪除此歸檔的權限',
+      noEdit: '您沒有編輯此條目的權限',
       noCreate: '您沒有建立歸檔的權限',
     },
     platePicker: {
@@ -930,6 +931,17 @@ export default {
       deleteEntryConfirm: '此條目將從日誌中刪除,其耗材、時間與成本貢獻也將從快速統計中移除。對應的歸檔(如有)不受影響。此操作無法復原。',
       entryDeleted: '已刪除列印日誌條目',
       entryDeleteFailed: '刪除列印日誌條目失敗',
+      editEntryTitle: '編輯列印日誌條目',
+      editEntryDescription: '對這次列印進行分類。故障分析小工具按這些值分組,因此更新會立即反映在統計數據中。',
+      entryUpdated: '已更新列印日誌條目',
+      entryUpdateFailed: '更新列印日誌條目失敗',
+      statuses: {
+        completed: '已完成',
+        failed: '失敗',
+        stopped: '已停止',
+        cancelled: '已取消',
+        skipped: '已略過',
+      },
     },
   },
 

+ 158 - 18
frontend/src/pages/ArchivesPage.tsx

@@ -64,14 +64,14 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
 import { useIsMobile } from '../hooks/useIsMobile';
-import type { Archive, ProjectListItem } from '../api/client';
+import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
-import { EditArchiveModal } from '../components/EditArchiveModal';
+import { EditArchiveModal, FAILURE_REASON_KEYS } from '../components/EditArchiveModal';
 import { PrintLogModal } from '../components/PrintLogModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { BatchTagModal } from '../components/BatchTagModal';
@@ -2612,6 +2612,11 @@ export function ArchivesPage() {
   });
   const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
   const [pendingDeleteEntryId, setPendingDeleteEntryId] = useState<number | null>(null);
+  // Per-row classification editor for Print Log entries (#1687 part 4).
+  // Holds the entry being edited; null = modal closed.
+  const [editingLogEntry, setEditingLogEntry] = useState<PrintLogEntry | null>(null);
+  const [editingLogFailureReason, setEditingLogFailureReason] = useState('');
+  const [editingLogStatus, setEditingLogStatus] = useState('');
   const [logPageSize, setLogPageSize] = useState(() => {
     const saved = localStorage.getItem('logPageSize');
     return saved ? Number(saved) : 25;
@@ -2736,6 +2741,23 @@ export function ArchivesPage() {
     },
   });
 
+  const updateLogEntryMutation = useMutation({
+    mutationFn: ({ id, body }: { id: number; body: { failure_reason?: string | null; status?: string } }) =>
+      api.updatePrintLogEntry(id, body),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      // Also invalidate /archives/stats — the Failure Analysis widget there
+      // groups by PrintLogEntry.failure_reason, so a re-classification needs
+      // to flow through (#1687 part 4).
+      queryClient.invalidateQueries({ queryKey: ['archives-stats'] });
+      setEditingLogEntry(null);
+      showToast(t('archives.log.entryUpdated'));
+    },
+    onError: () => {
+      showToast(t('archives.log.entryUpdateFailed'), 'error');
+    },
+  });
+
   // Persist all filters to localStorage
   useEffect(() => {
     if (filterPrinter !== null) {
@@ -3799,6 +3821,11 @@ export function ArchivesPage() {
                             }`}>
                               {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) : '—'}
@@ -3817,22 +3844,44 @@ export function ArchivesPage() {
                             </div>
                           </td>
                           <td className="px-4 py-3 text-right">
-                            <button
-                              type="button"
-                              onClick={() => setPendingDeleteEntryId(entry.id)}
-                              disabled={
-                                deleteLogEntryMutation.isPending ||
-                                !(hasPermission('archives:delete_all') || hasPermission('archives:delete_own'))
-                              }
-                              title={
-                                hasPermission('archives:delete_all') || hasPermission('archives:delete_own')
-                                  ? t('archives.log.deleteEntryTitle')
-                                  : t('archives.permission.noDelete')
-                              }
-                              className="text-bambu-gray hover:text-red-400 disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
-                            >
-                              <Trash2 className="w-4 h-4" />
-                            </button>
+                            <div className="inline-flex items-center gap-2">
+                              <button
+                                type="button"
+                                onClick={() => {
+                                  setEditingLogEntry(entry);
+                                  setEditingLogFailureReason(entry.failure_reason || '');
+                                  setEditingLogStatus(entry.status);
+                                }}
+                                disabled={
+                                  updateLogEntryMutation.isPending ||
+                                  !(hasPermission('archives:update_all') || hasPermission('archives:update_own'))
+                                }
+                                title={
+                                  hasPermission('archives:update_all') || hasPermission('archives:update_own')
+                                    ? t('archives.log.editEntryTitle')
+                                    : t('archives.permission.noEdit')
+                                }
+                                className="text-bambu-gray hover:text-bambu-blue disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
+                              >
+                                <Pencil className="w-4 h-4" />
+                              </button>
+                              <button
+                                type="button"
+                                onClick={() => setPendingDeleteEntryId(entry.id)}
+                                disabled={
+                                  deleteLogEntryMutation.isPending ||
+                                  !(hasPermission('archives:delete_all') || hasPermission('archives:delete_own'))
+                                }
+                                title={
+                                  hasPermission('archives:delete_all') || hasPermission('archives:delete_own')
+                                    ? t('archives.log.deleteEntryTitle')
+                                    : t('archives.permission.noDelete')
+                                }
+                                className="text-bambu-gray hover:text-red-400 disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
+                              >
+                                <Trash2 className="w-4 h-4" />
+                              </button>
+                            </div>
                           </td>
                         </tr>
                       ))}
@@ -3972,6 +4021,97 @@ export function ArchivesPage() {
           onCancel={() => setPendingDeleteEntryId(null)}
         />
       )}
+
+      {/* Per-row Print Log classification editor (#1687 part 4).
+          Reuses editArchive.failureReasons.* and archives.log.statuses.*
+          vocabularies so the dropdown stays in lockstep with the
+          Archive Edit modal — backend validates against the same list. */}
+      {editingLogEntry !== null && (
+        <div
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+          onClick={() => setEditingLogEntry(null)}
+        >
+          <div
+            className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg w-full max-w-md mx-4 p-6"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <h3 className="text-lg font-semibold text-white mb-4">
+              {t('archives.log.editEntryTitle')}
+            </h3>
+            <p className="text-xs text-bambu-gray mb-4">
+              {t('archives.log.editEntryDescription')}
+            </p>
+
+            <div className="space-y-4">
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {t('editArchive.status')}
+                </label>
+                <select
+                  value={editingLogStatus}
+                  onChange={(e) => setEditingLogStatus(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="completed">{t('archives.log.statuses.completed', { defaultValue: 'completed' })}</option>
+                  <option value="failed">{t('archives.log.statuses.failed', { defaultValue: 'failed' })}</option>
+                  <option value="stopped">{t('archives.log.statuses.stopped', { defaultValue: 'stopped' })}</option>
+                  <option value="cancelled">{t('archives.log.statuses.cancelled', { defaultValue: 'cancelled' })}</option>
+                  <option value="skipped">{t('archives.log.statuses.skipped', { defaultValue: 'skipped' })}</option>
+                </select>
+              </div>
+
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {t('editArchive.failureReason')}
+                </label>
+                <select
+                  value={editingLogFailureReason}
+                  onChange={(e) => setEditingLogFailureReason(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="">{t('editArchive.selectReason')}</option>
+                  {FAILURE_REASON_KEYS.map((key) => (
+                    <option key={key} value={key}>
+                      {t(`editArchive.failureReasons.${key}`)}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            </div>
+
+            <div className="flex justify-end gap-2 mt-6">
+              <Button
+                variant="secondary"
+                onClick={() => setEditingLogEntry(null)}
+              >
+                {t('common.cancel')}
+              </Button>
+              <Button
+                variant="primary"
+                onClick={() => {
+                  const body: { failure_reason?: string | null; status?: string } = {};
+                  // Send failure_reason only if it changed — empty string
+                  // clears the classification (backend stores it as NULL).
+                  if (editingLogFailureReason !== (editingLogEntry.failure_reason || '')) {
+                    body.failure_reason = editingLogFailureReason || null;
+                  }
+                  if (editingLogStatus !== editingLogEntry.status) {
+                    body.status = editingLogStatus;
+                  }
+                  if (Object.keys(body).length === 0) {
+                    setEditingLogEntry(null);
+                    return;
+                  }
+                  updateLogEntryMutation.mutate({ id: editingLogEntry.id, body });
+                }}
+                disabled={updateLogEntryMutation.isPending}
+              >
+                {updateLogEntryMutation.isPending ? t('editArchive.saving') : t('common.save')}
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DGbY3_Tm.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-Dx3eHcCx.js"></script>
+    <script type="module" crossorigin src="/assets/index-DGbY3_Tm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff