feat: add print log timeline view in archives page

Add a chronological, table-based print log as a 4th view mode in the
Archives page. Log entries are stored in a separate print_log_entries
table — clearing the log never touches archives or queue items.

Backend:
- New PrintLogEntry model with independent table
- GET /print-log/ endpoint with search, printer, user, status, date filters
- DELETE /print-log/ clears only log entries
- Thumbnail serving endpoint for log entries
- write_log_entry() service called on print completion
- Auth: ARCHIVES_READ for viewing, ARCHIVES_DELETE_ALL for clearing

Frontend:
- New 'log' ViewMode with ClipboardList icon toggle
- Filterable/searchable table with pagination (10/25/50/100 rows)
- Colored status badges, duration formatting, filament color swatches
- Clear button with confirmation modal
- All filter state persisted to localStorage
- i18n: EN, DE, JA, FR, IT translations
This commit is contained in:
maziggy
2026-02-15 13:14:15 +01:00
parent caedfffa5b
commit 27cecbed87
18 changed files with 1344 additions and 445 deletions
+1
View File
@@ -15,6 +15,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).
- **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
- **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
- **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
### Fixed
- **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.
+1
View File
@@ -76,6 +76,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
- Archive comparison (side-by-side diff)
- Tag management (rename/delete across all archives)
- **Print Log** — Chronological table view of all print activity with columns for date/time, print name, printer, user, status, duration, and filament. Filterable by search, printer, user, status, and date range. Pagination with configurable page size. Clear button removes log entries without affecting archives.
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
+129
View File
@@ -0,0 +1,129 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import settings
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/print-log", tags=["print-log"])
@router.get("/", response_model=PrintLogResponse)
async def get_print_log(
search: str | None = None,
printer_id: int | None = None,
created_by_username: str | None = None,
status: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get the print log."""
query = select(PrintLogEntry)
count_query = select(func.count(PrintLogEntry.id))
if printer_id is not None:
query = query.where(PrintLogEntry.printer_id == printer_id)
count_query = count_query.where(PrintLogEntry.printer_id == printer_id)
if created_by_username:
query = query.where(PrintLogEntry.created_by_username == created_by_username)
count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)
if status:
query = query.where(PrintLogEntry.status == status)
count_query = count_query.where(PrintLogEntry.status == status)
if search:
query = query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
count_query = count_query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
if date_from:
query = query.where(PrintLogEntry.created_at >= date_from)
count_query = count_query.where(PrintLogEntry.created_at >= date_from)
if date_to:
query = query.where(PrintLogEntry.created_at <= date_to)
count_query = count_query.where(PrintLogEntry.created_at <= date_to)
# Get total count
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)
result = await db.execute(query)
entries = result.scalars().all()
return PrintLogResponse(
items=[
PrintLogEntrySchema(
id=e.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,
thumbnail_path=e.thumbnail_path,
created_by_username=e.created_by_username,
created_at=e.created_at,
)
for e in entries
],
total=total,
)
@router.get("/{entry_id}/thumbnail")
async def get_print_log_thumbnail(
entry_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get the thumbnail for a print log entry.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
entry = await db.get(PrintLogEntry, entry_id)
if not entry or not entry.thumbnail_path:
raise HTTPException(404, "Thumbnail not found")
thumb_path = settings.base_dir / entry.thumbnail_path
if not thumb_path.exists():
raise HTTPException(404, "Thumbnail file not found")
return FileResponse(
path=thumb_path,
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.delete("/")
async def clear_print_log(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
):
"""Clear the print log.
Only deletes log entries. Archives and queue items are never touched.
"""
result = await db.execute(delete(PrintLogEntry))
deleted = result.rowcount
await db.commit()
logger.info("Print log cleared: %d entries deleted", deleted)
return {"deleted": deleted}
+1
View File
@@ -89,6 +89,7 @@ async def init_db():
notification_template,
orca_base_cache,
pending_upload,
print_log,
print_queue,
printer,
project,
+35
View File
@@ -194,6 +194,7 @@ from backend.app.api.routes import (
notification_templates,
notifications,
pending_uploads,
print_log,
print_queue,
printers,
projects,
@@ -1958,6 +1959,9 @@ async def on_print_complete(printer_id: int, data: dict):
except Exception as e:
logger.warning("[CALLBACK] WebSocket send_print_complete failed: %s", e)
# Capture user info before clearing (needed for print log entry)
_print_user_info = printer_manager.get_current_print_user(printer_id)
# Clear current print user tracking (Issue #206)
printer_manager.clear_current_print_user(printer_id)
@@ -2158,6 +2162,36 @@ async def on_print_complete(printer_id: int, data: dict):
log_timing("Archive status update")
# Write independent print log entry (separate table, never touches archives)
try:
async with async_session() as db:
from backend.app.models.archive import PrintArchive
from backend.app.services.print_log import write_log_entry
archive = await db.get(PrintArchive, archive_id)
if archive:
p_info = printer_manager.get_printer(printer_id)
await write_log_entry(
db,
status=data.get("status", "completed"),
print_name=archive.print_name,
printer_name=p_info.name if p_info else None,
printer_id=printer_id,
started_at=archive.started_at,
completed_at=archive.completed_at,
filament_type=archive.filament_type,
filament_color=archive.filament_color,
filament_used_grams=archive.filament_used_grams,
thumbnail_path=archive.thumbnail_path,
created_by_username=_print_user_info.get("username") if _print_user_info else None,
)
await db.commit()
logger.info("[PRINT_LOG] Log entry written for archive %s", archive_id)
except Exception as e:
logger.warning("[PRINT_LOG] Failed to write log entry for archive %s: %s", archive_id, e)
log_timing("Print log entry")
# Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
usage_results: list[dict] = []
try:
@@ -3369,6 +3403,7 @@ app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(print_log.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class PrintLogEntry(Base):
"""Independent print log entry. Written when print events occur.
This is a separate table from archives/queue — clearing the log
never touches archives or queue items.
"""
__tablename__ = "print_log_entries"
id: Mapped[int] = mapped_column(primary_key=True)
print_name: Mapped[str | None] = mapped_column(String(255))
printer_name: Mapped[str | None] = mapped_column(String(255))
printer_id: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(20)) # completed, failed, stopped, cancelled, skipped
started_at: Mapped[datetime | None] = mapped_column(DateTime)
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
duration_seconds: Mapped[int | None] = mapped_column(Integer)
filament_type: Mapped[str | None] = mapped_column(String(50))
filament_color: Mapped[str | None] = mapped_column(String(50))
filament_used_grams: Mapped[float | None] = mapped_column(Float)
thumbnail_path: Mapped[str | None] = mapped_column(String(500))
created_by_username: Mapped[str | None] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
from pydantic import BaseModel
class PrintLogEntrySchema(BaseModel):
id: int
print_name: str | None = None
printer_name: str | None = None
printer_id: int | None = None
status: str
started_at: datetime | None = None
completed_at: datetime | None = None
duration_seconds: int | None = None
filament_type: str | None = None
filament_color: str | None = None
filament_used_grams: float | None = None
thumbnail_path: str | None = None
created_by_username: str | None = None
created_at: datetime
class PrintLogResponse(BaseModel):
items: list[PrintLogEntrySchema]
total: int
+52
View File
@@ -0,0 +1,52 @@
"""Service for writing independent print log entries.
Log entries are written to a separate table and never touch archives or queue items.
"""
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.print_log import PrintLogEntry
logger = logging.getLogger(__name__)
async def write_log_entry(
db: AsyncSession,
*,
status: str,
print_name: str | None = None,
printer_name: str | None = None,
printer_id: int | None = None,
started_at: datetime | None = None,
completed_at: datetime | None = None,
filament_type: str | None = None,
filament_color: str | None = None,
filament_used_grams: float | None = None,
thumbnail_path: str | None = None,
created_by_username: str | None = None,
) -> PrintLogEntry:
"""Write a print log entry."""
duration = None
if started_at and completed_at:
duration = int((completed_at - started_at).total_seconds())
entry = PrintLogEntry(
print_name=print_name,
printer_name=printer_name,
printer_id=printer_id,
status=status,
started_at=started_at,
completed_at=completed_at,
duration_seconds=duration,
filament_type=filament_type,
filament_color=filament_color,
filament_used_grams=filament_used_grams,
thumbnail_path=thumbnail_path,
created_by_username=created_by_username,
)
db.add(entry)
await db.flush()
return entry
+104
View File
@@ -0,0 +1,104 @@
"""Unit tests for print log service and schema."""
from datetime import datetime, timedelta
import pytest
from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
class TestPrintLogEntrySchema:
"""Test PrintLogEntrySchema validation."""
def test_minimal_entry(self):
"""Schema accepts minimal required fields."""
entry = PrintLogEntrySchema(
id=1,
status="completed",
created_at=datetime(2024, 1, 15, 10, 30, 0),
)
assert entry.id == 1
assert entry.status == "completed"
assert entry.print_name is None
assert entry.printer_name is None
assert entry.duration_seconds is None
def test_full_entry(self):
"""Schema accepts all fields."""
started = datetime(2024, 1, 15, 10, 0, 0)
completed = datetime(2024, 1, 15, 12, 30, 0)
entry = PrintLogEntrySchema(
id=42,
print_name="Benchy",
printer_name="X1C-01",
printer_id=3,
status="completed",
started_at=started,
completed_at=completed,
duration_seconds=9000,
filament_type="PLA",
filament_color="#FF5500",
filament_used_grams=15.2,
thumbnail_path="archives/3/20240115_benchy/thumbnail.png",
created_by_username="admin",
created_at=datetime(2024, 1, 15, 12, 30, 0),
)
assert entry.print_name == "Benchy"
assert entry.printer_name == "X1C-01"
assert entry.filament_used_grams == 15.2
assert entry.created_by_username == "admin"
def test_failed_status(self):
"""Schema accepts various status values."""
for status in ("completed", "failed", "stopped", "cancelled", "skipped"):
entry = PrintLogEntrySchema(id=1, status=status, created_at=datetime.now())
assert entry.status == status
class TestPrintLogResponse:
"""Test PrintLogResponse pagination wrapper."""
def test_empty_response(self):
"""Empty response with zero total."""
resp = PrintLogResponse(items=[], total=0)
assert len(resp.items) == 0
assert resp.total == 0
def test_paginated_response(self):
"""Response with items and total count > items count."""
items = [PrintLogEntrySchema(id=i, status="completed", created_at=datetime.now()) for i in range(3)]
resp = PrintLogResponse(items=items, total=100)
assert len(resp.items) == 3
assert resp.total == 100
class TestWriteLogEntry:
"""Test the write_log_entry service function (logic only, no DB)."""
def test_duration_calculation(self):
"""Duration is computed from started_at and completed_at."""
started = datetime(2024, 1, 15, 10, 0, 0)
completed = started + timedelta(hours=2, minutes=30)
# Simulating the duration calculation from write_log_entry
duration = int((completed - started).total_seconds())
assert duration == 9000 # 2.5 hours = 9000 seconds
def test_duration_none_when_missing_times(self):
"""Duration is None when started_at or completed_at is missing."""
started = datetime(2024, 1, 15, 10, 0, 0)
completed_at = None
started_at = None
completed = datetime.now()
# No completed_at
duration = None
if started and completed_at:
duration = int((completed_at - started).total_seconds())
assert duration is None
# No started_at
duration = None
if started_at and completed:
duration = int((completed - started_at).total_seconds())
assert duration is None
+48
View File
@@ -369,6 +369,28 @@ export interface Archive {
created_by_username: string | null;
}
export interface PrintLogEntry {
id: number;
print_name: string | null;
printer_name: string | null;
printer_id: number | null;
status: string;
started_at: string | null;
completed_at: string | null;
duration_seconds: number | null;
filament_type: string | null;
filament_color: string | null;
filament_used_grams: number | null;
thumbnail_path: string | null;
created_by_username: string | null;
created_at: string;
}
export interface PrintLogResponse {
items: PrintLogEntry[];
total: number;
}
export interface ArchiveStats {
total_prints: number;
successful_prints: number;
@@ -2954,6 +2976,32 @@ export const api = {
return response.json();
},
// Print Log
getPrintLog: (params?: {
search?: string;
printerId?: number;
username?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
limit?: number;
offset?: number;
}) => {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.printerId) searchParams.set('printer_id', String(params.printerId));
if (params?.username) searchParams.set('created_by_username', params.username);
if (params?.status) searchParams.set('status', params.status);
if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
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));
return request<PrintLogResponse>(`/print-log/?${searchParams}`);
},
getPrintLogThumbnail: (id: number) => `${API_BASE}/print-log/${id}/thumbnail`,
clearPrintLog: () =>
request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
// Settings
getSettings: () => request<AppSettings>('/settings/'),
updateSettings: (data: AppSettingsUpdate) =>
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'Rasteransicht',
listView: 'Listenansicht',
calendarView: 'Kalenderansicht',
logView: 'Druckprotokoll',
manageTags: 'Tags verwalten',
showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
@@ -672,6 +673,34 @@ export default {
actions: 'Aktionen',
hasTimelapse: 'Hat Zeitraffer',
},
log: {
date: 'Datum',
printName: 'Druckname',
printer: 'Drucker',
user: 'Benutzer',
status: 'Status',
duration: 'Dauer',
filament: 'Filament',
allPrinters: 'Alle Drucker',
allUsers: 'Alle Benutzer',
allStatuses: 'Alle Status',
cancelled: 'Abgebrochen',
skipped: 'Übersprungen',
dateFrom: 'Von',
dateTo: 'Bis',
noEntries: 'Keine Druckprotokolleinträge gefunden',
showing: '{{count}} von {{total}} Einträgen',
rowsPerPage: 'Zeilen',
page: 'Seite',
prev: 'Zurück',
next: 'Weiter',
clearLog: 'Protokoll löschen',
clearLogTitle: 'Druckprotokoll löschen',
clearLogConfirm: 'Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?',
clearLogButton: 'Alle löschen',
cleared: '{{count}} Protokolleinträge gelöscht',
clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',
},
},
// Queue page
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'Grid view',
listView: 'List view',
calendarView: 'Calendar view',
logView: 'Print Log',
manageTags: 'Manage Tags',
showFailedPrints: 'Show failed prints',
hideFailedPrints: 'Hide failed prints',
@@ -672,6 +673,34 @@ export default {
actions: 'Actions',
hasTimelapse: 'Has timelapse',
},
log: {
date: 'Date',
printName: 'Print Name',
printer: 'Printer',
user: 'User',
status: 'Status',
duration: 'Duration',
filament: 'Filament',
allPrinters: 'All Printers',
allUsers: 'All Users',
allStatuses: 'All Statuses',
cancelled: 'Cancelled',
skipped: 'Skipped',
dateFrom: 'From',
dateTo: 'To',
noEntries: 'No print log entries found',
showing: 'Showing {{count}} of {{total}} entries',
rowsPerPage: 'Rows',
page: 'Page',
prev: 'Prev',
next: 'Next',
clearLog: 'Clear Log',
clearLogTitle: 'Clear Print Log',
clearLogConfirm: 'All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?',
clearLogButton: 'Clear All',
cleared: '{{count}} log entries cleared',
clearFailed: 'Failed to clear print log',
},
},
// Queue page
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'Grille',
listView: 'Liste',
calendarView: 'Calendrier',
logView: 'Journal d\'impression',
manageTags: 'Gérer les tags',
showFailedPrints: 'Afficher les échecs',
hideFailedPrints: 'Masquer les échecs',
@@ -672,6 +673,34 @@ export default {
actions: 'Actions',
hasTimelapse: 'A un timelapse',
},
log: {
date: 'Date',
printName: 'Nom de l\'impression',
printer: 'Imprimante',
user: 'Utilisateur',
status: 'Statut',
duration: 'Durée',
filament: 'Filament',
allPrinters: 'Toutes les imprimantes',
allUsers: 'Tous les utilisateurs',
allStatuses: 'Tous les statuts',
cancelled: 'Annulé',
skipped: 'Ignoré',
dateFrom: 'Du',
dateTo: 'Au',
noEntries: 'Aucune entrée de journal trouvée',
showing: '{{count}} sur {{total}} entrées',
rowsPerPage: 'Lignes',
page: 'Page',
prev: 'Préc.',
next: 'Suiv.',
clearLog: 'Effacer le journal',
clearLogTitle: 'Effacer le journal d\'impression',
clearLogConfirm: 'Toutes les entrées du journal d\'impression seront supprimées définitivement. Les archives et les éléments de file d\'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?',
clearLogButton: 'Tout effacer',
cleared: '{{count}} entrées de journal effacées',
clearFailed: 'Échec de l\'effacement du journal d\'impression',
},
},
// Queue page
+29
View File
@@ -475,6 +475,7 @@ export default {
gridView: 'Vista griglia',
listView: 'Vista elenco',
calendarView: 'Vista calendario',
logView: 'Registro stampe',
manageTags: 'Gestisci tag',
showFailedPrints: 'Mostra stampe fallite',
hideFailedPrints: 'Nascondi stampe fallite',
@@ -663,6 +664,34 @@ export default {
actions: 'Azioni',
hasTimelapse: 'Ha timelapse',
},
log: {
date: 'Data',
printName: 'Nome stampa',
printer: 'Stampante',
user: 'Utente',
status: 'Stato',
duration: 'Durata',
filament: 'Filamento',
allPrinters: 'Tutte le stampanti',
allUsers: 'Tutti gli utenti',
allStatuses: 'Tutti gli stati',
cancelled: 'Annullato',
skipped: 'Saltato',
dateFrom: 'Dal',
dateTo: 'Al',
noEntries: 'Nessuna voce di registro trovata',
showing: '{{count}} di {{total}} voci',
rowsPerPage: 'Righe',
page: 'Pagina',
prev: 'Prec.',
next: 'Succ.',
clearLog: 'Cancella registro',
clearLogTitle: 'Cancella registro stampe',
clearLogConfirm: 'Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?',
clearLogButton: 'Cancella tutto',
cleared: '{{count}} voci di registro cancellate',
clearFailed: 'Impossibile cancellare il registro stampe',
},
},
// Queue page
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'グリッド表示',
listView: 'リスト表示',
calendarView: 'カレンダー表示',
logView: '印刷ログ',
showFailedPrints: '失敗した印刷を表示',
hideFailedPrints: '失敗した印刷を非表示',
printTime: '印刷時間',
@@ -677,6 +678,34 @@ export default {
date: '日付',
actions: '操作',
},
log: {
date: '日時',
printName: '印刷名',
printer: 'プリンター',
user: 'ユーザー',
status: 'ステータス',
duration: '所要時間',
filament: 'フィラメント',
allPrinters: '全プリンター',
allUsers: '全ユーザー',
allStatuses: '全ステータス',
cancelled: 'キャンセル',
skipped: 'スキップ',
dateFrom: '開始日',
dateTo: '終了日',
noEntries: '印刷ログが見つかりません',
showing: '{{total}}件中{{count}}件を表示',
rowsPerPage: '行数',
page: 'ページ',
prev: '前へ',
next: '次へ',
clearLog: 'ログをクリア',
clearLogTitle: '印刷ログをクリア',
clearLogConfirm: 'すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか?',
clearLogButton: 'すべてクリア',
cleared: '{{count}}件のログエントリを削除しました',
clearFailed: '印刷ログの削除に失敗しました',
},
noPrinterAvailable: '利用可能なプリンターがありません',
archiveOrReprint: 'アーカイブまたは再印刷',
multiPrinterPrint: 'マルチプリンター印刷',
+354 -27
View File
@@ -46,6 +46,7 @@ import {
ChevronRight,
Settings,
User,
ClipboardList,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer, type SlicerType } from '../utils/slicer';
@@ -2064,7 +2065,7 @@ function ArchiveListRow({
}
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
type ViewMode = 'grid' | 'list' | 'calendar';
type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
@@ -2133,6 +2134,29 @@ export function ArchivesPage() {
const [showTagManagement, setShowTagManagement] = useState(false);
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
// Log view state
const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
localStorage.getItem('logFilterUser') || null
);
const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>
localStorage.getItem('logFilterStatus')
);
const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>
localStorage.getItem('logFilterDateFrom') || ''
);
const [logFilterDateTo, setLogFilterDateTo] = useState(() =>
localStorage.getItem('logFilterDateTo') || ''
);
const [logOffset, setLogOffset] = useState(() => {
const saved = localStorage.getItem('logOffset');
return saved ? Number(saved) : 0;
});
const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
const [logPageSize, setLogPageSize] = useState(() => {
const saved = localStorage.getItem('logPageSize');
return saved ? Number(saved) : 25;
});
// Clear highlight after 5 seconds and scroll to highlighted element
useEffect(() => {
if (highlightedArchiveId) {
@@ -2173,6 +2197,27 @@ export function ArchivesPage() {
queryFn: api.getSettings,
});
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: api.getUsers,
enabled: viewMode === 'log',
});
const { data: printLogData, isLoading: isLogLoading } = useQuery({
queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
queryFn: () => api.getPrintLog({
search: search || undefined,
printerId: filterPrinter || undefined,
username: logFilterUser || undefined,
status: logFilterStatus || undefined,
dateFrom: logFilterDateFrom || undefined,
dateTo: logFilterDateTo || undefined,
limit: logPageSize,
offset: logOffset,
}),
enabled: viewMode === 'log',
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
@@ -2191,6 +2236,18 @@ export function ArchivesPage() {
},
});
const clearLogMutation = useMutation({
mutationFn: () => api.clearPrintLog(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['print-log'] });
setLogOffset(0);
showToast(t('archives.log.cleared', { count: data.deleted }));
},
onError: () => {
showToast(t('archives.log.clearFailed'), 'error');
},
});
// Persist all filters to localStorage
useEffect(() => {
if (filterPrinter !== null) {
@@ -2248,6 +2305,47 @@ export function ArchivesPage() {
localStorage.setItem('archiveCollection', collection);
}, [collection]);
// Persist log view filters
useEffect(() => {
if (logFilterUser) {
localStorage.setItem('logFilterUser', logFilterUser);
} else {
localStorage.removeItem('logFilterUser');
}
}, [logFilterUser]);
useEffect(() => {
if (logFilterStatus) {
localStorage.setItem('logFilterStatus', logFilterStatus);
} else {
localStorage.removeItem('logFilterStatus');
}
}, [logFilterStatus]);
useEffect(() => {
if (logFilterDateFrom) {
localStorage.setItem('logFilterDateFrom', logFilterDateFrom);
} else {
localStorage.removeItem('logFilterDateFrom');
}
}, [logFilterDateFrom]);
useEffect(() => {
if (logFilterDateTo) {
localStorage.setItem('logFilterDateTo', logFilterDateTo);
} else {
localStorage.removeItem('logFilterDateTo');
}
}, [logFilterDateTo]);
useEffect(() => {
localStorage.setItem('logOffset', logOffset.toString());
}, [logOffset]);
useEffect(() => {
localStorage.setItem('logPageSize', logPageSize.toString());
}, [logPageSize]);
const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
// Extract unique materials and colors from archives
@@ -2672,8 +2770,40 @@ export function ArchivesPage() {
</div>
</div>
{/* Filters */}
<Card className="mb-6">
{/* View mode toggle — always visible */}
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4">
<button
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('grid')}
title={t('archives.gridView')}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('list')}
title={t('archives.listView')}
>
<List className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('calendar')}
title={t('archives.calendarView')}
>
<CalendarDays className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('log')}
title={t('archives.logView')}
>
<ClipboardList className="w-4 h-4" />
</button>
</div>
{/* Filters (hidden in log view which has its own filters) */}
{viewMode !== 'log' && <Card className="mb-6">
<CardContent className="py-4">
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
{/* Search - full width on mobile */}
@@ -2799,29 +2929,6 @@ export function ArchivesPage() {
<option value="size-asc">{t('archives.sortSmallest')}</option>
</select>
</div>
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
<button
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('grid')}
title={t('archives.gridView')}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('list')}
title={t('archives.listView')}
>
<List className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('calendar')}
title={t('archives.calendarView')}
>
<CalendarDays className="w-4 h-4" />
</button>
</div>
</div>
{hasTopFilters && (
<Button
@@ -2879,7 +2986,7 @@ export function ArchivesPage() {
</div>
)}
</CardContent>
</Card>
</Card>}
{/* Pending Uploads Panel (visible when in queue mode with pending files) */}
<PendingUploadsPanel />
@@ -2958,6 +3065,211 @@ export function ArchivesPage() {
))}
</div>
</Card>
) : viewMode === 'log' ? (
<div className="space-y-4">
{/* Log filters */}
<Card>
<CardContent className="py-3">
<div className="flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap">
{/* Search */}
<div className="flex-1 relative md:min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder={t('archives.searchPlaceholder')}
className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}
/>
</div>
{/* Printer filter */}
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={filterPrinter || ''}
onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}
>
<option value="">{t('archives.log.allPrinters')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{/* User filter */}
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterUser || ''}
onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}
>
<option value="">{t('archives.log.allUsers')}</option>
{users?.map((u) => (
<option key={u.id} value={u.username}>{u.username}</option>
))}
</select>
{/* Status filter */}
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterStatus || ''}
onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}
>
<option value="">{t('archives.log.allStatuses')}</option>
<option value="completed">{t('archives.status.completed')}</option>
<option value="failed">{t('archives.status.failed')}</option>
<option value="stopped">{t('archives.status.stopped')}</option>
<option value="cancelled">{t('archives.log.cancelled')}</option>
<option value="skipped">{t('archives.log.skipped')}</option>
</select>
{/* Date range */}
<div className="flex items-center gap-2">
<label className="text-sm text-bambu-gray">{t('archives.log.dateFrom')}</label>
<input
type="date"
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterDateFrom}
onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-bambu-gray">{t('archives.log.dateTo')}</label>
<input
type="date"
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterDateTo}
onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
/>
</div>
{/* Clear log button */}
<div className="ml-auto">
<Button
variant="danger"
size="sm"
onClick={() => setShowClearLogConfirm(true)}
disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}
>
<Trash2 className="w-4 h-4" />
{t('archives.log.clearLog')}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Log table */}
<Card>
{isLogLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : !printLogData?.items.length ? (
<div className="text-center py-12 text-bambu-gray">
{t('archives.log.noEntries')}
</div>
) : (
<>
<div className="overflow-x-auto">
<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>
</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 truncate max-w-[200px]">
{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-500/20 text-green-400' :
entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :
entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :
entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :
'bg-gray-500/20 text-gray-400'
}`}>
{entry.status}
</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 && (
<span
className="w-3 h-3 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}
/>
)}
<span className="text-bambu-gray-light text-xs">
{entry.filament_type || '—'}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2">
<div className="flex items-center gap-3">
<span className="text-sm text-bambu-gray">
{t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
</span>
<div className="flex items-center gap-1.5">
<label className="text-xs text-bambu-gray">{t('archives.log.rowsPerPage')}</label>
<select
className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
value={logPageSize}
onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">
{t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
</span>
<Button variant="secondary" size="sm" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</Card>
</div>
) : null}
{/* Upload Modal */}
@@ -3019,6 +3331,21 @@ export function ArchivesPage() {
{showTagManagement && (
<TagManagementModal onClose={() => setShowTagManagement(false)} />
)}
{/* Clear Log Confirmation */}
{showClearLogConfirm && (
<ConfirmModal
title={t('archives.log.clearLogTitle')}
message={t('archives.log.clearLogConfirm')}
confirmText={t('archives.log.clearLogButton')}
variant="danger"
onConfirm={() => {
clearLogMutation.mutate();
setShowClearLogConfirm(false);
}}
onCancel={() => setShowClearLogConfirm(false)}
/>
)}
</div>
);
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BwlOtO_N.js"></script>
<script type="module" crossorigin src="/assets/index-BKB_4Fk2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
</head>
<body>