mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -89,6 +89,7 @@ async def init_db():
|
||||
notification_template,
|
||||
orca_base_cache,
|
||||
pending_upload,
|
||||
print_log,
|
||||
print_queue,
|
||||
printer,
|
||||
project,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 'マルチプリンター印刷',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user