From d1518083fc538de8a862cba9cf2603603be25851 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 10 Dec 2025 17:06:43 +0000 Subject: [PATCH] ## New Features ### Projects / Print Grouping - Create projects to group related prints (e.g., "Voron Build" with 50 parts) - Track progress with target count and completion percentage - Assign archives to projects via edit modal or context menu - Project cards show archive thumbnails with clickable links - Color-coded project badges on archive cards - Filter and manage projects by status (active/completed/archived) ### Full-Text Search (FTS5) - SQLite FTS5 virtual table for efficient searching - Search across print_name, filename, tags, notes, designer, filament_type - Automatic index sync with triggers for INSERT/UPDATE/DELETE ### Webhooks & API Keys - API key authentication with granular permissions - Permissions: can_read_status, can_manage_queue, can_control_printer - Secure key generation with prefix display only after creation - Settings page API Keys tab for key management - Webhook endpoints for external integrations ### Failure Analysis - Dashboard widget showing failure rate with color coding - Correlate failures with conditions (filament type, printer, time) - Top failure reasons breakdown - Weekly trend visualization ### Archive Comparison - Select 2-5 archives to compare side-by-side - Highlight differences in print settings (yellow) - Success/failure correlation insights - Modal with close via button, X, Escape, or backdrop ### CSV/Excel Export - Export archives and statistics with current filters - Support for both CSV and Excel (.xlsx) formats - openpyxl dependency added ## Bug Fixes - Fixed context menu submenu not showing (removed overflow-hidden) - Fixed project card thumbnails using correct API endpoint - Fixed EditArchiveModal to invalidate projects query on save - Fixed clipboard API fallback for HTTP contexts - Fixed archive PATCH 500 error (FTS5 index rebuild) - Fixed FastAPI trailing slash routing for projects endpoint ## UI Improvements - Context menu submenu with hover/click support - Project badge on archive cards with project color - "Go to Project" context menu item for assigned archives - Clickable project card thumbnails linking to archives - Reset Layout button moved to Stats page header --- README.md | 13 +- backend/app/api/routes/api_keys.py | 138 + backend/app/api/routes/archives.py | 326 +- backend/app/api/routes/projects.py | 428 + backend/app/api/routes/webhook.py | 332 + backend/app/core/auth.py | 114 + backend/app/core/database.py | 68 +- backend/app/main.py | 5 +- backend/app/models/__init__.py | 4 + backend/app/models/api_key.py | 29 + backend/app/models/archive.py | 5 + backend/app/models/print_queue.py | 5 + backend/app/models/project.py | 32 + backend/app/schemas/api_key.py | 46 + backend/app/schemas/archive.py | 3 + backend/app/schemas/project.py | 85 + backend/app/services/archive.py | 12 +- backend/app/services/archive_comparison.py | 278 + backend/app/services/export.py | 335 + backend/app/services/failure_analysis.py | 198 + frontend/public/manifest.json | 7 + frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 328 +- .../src/components/CompareArchivesModal.tsx | 190 + frontend/src/components/ContextMenu.tsx | 109 +- frontend/src/components/Dashboard.tsx | 37 +- frontend/src/components/EditArchiveModal.tsx | 30 +- frontend/src/components/Layout.tsx | 3 +- frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/pages/ArchivesPage.tsx | 198 +- frontend/src/pages/ProjectsPage.tsx | 453 + frontend/src/pages/SettingsPage.tsx | 340 +- frontend/src/pages/StatsPage.tsx | 165 +- requirements.txt | 3 + static/assets/index-BN5iZvNL.js | 8611 ----------------- static/assets/index-Bwdh7UG9.css | 1 + static/assets/index-Dm9m4fYz.css | 1 - static/assets/index-Y4EG-tDv.js | 8611 +++++++++++++++++ static/index.html | 4 +- static/manifest.json | 7 + 41 files changed, 12888 insertions(+), 8670 deletions(-) create mode 100644 backend/app/api/routes/api_keys.py create mode 100644 backend/app/api/routes/projects.py create mode 100644 backend/app/api/routes/webhook.py create mode 100644 backend/app/core/auth.py create mode 100644 backend/app/models/api_key.py create mode 100644 backend/app/models/project.py create mode 100644 backend/app/schemas/api_key.py create mode 100644 backend/app/schemas/project.py create mode 100644 backend/app/services/archive_comparison.py create mode 100644 backend/app/services/export.py create mode 100644 backend/app/services/failure_analysis.py create mode 100644 frontend/src/components/CompareArchivesModal.tsx create mode 100644 frontend/src/pages/ProjectsPage.tsx delete mode 100644 static/assets/index-BN5iZvNL.js create mode 100644 static/assets/index-Bwdh7UG9.css delete mode 100644 static/assets/index-Dm9m4fYz.css create mode 100644 static/assets/index-Y4EG-tDv.js diff --git a/README.md b/README.md index 0df94a479..10d3883c9 100644 --- a/README.md +++ b/README.md @@ -45,16 +45,18 @@ ### 📦 Print Archive - Automatic 3MF archiving with metadata - 3D model preview (Three.js) -- Duplicate detection +- Duplicate detection & full-text search - Photo attachments & failure analysis - Re-print to any connected printer +- Archive comparison (side-by-side diff) ### 📊 Monitoring & Stats - Real-time printer status via WebSocket - HMS error monitoring - Print success rates & trends - Filament usage tracking -- Cost analytics +- Cost analytics & failure analysis +- CSV/Excel export ### ⏰ Scheduling & Automation - Print queue with drag-and-drop @@ -63,6 +65,12 @@ - Auto power-on before print - Auto power-off after cooldown +### 📁 Projects +- Group related prints (e.g., "Voron Build") +- Track progress with target counts +- Color-coded project badges +- Assign archives via context menu + @@ -78,6 +86,7 @@ - Bambu Cloud profile management - K-profiles (pressure advance) - External sidebar links +- Webhooks & API keys ### 🛠️ Maintenance - Maintenance scheduling & tracking diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py new file mode 100644 index 000000000..eb3a2493f --- /dev/null +++ b/backend/app/api/routes/api_keys.py @@ -0,0 +1,138 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.core.auth import generate_api_key +from backend.app.models.api_key import APIKey +from backend.app.schemas.api_key import ( + APIKeyCreate, + APIKeyUpdate, + APIKeyResponse, + APIKeyCreateResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api-keys", tags=["api-keys"]) + + +@router.get("/", response_model=list[APIKeyResponse]) +async def list_api_keys(db: AsyncSession = Depends(get_db)): + """List all API keys (without full key values).""" + result = await db.execute( + select(APIKey).order_by(APIKey.created_at.desc()) + ) + return list(result.scalars().all()) + + +@router.post("/", response_model=APIKeyCreateResponse) +async def create_api_key( + data: APIKeyCreate, + db: AsyncSession = Depends(get_db), +): + """Create a new API key. + + IMPORTANT: The full API key is only returned in this response. + Store it securely - it cannot be retrieved again. + """ + # Generate the key + full_key, key_hash, key_prefix = generate_api_key() + + api_key = APIKey( + name=data.name, + key_hash=key_hash, + key_prefix=key_prefix, + can_queue=data.can_queue, + can_control_printer=data.can_control_printer, + can_read_status=data.can_read_status, + printer_ids=data.printer_ids, + expires_at=data.expires_at, + ) + db.add(api_key) + await db.flush() + await db.refresh(api_key) + + # Return with full key (only time it's shown) + return APIKeyCreateResponse( + id=api_key.id, + name=api_key.name, + key_prefix=api_key.key_prefix, + key=full_key, # Only returned on creation + can_queue=api_key.can_queue, + can_control_printer=api_key.can_control_printer, + can_read_status=api_key.can_read_status, + printer_ids=api_key.printer_ids, + enabled=api_key.enabled, + last_used=api_key.last_used, + created_at=api_key.created_at, + expires_at=api_key.expires_at, + ) + + +@router.get("/{key_id}", response_model=APIKeyResponse) +async def get_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), +): + """Get an API key by ID.""" + result = await db.execute(select(APIKey).where(APIKey.id == key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + + return api_key + + +@router.patch("/{key_id}", response_model=APIKeyResponse) +async def update_api_key( + key_id: int, + data: APIKeyUpdate, + db: AsyncSession = Depends(get_db), +): + """Update an API key.""" + result = await db.execute(select(APIKey).where(APIKey.id == key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + + # Update fields if provided + if data.name is not None: + api_key.name = data.name + if data.can_queue is not None: + api_key.can_queue = data.can_queue + if data.can_control_printer is not None: + api_key.can_control_printer = data.can_control_printer + if data.can_read_status is not None: + api_key.can_read_status = data.can_read_status + if data.printer_ids is not None: + api_key.printer_ids = data.printer_ids + if data.enabled is not None: + api_key.enabled = data.enabled + if data.expires_at is not None: + api_key.expires_at = data.expires_at + + await db.flush() + await db.refresh(api_key) + + return api_key + + +@router.delete("/{key_id}") +async def delete_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), +): + """Delete (revoke) an API key.""" + result = await db.execute(select(APIKey).where(APIKey.id == key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + + await db.delete(api_key) + + return {"message": "API key deleted"} diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 52704e263..deec190ee 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -54,6 +54,8 @@ def archive_to_response( data = { "id": archive.id, "printer_id": archive.printer_id, + "project_id": archive.project_id, + "project_name": archive.project.name if archive.project else None, "filename": archive.filename, "file_path": archive.file_path, "file_size": archive.file_size, @@ -98,6 +100,7 @@ def archive_to_response( @router.get("/", response_model=list[ArchiveResponse]) async def list_archives( printer_id: int | None = None, + project_id: int | None = None, limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db), @@ -106,6 +109,7 @@ async def list_archives( service = ArchiveService(db) archives = await service.list_archives( printer_id=printer_id, + project_id=project_id, limit=limit, offset=offset, ) @@ -121,6 +125,286 @@ async def list_archives( return result +@router.get("/search", response_model=list[ArchiveResponse]) +async def search_archives( + q: str = Query(..., min_length=2, description="Search query"), + printer_id: int | None = None, + project_id: int | None = None, + status: str | None = None, + limit: int = 50, + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + """Full-text search across archives. + + Searches print_name, filename, tags, notes, designer, and filament_type fields. + Supports partial matches with wildcards (e.g., 'vor*' matches 'voron'). + """ + from sqlalchemy import text + from sqlalchemy.orm import selectinload + + # Prepare search query - add wildcard for partial matches + search_term = q.strip() + if not search_term.endswith('*'): + search_term = f"{search_term}*" + + # Build the FTS query + # Using MATCH for FTS5 full-text search + fts_query = text(""" + SELECT rowid FROM archive_fts + WHERE archive_fts MATCH :search_term + ORDER BY rank + LIMIT :limit OFFSET :offset + """) + + try: + result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0}) + matched_ids = [row[0] for row in result.fetchall()] + except Exception as e: + logger.warning(f"FTS search failed, falling back to LIKE search: {e}") + # Fallback to LIKE search if FTS fails + like_pattern = f"%{q}%" + query = ( + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .where( + (PrintArchive.print_name.ilike(like_pattern)) | + (PrintArchive.filename.ilike(like_pattern)) | + (PrintArchive.tags.ilike(like_pattern)) | + (PrintArchive.notes.ilike(like_pattern)) | + (PrintArchive.designer.ilike(like_pattern)) | + (PrintArchive.filament_type.ilike(like_pattern)) + ) + .order_by(PrintArchive.created_at.desc()) + ) + + if printer_id: + query = query.where(PrintArchive.printer_id == printer_id) + if project_id: + query = query.where(PrintArchive.project_id == project_id) + if status: + query = query.where(PrintArchive.status == status) + + query = query.limit(limit).offset(offset) + result = await db.execute(query) + archives = result.scalars().all() + return [archive_to_response(a) for a in archives] + + if not matched_ids: + return [] + + # Fetch full archive records for matched IDs + query = ( + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .where(PrintArchive.id.in_(matched_ids)) + ) + + # Apply additional filters + if printer_id: + query = query.where(PrintArchive.printer_id == printer_id) + if project_id: + query = query.where(PrintArchive.project_id == project_id) + if status: + query = query.where(PrintArchive.status == status) + + result = await db.execute(query) + archives_dict = {a.id: a for a in result.scalars().all()} + + # Preserve FTS ranking order and apply pagination + ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict] + paginated = ordered_archives[offset:offset + limit] + + return [archive_to_response(a) for a in paginated] + + +@router.post("/search/rebuild-index") +async def rebuild_search_index(db: AsyncSession = Depends(get_db)): + """Rebuild the full-text search index from existing archives. + + Use this if search results seem incomplete or incorrect. + """ + from sqlalchemy import text + + try: + # Clear and rebuild the FTS index + await db.execute(text("DELETE FROM archive_fts")) + + # Repopulate from print_archives + await db.execute(text(""" + INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type) + SELECT id, print_name, filename, tags, notes, designer, filament_type + FROM print_archives + """)) + + await db.commit() + + # Count entries + result = await db.execute(text("SELECT COUNT(*) FROM archive_fts")) + count = result.scalar() or 0 + + return {"message": f"Search index rebuilt with {count} entries"} + except Exception as e: + logger.error(f"Failed to rebuild search index: {e}") + raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}") + + +@router.get("/analysis/failures") +async def analyze_failures( + days: int = 30, + printer_id: int | None = None, + project_id: int | None = None, + db: AsyncSession = Depends(get_db), +): + """Analyze failure patterns across prints. + + Returns failure statistics including: + - Overall failure rate + - Failures by reason, filament type, printer + - Time of day distribution + - Recent failures + - Weekly trend + """ + from backend.app.services.failure_analysis import FailureAnalysisService + + service = FailureAnalysisService(db) + return await service.analyze_failures( + days=days, + printer_id=printer_id, + project_id=project_id, + ) + + +@router.get("/compare") +async def compare_archives( + archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"), + db: AsyncSession = Depends(get_db), +): + """Compare multiple archives side by side. + + Compares print settings, filament usage, and print times. + Also analyzes correlation between settings and success/failure. + + Args: + archive_ids: Comma-separated list of 2-5 archive IDs to compare + """ + from backend.app.services.archive_comparison import ArchiveComparisonService + + # Parse and validate archive IDs + try: + ids = [int(id.strip()) for id in archive_ids.split(",")] + except ValueError: + raise HTTPException(400, "Invalid archive IDs format") + + if len(ids) < 2: + raise HTTPException(400, "At least 2 archives required for comparison") + if len(ids) > 5: + raise HTTPException(400, "Maximum 5 archives can be compared at once") + + service = ArchiveComparisonService(db) + try: + return await service.compare_archives(ids) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@router.get("/export") +async def export_archives( + format: str = Query("csv", description="Export format: csv or xlsx"), + fields: str | None = Query(None, description="Comma-separated field names"), + printer_id: int | None = None, + project_id: int | None = None, + status: str | None = None, + date_from: str | None = Query(None, description="Start date (ISO format)"), + date_to: str | None = Query(None, description="End date (ISO format)"), + search: str | None = None, + db: AsyncSession = Depends(get_db), +): + """Export archives to CSV or Excel format. + + Returns a downloadable file with archive data. + """ + from datetime import datetime + from fastapi.responses import StreamingResponse + from backend.app.services.export import ExportService + + if format not in ("csv", "xlsx"): + raise HTTPException(400, "Format must be 'csv' or 'xlsx'") + + # Parse fields + field_list = None + if fields: + field_list = [f.strip() for f in fields.split(",")] + + # Parse dates + date_from_dt = None + date_to_dt = None + if date_from: + try: + date_from_dt = datetime.fromisoformat(date_from) + except ValueError: + raise HTTPException(400, "Invalid date_from format") + if date_to: + try: + date_to_dt = datetime.fromisoformat(date_to) + except ValueError: + raise HTTPException(400, "Invalid date_to format") + + service = ExportService(db) + try: + file_bytes, filename, content_type = await service.export_archives( + format=format, + fields=field_list, + printer_id=printer_id, + project_id=project_id, + status=status, + date_from=date_from_dt, + date_to=date_to_dt, + search=search, + ) + except ImportError as e: + raise HTTPException(500, str(e)) + + return StreamingResponse( + io.BytesIO(file_bytes), + media_type=content_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/stats/export") +async def export_stats( + format: str = Query("csv", description="Export format: csv or xlsx"), + days: int = 30, + printer_id: int | None = None, + project_id: int | None = None, + db: AsyncSession = Depends(get_db), +): + """Export statistics summary to CSV or Excel format.""" + from fastapi.responses import StreamingResponse + from backend.app.services.export import ExportService + + if format not in ("csv", "xlsx"): + raise HTTPException(400, "Format must be 'csv' or 'xlsx'") + + service = ExportService(db) + try: + file_bytes, filename, content_type = await service.export_stats( + format=format, + days=days, + printer_id=printer_id, + project_id=project_id, + ) + except ImportError as e: + raise HTTPException(500, str(e)) + + return StreamingResponse( + io.BytesIO(file_bytes), + media_type=content_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.get("/stats", response_model=ArchiveStats) async def get_archive_stats(db: AsyncSession = Depends(get_db)): """Get statistics across all archives.""" @@ -279,15 +563,41 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): return archive_to_response(archive, duplicates) +@router.get("/{archive_id}/similar") +async def find_similar_archives( + archive_id: int, + limit: int = 10, + db: AsyncSession = Depends(get_db), +): + """Find archives with similar settings for comparison. + + Returns archives that match by: + - Same print name (highest priority) + - Same file content hash + - Same filament type + """ + from backend.app.services.archive_comparison import ArchiveComparisonService + + service = ArchiveComparisonService(db) + try: + return await service.find_similar_archives(archive_id, limit=limit) + except ValueError as e: + raise HTTPException(404, str(e)) + + @router.patch("/{archive_id}", response_model=ArchiveResponse) async def update_archive( archive_id: int, update_data: ArchiveUpdate, db: AsyncSession = Depends(get_db), ): - """Update archive metadata (tags, notes, cost, is_favorite).""" + """Update archive metadata (tags, notes, cost, is_favorite, project_id).""" + from sqlalchemy.orm import selectinload + result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .where(PrintArchive.id == archive_id) ) archive = result.scalar_one_or_none() if not archive: @@ -297,8 +607,16 @@ async def update_archive( setattr(archive, field, value) await db.commit() - await db.refresh(archive) - return archive + + # Re-fetch with project relationship loaded after commit + result = await db.execute( + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + + return archive_to_response(archive) @router.post("/{archive_id}/favorite", response_model=ArchiveResponse) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py new file mode 100644 index 000000000..ac360360e --- /dev/null +++ b/backend/app/api/routes/projects.py @@ -0,0 +1,428 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from backend.app.core.database import get_db +from backend.app.models.project import Project +from backend.app.models.archive import PrintArchive +from backend.app.models.print_queue import PrintQueueItem +from backend.app.schemas.project import ( + ProjectCreate, + ProjectUpdate, + ProjectResponse, + ProjectListResponse, + ProjectStats, + BatchAddArchives, + BatchAddQueueItems, + ArchivePreview, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +async def compute_project_stats( + db: AsyncSession, project_id: int, target_count: int | None = None +) -> ProjectStats: + """Compute statistics for a project.""" + # Count total archives + total_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id) + ) + total_archives = total_result.scalar() or 0 + + # Count completed archives + completed_result = await db.execute( + select(func.count(PrintArchive.id)).where( + PrintArchive.project_id == project_id, + PrintArchive.status == "completed" + ) + ) + completed_prints = completed_result.scalar() or 0 + + # Count failed archives + failed_result = await db.execute( + select(func.count(PrintArchive.id)).where( + PrintArchive.project_id == project_id, + PrintArchive.status == "failed" + ) + ) + failed_prints = failed_result.scalar() or 0 + + # Sum print time and filament + sums_result = await db.execute( + select( + func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"), + func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"), + ).where(PrintArchive.project_id == project_id) + ) + sums = sums_result.first() + + # Count queued items + queued_result = await db.execute( + select(func.count(PrintQueueItem.id)).where( + PrintQueueItem.project_id == project_id, + PrintQueueItem.status == "pending" + ) + ) + queued_prints = queued_result.scalar() or 0 + + # Count in-progress items + in_progress_result = await db.execute( + select(func.count(PrintQueueItem.id)).where( + PrintQueueItem.project_id == project_id, + PrintQueueItem.status == "printing" + ) + ) + in_progress_prints = in_progress_result.scalar() or 0 + + # Calculate progress + progress_percent = None + if target_count and target_count > 0: + progress_percent = round((completed_prints / target_count) * 100, 1) + + return ProjectStats( + total_archives=total_archives, + completed_prints=completed_prints, + failed_prints=failed_prints, + queued_prints=queued_prints, + in_progress_prints=in_progress_prints, + total_print_time_hours=round((sums.total_time or 0) / 3600, 2), + total_filament_grams=round(sums.total_filament or 0, 2), + progress_percent=progress_percent, + ) + + +@router.get("", response_model=list[ProjectListResponse]) +@router.get("/", response_model=list[ProjectListResponse]) +async def list_projects( + status: str | None = None, + db: AsyncSession = Depends(get_db), +): + """List all projects with basic stats.""" + query = select(Project) + if status: + query = query.where(Project.status == status) + query = query.order_by(Project.updated_at.desc()) + + result = await db.execute(query) + projects = result.scalars().all() + + # Compute quick stats for each project + response = [] + for project in projects: + # Get archive count + archive_count_result = await db.execute( + select(func.count(PrintArchive.id)).where( + PrintArchive.project_id == project.id + ) + ) + archive_count = archive_count_result.scalar() or 0 + + # Get queue count + queue_count_result = await db.execute( + select(func.count(PrintQueueItem.id)).where( + PrintQueueItem.project_id == project.id, + PrintQueueItem.status.in_(["pending", "printing"]), + ) + ) + queue_count = queue_count_result.scalar() or 0 + + # Get completed count for progress + completed_result = await db.execute( + select(func.count(PrintArchive.id)).where( + PrintArchive.project_id == project.id, + PrintArchive.status == "completed", + ) + ) + completed_count = completed_result.scalar() or 0 + + progress_percent = None + if project.target_count and project.target_count > 0: + progress_percent = round((completed_count / project.target_count) * 100, 1) + + # Get archive previews (up to 6 most recent) + archives_result = await db.execute( + select(PrintArchive) + .where(PrintArchive.project_id == project.id) + .order_by(PrintArchive.created_at.desc()) + .limit(6) + ) + archives = archives_result.scalars().all() + archive_previews = [ + ArchivePreview( + id=a.id, + print_name=a.print_name, + thumbnail_path=a.thumbnail_path, + status=a.status, + ) + for a in archives + ] + + response.append( + ProjectListResponse( + id=project.id, + name=project.name, + description=project.description, + color=project.color, + status=project.status, + target_count=project.target_count, + created_at=project.created_at, + archive_count=archive_count, + queue_count=queue_count, + progress_percent=progress_percent, + archives=archive_previews, + ) + ) + + return response + + +@router.post("/", response_model=ProjectResponse) +async def create_project( + data: ProjectCreate, + db: AsyncSession = Depends(get_db), +): + """Create a new project.""" + project = Project( + name=data.name, + description=data.description, + color=data.color, + target_count=data.target_count, + ) + db.add(project) + await db.flush() + await db.refresh(project) + + stats = await compute_project_stats(db, project.id, project.target_count) + + return ProjectResponse( + id=project.id, + name=project.name, + description=project.description, + color=project.color, + status=project.status, + target_count=project.target_count, + created_at=project.created_at, + updated_at=project.updated_at, + stats=stats, + ) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + project_id: int, + db: AsyncSession = Depends(get_db), +): + """Get a project by ID with detailed stats.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + stats = await compute_project_stats(db, project.id, project.target_count) + + return ProjectResponse( + id=project.id, + name=project.name, + description=project.description, + color=project.color, + status=project.status, + target_count=project.target_count, + created_at=project.created_at, + updated_at=project.updated_at, + stats=stats, + ) + + +@router.patch("/{project_id}", response_model=ProjectResponse) +async def update_project( + project_id: int, + data: ProjectUpdate, + db: AsyncSession = Depends(get_db), +): + """Update a project.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Update fields if provided + if data.name is not None: + project.name = data.name + if data.description is not None: + project.description = data.description + if data.color is not None: + project.color = data.color + if data.status is not None: + if data.status not in ["active", "completed", "archived"]: + raise HTTPException(status_code=400, detail="Invalid status") + project.status = data.status + if data.target_count is not None: + project.target_count = data.target_count + + await db.flush() + await db.refresh(project) + + stats = await compute_project_stats(db, project.id, project.target_count) + + return ProjectResponse( + id=project.id, + name=project.name, + description=project.description, + color=project.color, + status=project.status, + target_count=project.target_count, + created_at=project.created_at, + updated_at=project.updated_at, + stats=stats, + ) + + +@router.delete("/{project_id}") +async def delete_project( + project_id: int, + db: AsyncSession = Depends(get_db), +): + """Delete a project. Archives and queue items will have project_id set to NULL.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + await db.delete(project) + + return {"message": "Project deleted"} + + +@router.get("/{project_id}/archives") +async def list_project_archives( + project_id: int, + limit: int = 100, + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + """List archives in a project.""" + # Verify project exists + result = await db.execute(select(Project).where(Project.id == project_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + + # Get archives + query = ( + select(PrintArchive) + .where(PrintArchive.project_id == project_id) + .order_by(PrintArchive.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await db.execute(query) + archives = result.scalars().all() + + # Import the response converter from archives module + from backend.app.api.routes.archives import archive_to_response + + return [archive_to_response(a) for a in archives] + + +@router.get("/{project_id}/queue") +async def list_project_queue( + project_id: int, + db: AsyncSession = Depends(get_db), +): + """List queue items in a project.""" + # Verify project exists + result = await db.execute(select(Project).where(Project.id == project_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + + # Get queue items + query = ( + select(PrintQueueItem) + .where(PrintQueueItem.project_id == project_id) + .order_by(PrintQueueItem.position) + ) + result = await db.execute(query) + items = result.scalars().all() + + return items + + +@router.post("/{project_id}/add-archives") +async def add_archives_to_project( + project_id: int, + data: BatchAddArchives, + db: AsyncSession = Depends(get_db), +): + """Batch add archives to a project.""" + # Verify project exists + result = await db.execute(select(Project).where(Project.id == project_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + + # Update archives + updated = 0 + for archive_id in data.archive_ids: + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if archive: + archive.project_id = project_id + updated += 1 + + return {"message": f"Added {updated} archives to project"} + + +@router.post("/{project_id}/add-queue") +async def add_queue_items_to_project( + project_id: int, + data: BatchAddQueueItems, + db: AsyncSession = Depends(get_db), +): + """Batch add queue items to a project.""" + # Verify project exists + result = await db.execute(select(Project).where(Project.id == project_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + + # Update queue items + updated = 0 + for item_id in data.queue_item_ids: + result = await db.execute( + select(PrintQueueItem).where(PrintQueueItem.id == item_id) + ) + item = result.scalar_one_or_none() + if item: + item.project_id = project_id + updated += 1 + + return {"message": f"Added {updated} queue items to project"} + + +@router.post("/{project_id}/remove-archives") +async def remove_archives_from_project( + project_id: int, + data: BatchAddArchives, + db: AsyncSession = Depends(get_db), +): + """Remove archives from a project (sets project_id to NULL).""" + updated = 0 + for archive_id in data.archive_ids: + result = await db.execute( + select(PrintArchive).where( + PrintArchive.id == archive_id, + PrintArchive.project_id == project_id, + ) + ) + archive = result.scalar_one_or_none() + if archive: + archive.project_id = None + updated += 1 + + return {"message": f"Removed {updated} archives from project"} diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py new file mode 100644 index 000000000..06c104935 --- /dev/null +++ b/backend/app/api/routes/webhook.py @@ -0,0 +1,332 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel + +from backend.app.core.database import get_db +from backend.app.core.auth import get_api_key, check_permission, check_printer_access +from backend.app.models.api_key import APIKey +from backend.app.models.archive import PrintArchive +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.printer import Printer +from backend.app.services.printer_manager import printer_manager + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/webhook", tags=["webhook"]) + + +# Request schemas +class QueueAddRequest(BaseModel): + archive_id: int + printer_id: int + project_id: int | None = None + scheduled_time: str | None = None # ISO format datetime + require_previous_success: bool = False + auto_off_after: bool = False + + +class QueueAddResponse(BaseModel): + id: int + archive_id: int + printer_id: int + position: int + status: str + message: str + + +class PrinterStatusResponse(BaseModel): + id: int + name: str + connected: bool + state: str | None + current_print: str | None + progress: float | None + remaining_time: int | None + + +class QueueStatusResponse(BaseModel): + printer_id: int + printer_name: str + pending: int + printing: int + items: list[dict] + + +# Webhook endpoints + +@router.post("/queue/add", response_model=QueueAddResponse) +async def webhook_add_to_queue( + data: QueueAddRequest, + api_key: APIKey = Depends(get_api_key), + db: AsyncSession = Depends(get_db), +): + """Add a print to the queue via webhook. + + Requires 'can_queue' permission. + """ + check_permission(api_key, 'queue') + check_printer_access(api_key, data.printer_id) + + # Verify archive exists + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == data.archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(status_code=404, detail="Archive not found") + + # Verify printer exists + result = await db.execute( + select(Printer).where(Printer.id == data.printer_id) + ) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + + # Get next position + result = await db.execute( + select(PrintQueueItem.position) + .where( + PrintQueueItem.printer_id == data.printer_id, + PrintQueueItem.status == "pending", + ) + .order_by(PrintQueueItem.position.desc()) + .limit(1) + ) + max_position = result.scalar() + next_position = (max_position or 0) + 1 + + # Parse scheduled time if provided + scheduled_time = None + if data.scheduled_time: + from datetime import datetime + try: + scheduled_time = datetime.fromisoformat(data.scheduled_time.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid scheduled_time format") + + # Create queue item + queue_item = PrintQueueItem( + printer_id=data.printer_id, + archive_id=data.archive_id, + project_id=data.project_id, + position=next_position, + scheduled_time=scheduled_time, + require_previous_success=data.require_previous_success, + auto_off_after=data.auto_off_after, + ) + db.add(queue_item) + await db.flush() + await db.refresh(queue_item) + + return QueueAddResponse( + id=queue_item.id, + archive_id=queue_item.archive_id, + printer_id=queue_item.printer_id, + position=queue_item.position, + status=queue_item.status, + message=f"Added to queue at position {queue_item.position}", + ) + + +@router.post("/printer/{printer_id}/start") +async def webhook_start_print( + printer_id: int, + api_key: APIKey = Depends(get_api_key), + db: AsyncSession = Depends(get_db), +): + """Start the next queued print on a printer. + + Requires 'can_control_printer' permission. + """ + check_permission(api_key, 'control_printer') + check_printer_access(api_key, printer_id) + + # Get printer + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + + # Get next pending queue item + result = await db.execute( + select(PrintQueueItem) + .where( + PrintQueueItem.printer_id == printer_id, + PrintQueueItem.status == "pending", + ) + .order_by(PrintQueueItem.position) + .limit(1) + ) + queue_item = result.scalar_one_or_none() + if not queue_item: + raise HTTPException(status_code=404, detail="No pending prints in queue") + + # Check if printer is ready + status = printer_manager.get_status(printer_id) + if not status or not status.get("connected"): + raise HTTPException(status_code=503, detail="Printer not connected") + + if status.get("state") not in ["IDLE", "FINISH", "FAILED"]: + raise HTTPException( + status_code=409, + detail=f"Printer is busy (state: {status.get('state')})" + ) + + # Start the print + try: + await printer_manager.start_print(printer_id, queue_item.archive_id) + except Exception as e: + logger.error(f"Failed to start print: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return {"message": "Print started", "queue_item_id": queue_item.id} + + +@router.post("/printer/{printer_id}/stop") +async def webhook_stop_print( + printer_id: int, + api_key: APIKey = Depends(get_api_key), +): + """Stop the current print on a printer. + + Requires 'can_control_printer' permission. + """ + check_permission(api_key, 'control_printer') + check_printer_access(api_key, printer_id) + + status = printer_manager.get_status(printer_id) + if not status or not status.get("connected"): + raise HTTPException(status_code=503, detail="Printer not connected") + + if status.get("state") != "RUNNING": + raise HTTPException(status_code=409, detail="No print in progress") + + try: + await printer_manager.stop_print(printer_id) + except Exception as e: + logger.error(f"Failed to stop print: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return {"message": "Print stopped"} + + +@router.post("/printer/{printer_id}/cancel") +async def webhook_cancel_print( + printer_id: int, + api_key: APIKey = Depends(get_api_key), +): + """Cancel the current print on a printer. + + Requires 'can_control_printer' permission. + """ + check_permission(api_key, 'control_printer') + check_printer_access(api_key, printer_id) + + status = printer_manager.get_status(printer_id) + if not status or not status.get("connected"): + raise HTTPException(status_code=503, detail="Printer not connected") + + if status.get("state") not in ["RUNNING", "PAUSE"]: + raise HTTPException(status_code=409, detail="No print to cancel") + + try: + await printer_manager.cancel_print(printer_id) + except Exception as e: + logger.error(f"Failed to cancel print: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return {"message": "Print cancelled"} + + +@router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse) +async def webhook_get_printer_status( + printer_id: int, + api_key: APIKey = Depends(get_api_key), + db: AsyncSession = Depends(get_db), +): + """Get status of a printer. + + Requires 'can_read_status' permission. + """ + check_permission(api_key, 'read_status') + check_printer_access(api_key, printer_id) + + # Get printer + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + + status = printer_manager.get_status(printer_id) + + return PrinterStatusResponse( + id=printer.id, + name=printer.name, + connected=status.get("connected", False) if status else False, + state=status.get("state") if status else None, + current_print=status.get("current_print") if status else None, + progress=status.get("progress") if status else None, + remaining_time=status.get("remaining_time") if status else None, + ) + + +@router.get("/queue", response_model=list[QueueStatusResponse]) +async def webhook_get_queue_status( + printer_id: int | None = None, + api_key: APIKey = Depends(get_api_key), + db: AsyncSession = Depends(get_db), +): + """Get queue status for all printers or a specific printer. + + Requires 'can_read_status' permission. + """ + check_permission(api_key, 'read_status') + + # Get printers + if printer_id: + check_printer_access(api_key, printer_id) + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printers = result.scalars().all() + else: + result = await db.execute(select(Printer)) + printers = result.scalars().all() + # Filter by allowed printers if limited + if api_key.printer_ids: + printers = [p for p in printers if p.id in api_key.printer_ids] + + response = [] + for printer in printers: + # Get queue items + result = await db.execute( + select(PrintQueueItem) + .where( + PrintQueueItem.printer_id == printer.id, + PrintQueueItem.status.in_(["pending", "printing"]), + ) + .order_by(PrintQueueItem.position) + ) + items = result.scalars().all() + + pending_count = sum(1 for i in items if i.status == "pending") + printing_count = sum(1 for i in items if i.status == "printing") + + response.append(QueueStatusResponse( + printer_id=printer.id, + printer_name=printer.name, + pending=pending_count, + printing=printing_count, + items=[ + { + "id": item.id, + "archive_id": item.archive_id, + "position": item.position, + "status": item.status, + } + for item in items + ], + )) + + return response diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 000000000..9ca4b721d --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,114 @@ +import hashlib +import secrets +from datetime import datetime +from typing import Optional + +from fastapi import Header, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.models.api_key import APIKey + + +def generate_api_key() -> tuple[str, str, str]: + """Generate a new API key. + + Returns: + Tuple of (full_key, key_hash, key_prefix) + """ + # Generate a random 32-byte key and encode as hex (64 chars) + full_key = f"bb_{secrets.token_hex(32)}" + key_hash = hashlib.sha256(full_key.encode()).hexdigest() + key_prefix = full_key[:11] # "bb_" + first 8 chars of token + return full_key, key_hash, key_prefix + + +def hash_api_key(key: str) -> str: + """Hash an API key for comparison.""" + return hashlib.sha256(key.encode()).hexdigest() + + +async def get_api_key( + x_api_key: str = Header(..., alias="X-API-Key"), + db: AsyncSession = Depends(get_db), +) -> APIKey: + """Verify API key and return the key record. + + Raises HTTPException if key is invalid, disabled, or expired. + """ + key_hash = hash_api_key(x_api_key) + + result = await db.execute( + select(APIKey).where(APIKey.key_hash == key_hash) + ) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=401, detail="Invalid API key") + + if not api_key.enabled: + raise HTTPException(status_code=403, detail="API key is disabled") + + if api_key.expires_at and api_key.expires_at < datetime.utcnow(): + raise HTTPException(status_code=403, detail="API key has expired") + + # Update last_used timestamp + api_key.last_used = datetime.utcnow() + + return api_key + + +async def get_optional_api_key( + x_api_key: Optional[str] = Header(None, alias="X-API-Key"), + db: AsyncSession = Depends(get_db), +) -> Optional[APIKey]: + """Get API key if provided, return None otherwise.""" + if not x_api_key: + return None + + try: + return await get_api_key(x_api_key, db) + except HTTPException: + return None + + +def check_permission(api_key: APIKey, permission: str) -> None: + """Check if API key has a specific permission. + + Args: + api_key: The API key record + permission: One of 'queue', 'control_printer', 'read_status' + + Raises HTTPException if permission is denied. + """ + permission_map = { + 'queue': api_key.can_queue, + 'control_printer': api_key.can_control_printer, + 'read_status': api_key.can_read_status, + } + + if permission not in permission_map: + raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}") + + if not permission_map[permission]: + raise HTTPException( + status_code=403, + detail=f"API key does not have '{permission}' permission" + ) + + +def check_printer_access(api_key: APIKey, printer_id: int) -> None: + """Check if API key has access to a specific printer. + + Args: + api_key: The API key record + printer_id: The printer ID to check + + Raises HTTPException if access is denied. + """ + if api_key.printer_ids is not None and printer_id not in api_key.printer_ids: + raise HTTPException( + status_code=403, + detail=f"API key does not have access to printer {printer_id}" + ) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3e9d97b95..f8c85d347 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -34,7 +34,7 @@ async def get_db() -> AsyncSession: async def init_db(): # Import models to register them with SQLAlchemy - from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link # noqa: F401 + from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -191,6 +191,72 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add project_id column to print_archives + try: + await conn.execute(text( + "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL" + )) + except Exception: + pass + + # Migration: Add project_id column to print_queue + try: + await conn.execute(text( + "ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL" + )) + except Exception: + pass + + # Migration: Create FTS5 virtual table for archive full-text search + try: + await conn.execute(text(""" + CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5( + print_name, + filename, + tags, + notes, + designer, + filament_type, + content='print_archives', + content_rowid='id' + ) + """)) + except Exception: + pass + + # Migration: Create triggers to keep FTS index in sync + try: + await conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN + INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type) + VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type); + END + """)) + except Exception: + pass + + try: + await conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN + INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type) + VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type); + END + """)) + except Exception: + pass + + try: + await conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN + INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type) + VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type); + INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type) + VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type); + END + """)) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/main.py b/backend/app/main.py index c824b8c88..778557654 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,7 +54,7 @@ from fastapi.responses import FileResponse from backend.app.core.database import init_db, async_session from sqlalchemy import select, or_ from backend.app.core.websocket import ws_manager -from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links +from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook from backend.app.api.routes import settings as settings_routes from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import ( @@ -1032,6 +1032,9 @@ app.include_router(updates.router, prefix=app_settings.api_prefix) app.include_router(maintenance.router, prefix=app_settings.api_prefix) app.include_router(camera.router, prefix=app_settings.api_prefix) app.include_router(external_links.router, prefix=app_settings.api_prefix) +app.include_router(projects.router, prefix=app_settings.api_prefix) +app.include_router(api_keys.router, prefix=app_settings.api_prefix) +app.include_router(webhook.router, prefix=app_settings.api_prefix) app.include_router(websocket.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2dae4e7f2..e1643ea3c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,8 @@ from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, from backend.app.models.kprofile_note import KProfileNote from backend.app.models.notification_template import NotificationTemplate from backend.app.models.notification import NotificationLog +from backend.app.models.project import Project +from backend.app.models.api_key import APIKey __all__ = [ "Printer", @@ -20,4 +22,6 @@ __all__ = [ "KProfileNote", "NotificationTemplate", "NotificationLog", + "Project", + "APIKey", ] diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py new file mode 100644 index 000000000..790e4f987 --- /dev/null +++ b/backend/app/models/api_key.py @@ -0,0 +1,29 @@ +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, Text, JSON, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class APIKey(Base): + """API key for external webhook access.""" + + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) # User-friendly name + key_hash: Mapped[str] = mapped_column(String(64)) # SHA256 hash of the key + key_prefix: Mapped[str] = mapped_column(String(8)) # First 8 chars for identification + + # Permissions + can_queue: Mapped[bool] = mapped_column(Boolean, default=True) # Add to queue + can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False) # Start/stop/cancel + can_read_status: Mapped[bool] = mapped_column(Boolean, default=True) # Query status + + # Optional scope limits + printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) # null = all printers + + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Optional expiry diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 800139567..d8971009b 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -10,6 +10,9 @@ class PrintArchive(Base): id: Mapped[int] = mapped_column(primary_key=True) printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True) + project_id: Mapped[int | None] = mapped_column( + ForeignKey("projects.id", ondelete="SET NULL"), nullable=True + ) # File info filename: Mapped[str] = mapped_column(String(255)) @@ -63,6 +66,8 @@ class PrintArchive(Base): # Relationships printer: Mapped["Printer | None"] = relationship(back_populates="archives") + project: Mapped["Project | None"] = relationship(back_populates="archives") from backend.app.models.printer import Printer # noqa: E402, F811 +from backend.app.models.project import Project # noqa: E402, F811 diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 4d0325327..1089d013b 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -19,6 +19,9 @@ class PrintQueueItem(Base): archive_id: Mapped[int] = mapped_column( ForeignKey("print_archives.id", ondelete="CASCADE") ) + project_id: Mapped[int | None] = mapped_column( + ForeignKey("projects.id", ondelete="SET NULL"), nullable=True + ) # Scheduling position: Mapped[int] = mapped_column(Integer, default=0) # Queue order @@ -44,7 +47,9 @@ class PrintQueueItem(Base): # Relationships printer: Mapped["Printer"] = relationship() archive: Mapped["PrintArchive"] = relationship() + project: Mapped["Project | None"] = relationship(back_populates="queue_items") from backend.app.models.printer import Printer # noqa: E402 from backend.app.models.archive import PrintArchive # noqa: E402 +from backend.app.models.project import Project # noqa: E402 diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 000000000..f41a4a0e1 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,32 @@ +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class Project(Base): + """Project to group related prints (e.g., 'Voron Build' with multiple parts).""" + + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str | None] = mapped_column(String(20), nullable=True) # Hex color for UI + status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived + target_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Optional target number of prints + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + # Relationships + archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project") + queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project") + + +from backend.app.models.archive import PrintArchive # noqa: E402 +from backend.app.models.print_queue import PrintQueueItem # noqa: E402 diff --git a/backend/app/schemas/api_key.py b/backend/app/schemas/api_key.py new file mode 100644 index 000000000..92ad31cb9 --- /dev/null +++ b/backend/app/schemas/api_key.py @@ -0,0 +1,46 @@ +from datetime import datetime +from pydantic import BaseModel + + +class APIKeyCreate(BaseModel): + """Schema for creating a new API key.""" + name: str + can_queue: bool = True + can_control_printer: bool = False + can_read_status: bool = True + printer_ids: list[int] | None = None # null = all printers + expires_at: datetime | None = None + + +class APIKeyUpdate(BaseModel): + """Schema for updating an API key.""" + name: str | None = None + can_queue: bool | None = None + can_control_printer: bool | None = None + can_read_status: bool | None = None + printer_ids: list[int] | None = None + enabled: bool | None = None + expires_at: datetime | None = None + + +class APIKeyResponse(BaseModel): + """Schema for API key response (without full key).""" + id: int + name: str + key_prefix: str # First 8 chars for identification + can_queue: bool + can_control_printer: bool + can_read_status: bool + printer_ids: list[int] | None + enabled: bool + last_used: datetime | None + created_at: datetime + expires_at: datetime | None + + class Config: + from_attributes = True + + +class APIKeyCreateResponse(APIKeyResponse): + """Response when creating a key - includes full key (shown only once).""" + key: str # Full API key, only shown on creation diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 48f7ed291..9e1feb3c7 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -13,6 +13,7 @@ class ArchiveBase(BaseModel): class ArchiveUpdate(ArchiveBase): printer_id: int | None = None + project_id: int | None = None class ArchiveDuplicate(BaseModel): @@ -26,6 +27,8 @@ class ArchiveDuplicate(BaseModel): class ArchiveResponse(BaseModel): id: int printer_id: int | None + project_id: int | None = None + project_name: str | None = None # Included for convenience filename: str file_path: str file_size: int diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 000000000..384b3fca2 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,85 @@ +from datetime import datetime +from pydantic import BaseModel + + +class ProjectCreate(BaseModel): + """Schema for creating a new project.""" + name: str + description: str | None = None + color: str | None = None + target_count: int | None = None + + +class ProjectUpdate(BaseModel): + """Schema for updating a project.""" + name: str | None = None + description: str | None = None + color: str | None = None + status: str | None = None # active, completed, archived + target_count: int | None = None + + +class ProjectStats(BaseModel): + """Statistics for a project.""" + total_archives: int = 0 + completed_prints: int = 0 + failed_prints: int = 0 + queued_prints: int = 0 + in_progress_prints: int = 0 + total_print_time_hours: float = 0.0 + total_filament_grams: float = 0.0 + progress_percent: float | None = None # Based on target_count + + +class ProjectResponse(BaseModel): + """Schema for project response.""" + id: int + name: str + description: str | None + color: str | None + status: str + target_count: int | None + created_at: datetime + updated_at: datetime + stats: ProjectStats | None = None + + class Config: + from_attributes = True + + +class ArchivePreview(BaseModel): + """Minimal archive data for project preview.""" + id: int + print_name: str | None + thumbnail_path: str | None + status: str + + +class ProjectListResponse(BaseModel): + """Schema for project list item (lighter weight).""" + id: int + name: str + description: str | None + color: str | None + status: str + target_count: int | None + created_at: datetime + # Quick stats + archive_count: int = 0 + queue_count: int = 0 + progress_percent: float | None = None + # Preview of archives (up to 5) + archives: list[ArchivePreview] = [] + + class Config: + from_attributes = True + + +class BatchAddArchives(BaseModel): + """Schema for batch adding archives to a project.""" + archive_ids: list[int] + + +class BatchAddQueueItems(BaseModel): + """Schema for batch adding queue items to a project.""" + queue_item_ids: list[int] diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index a28b6f222..fa9d98474 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -733,15 +733,25 @@ class ArchiveService: async def list_archives( self, printer_id: int | None = None, + project_id: int | None = None, limit: int = 50, offset: int = 0, ) -> list[PrintArchive]: """List archives with optional filtering.""" - query = select(PrintArchive).order_by(PrintArchive.created_at.desc()) + from sqlalchemy.orm import selectinload + + query = ( + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .order_by(PrintArchive.created_at.desc()) + ) if printer_id: query = query.where(PrintArchive.printer_id == printer_id) + if project_id: + query = query.where(PrintArchive.project_id == project_id) + query = query.limit(limit).offset(offset) result = await self.db.execute(query) return list(result.scalars().all()) diff --git a/backend/app/services/archive_comparison.py b/backend/app/services/archive_comparison.py new file mode 100644 index 000000000..4a855d230 --- /dev/null +++ b/backend/app/services/archive_comparison.py @@ -0,0 +1,278 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from backend.app.models.archive import PrintArchive + + +class ArchiveComparisonService: + """Service for comparing print archives.""" + + # Fields to compare + COMPARABLE_FIELDS = [ + ("layer_height", "Layer Height", "mm"), + ("nozzle_diameter", "Nozzle Diameter", "mm"), + ("bed_temperature", "Bed Temperature", "°C"), + ("nozzle_temperature", "Nozzle Temperature", "°C"), + ("filament_type", "Filament Type", None), + ("filament_used_grams", "Filament Used", "g"), + ("print_time_seconds", "Print Time", "s"), + ("total_layers", "Total Layers", None), + ("status", "Status", None), + ] + + def __init__(self, db: AsyncSession): + self.db = db + + async def compare_archives(self, archive_ids: list[int]) -> dict: + """Compare multiple archives side by side. + + Args: + archive_ids: List of 2-5 archive IDs to compare + + Returns: + Dictionary with comparison results + """ + if len(archive_ids) < 2: + raise ValueError("At least 2 archives required for comparison") + if len(archive_ids) > 5: + raise ValueError("Maximum 5 archives can be compared at once") + + # Fetch archives + result = await self.db.execute( + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .where(PrintArchive.id.in_(archive_ids)) + ) + archives = {a.id: a for a in result.scalars().all()} + + if len(archives) != len(archive_ids): + missing = set(archive_ids) - set(archives.keys()) + raise ValueError(f"Archives not found: {missing}") + + # Preserve order from input + ordered_archives = [archives[id] for id in archive_ids] + + # Build basic info for each archive + archive_info = [ + { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + "printer_id": a.printer_id, + "project_name": a.project.name if a.project else None, + } + for a in ordered_archives + ] + + # Build field comparison + comparison = [] + differences = [] + + for field_name, display_name, unit in self.COMPARABLE_FIELDS: + values = [getattr(a, field_name) for a in ordered_archives] + + # Format values for display + formatted_values = [] + for v in values: + if v is None: + formatted_values.append(None) + elif field_name == "print_time_seconds": + # Format as human-readable time + hours = int(v) // 3600 + minutes = (int(v) % 3600) // 60 + formatted_values.append(f"{hours}h {minutes}m" if hours else f"{minutes}m") + elif isinstance(v, float): + formatted_values.append(round(v, 2)) + else: + formatted_values.append(v) + + # Check if values differ + non_none_values = [v for v in values if v is not None] + has_difference = len(set(str(v) for v in non_none_values)) > 1 if non_none_values else False + + field_data = { + "field": field_name, + "label": display_name, + "unit": unit, + "values": formatted_values, + "raw_values": values, + "has_difference": has_difference, + } + + comparison.append(field_data) + + if has_difference: + differences.append(field_data) + + # Analyze success/failure correlation + success_correlation = self._analyze_success_correlation(ordered_archives) + + return { + "archives": archive_info, + "comparison": comparison, + "differences": differences, + "success_correlation": success_correlation, + } + + def _analyze_success_correlation(self, archives: list[PrintArchive]) -> dict: + """Analyze what settings correlate with success/failure.""" + successful = [a for a in archives if a.status == "completed"] + failed = [a for a in archives if a.status == "failed"] + + if not successful or not failed: + return { + "has_both_outcomes": False, + "message": "Need both successful and failed prints to analyze correlation", + } + + # Find settings that differ between successful and failed + insights = [] + + for field_name, display_name, unit in self.COMPARABLE_FIELDS: + if field_name == "status": + continue + + success_values = [getattr(a, field_name) for a in successful if getattr(a, field_name) is not None] + failed_values = [getattr(a, field_name) for a in failed if getattr(a, field_name) is not None] + + if not success_values or not failed_values: + continue + + # For numeric fields, compare averages + if isinstance(success_values[0], (int, float)): + success_avg = sum(success_values) / len(success_values) + failed_avg = sum(failed_values) / len(failed_values) + + if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01): + direction = "higher" if success_avg > failed_avg else "lower" + insights.append({ + "field": field_name, + "label": display_name, + "success_avg": round(success_avg, 2), + "failed_avg": round(failed_avg, 2), + "insight": f"Successful prints had {direction} {display_name}", + }) + else: + # For categorical fields, check if success uses different values + success_set = set(str(v) for v in success_values) + failed_set = set(str(v) for v in failed_values) + + if success_set != failed_set: + insights.append({ + "field": field_name, + "label": display_name, + "success_values": list(success_set), + "failed_values": list(failed_set), + "insight": f"Different {display_name} used in successful vs failed prints", + }) + + return { + "has_both_outcomes": True, + "successful_count": len(successful), + "failed_count": len(failed), + "insights": insights, + } + + async def find_similar_archives( + self, + archive_id: int, + limit: int = 10, + ) -> list[dict]: + """Find archives with similar settings for comparison. + + Args: + archive_id: The archive to find similar ones for + limit: Maximum number of results + + Returns: + List of similar archives with match reasons + """ + # Get the reference archive + result = await self.db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + reference = result.scalar_one_or_none() + + if not reference: + raise ValueError("Archive not found") + + # Find similar archives + similar = [] + + # By same print name + if reference.print_name: + result = await self.db.execute( + select(PrintArchive) + .where( + PrintArchive.id != archive_id, + PrintArchive.print_name == reference.print_name, + ) + .order_by(PrintArchive.created_at.desc()) + .limit(limit) + ) + for a in result.scalars().all(): + similar.append({ + "archive": { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + }, + "match_reason": "Same print name", + "match_score": 100, + }) + + # By content hash + if reference.content_hash and len(similar) < limit: + result = await self.db.execute( + select(PrintArchive) + .where( + PrintArchive.id != archive_id, + PrintArchive.content_hash == reference.content_hash, + ) + .order_by(PrintArchive.created_at.desc()) + .limit(limit - len(similar)) + ) + for a in result.scalars().all(): + if not any(s["archive"]["id"] == a.id for s in similar): + similar.append({ + "archive": { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + }, + "match_reason": "Same file content", + "match_score": 95, + }) + + # By same filament type + if reference.filament_type and len(similar) < limit: + result = await self.db.execute( + select(PrintArchive) + .where( + PrintArchive.id != archive_id, + PrintArchive.filament_type == reference.filament_type, + ) + .order_by(PrintArchive.created_at.desc()) + .limit(limit - len(similar)) + ) + for a in result.scalars().all(): + if not any(s["archive"]["id"] == a.id for s in similar): + similar.append({ + "archive": { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + }, + "match_reason": f"Same filament type ({reference.filament_type})", + "match_score": 50, + }) + + # Sort by match score + similar.sort(key=lambda x: x["match_score"], reverse=True) + + return similar[:limit] diff --git a/backend/app/services/export.py b/backend/app/services/export.py new file mode 100644 index 000000000..8b953d3df --- /dev/null +++ b/backend/app/services/export.py @@ -0,0 +1,335 @@ +import csv +import io +from datetime import datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from backend.app.models.archive import PrintArchive +from backend.app.models.project import Project + + +class ExportService: + """Service for exporting archive data to CSV/Excel formats.""" + + # Default fields to export + DEFAULT_FIELDS = [ + "id", + "print_name", + "filename", + "status", + "printer_id", + "project_name", + "filament_type", + "filament_used_grams", + "print_time_seconds", + "layer_height", + "nozzle_diameter", + "bed_temperature", + "nozzle_temperature", + "total_layers", + "cost", + "designer", + "tags", + "notes", + "failure_reason", + "started_at", + "completed_at", + "created_at", + ] + + # Field labels for headers + FIELD_LABELS = { + "id": "ID", + "print_name": "Print Name", + "filename": "Filename", + "status": "Status", + "printer_id": "Printer ID", + "project_name": "Project", + "filament_type": "Filament Type", + "filament_used_grams": "Filament (g)", + "print_time_seconds": "Print Time (s)", + "layer_height": "Layer Height (mm)", + "nozzle_diameter": "Nozzle (mm)", + "bed_temperature": "Bed Temp (°C)", + "nozzle_temperature": "Nozzle Temp (°C)", + "total_layers": "Total Layers", + "cost": "Cost", + "designer": "Designer", + "tags": "Tags", + "notes": "Notes", + "failure_reason": "Failure Reason", + "started_at": "Started At", + "completed_at": "Completed At", + "created_at": "Created At", + } + + def __init__(self, db: AsyncSession): + self.db = db + + async def export_archives( + self, + format: str = "csv", + fields: list[str] | None = None, + printer_id: int | None = None, + project_id: int | None = None, + status: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + search: str | None = None, + ) -> tuple[bytes, str, str]: + """Export archives to CSV or Excel format. + + Args: + format: Export format ('csv' or 'xlsx') + fields: List of fields to include (None = all default fields) + printer_id: Filter by printer + project_id: Filter by project + status: Filter by status + date_from: Filter by start date + date_to: Filter by end date + search: Search filter + + Returns: + Tuple of (file_bytes, filename, content_type) + """ + # Build query + query = ( + select(PrintArchive) + .options(selectinload(PrintArchive.project)) + .order_by(PrintArchive.created_at.desc()) + ) + + # Apply filters + if printer_id: + query = query.where(PrintArchive.printer_id == printer_id) + if project_id: + query = query.where(PrintArchive.project_id == project_id) + if status: + query = query.where(PrintArchive.status == status) + if date_from: + query = query.where(PrintArchive.created_at >= date_from) + if date_to: + query = query.where(PrintArchive.created_at <= date_to) + if search: + like_pattern = f"%{search}%" + query = query.where( + (PrintArchive.print_name.ilike(like_pattern)) | + (PrintArchive.filename.ilike(like_pattern)) | + (PrintArchive.tags.ilike(like_pattern)) | + (PrintArchive.notes.ilike(like_pattern)) | + (PrintArchive.designer.ilike(like_pattern)) + ) + + # Execute query + result = await self.db.execute(query) + archives = list(result.scalars().all()) + + # Determine fields to export + export_fields = fields if fields else self.DEFAULT_FIELDS + + # Convert to rows + rows = [] + for archive in archives: + row = self._archive_to_row(archive, export_fields) + rows.append(row) + + # Generate headers + headers = [self.FIELD_LABELS.get(f, f) for f in export_fields] + + # Generate file + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if format == "xlsx": + file_bytes = self._generate_xlsx(headers, rows, export_fields) + filename = f"archives_export_{timestamp}.xlsx" + content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + else: + file_bytes = self._generate_csv(headers, rows) + filename = f"archives_export_{timestamp}.csv" + content_type = "text/csv" + + return file_bytes, filename, content_type + + async def export_stats( + self, + format: str = "csv", + days: int = 30, + printer_id: int | None = None, + project_id: int | None = None, + ) -> tuple[bytes, str, str]: + """Export statistics summary to CSV or Excel format. + + Args: + format: Export format ('csv' or 'xlsx') + days: Number of days to include in stats + printer_id: Filter by printer + project_id: Filter by project + + Returns: + Tuple of (file_bytes, filename, content_type) + """ + from backend.app.services.failure_analysis import FailureAnalysisService + + # Get failure analysis data (includes stats) + analysis_service = FailureAnalysisService(self.db) + analysis = await analysis_service.analyze_failures( + days=days, + printer_id=printer_id, + project_id=project_id, + ) + + # Build stats rows + rows = [ + ["Metric", "Value"], + ["Period (days)", analysis["period_days"]], + ["Total Prints", analysis["total_prints"]], + ["Failed Prints", analysis["failed_prints"]], + ["Failure Rate (%)", analysis["failure_rate"]], + [""], + ["Failures by Reason", ""], + ] + + for reason, count in analysis["failures_by_reason"].items(): + rows.append([reason, count]) + + rows.append([""]) + rows.append(["Failures by Filament", ""]) + + for filament, count in analysis["failures_by_filament"].items(): + rows.append([filament, count]) + + rows.append([""]) + rows.append(["Failures by Printer", ""]) + + for printer, count in analysis["failures_by_printer"].items(): + rows.append([printer, count]) + + rows.append([""]) + rows.append(["Weekly Trend", ""]) + rows.append(["Week", "Total", "Failed", "Rate (%)"]) + + for week in analysis["trend"]: + rows.append([ + week["week_start"], + week["total_prints"], + week["failed_prints"], + week["failure_rate"], + ]) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if format == "xlsx": + file_bytes = self._generate_xlsx_simple(rows) + filename = f"stats_export_{timestamp}.xlsx" + content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + else: + file_bytes = self._generate_csv_simple(rows) + filename = f"stats_export_{timestamp}.csv" + content_type = "text/csv" + + return file_bytes, filename, content_type + + def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]: + """Convert an archive to a row of values.""" + row = [] + for field in fields: + if field == "project_name": + value = archive.project.name if archive.project else None + elif field in ("started_at", "completed_at", "created_at"): + value = getattr(archive, field) + if value: + value = value.isoformat() + else: + value = getattr(archive, field, None) + row.append(value) + return row + + def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes: + """Generate CSV file content.""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(headers) + writer.writerows(rows) + return output.getvalue().encode("utf-8") + + def _generate_csv_simple(self, rows: list[list]) -> bytes: + """Generate CSV file content from simple rows (no separate headers).""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerows(rows) + return output.getvalue().encode("utf-8") + + def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes: + """Generate Excel file content.""" + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from openpyxl.utils import get_column_letter + except ImportError: + raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl") + + wb = Workbook() + ws = wb.active + ws.title = "Archives" + + # Header style + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_alignment = Alignment(horizontal="center") + + # Write headers + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # Write data + for row_idx, row in enumerate(rows, 2): + for col_idx, value in enumerate(row, 1): + ws.cell(row=row_idx, column=col_idx, value=value) + + # Auto-adjust column widths + for col_idx, field in enumerate(fields, 1): + column_letter = get_column_letter(col_idx) + max_length = len(headers[col_idx - 1]) + for row in rows: + cell_value = row[col_idx - 1] + if cell_value is not None: + max_length = max(max_length, len(str(cell_value))) + ws.column_dimensions[column_letter].width = min(max_length + 2, 50) + + # Freeze header row + ws.freeze_panes = "A2" + + output = io.BytesIO() + wb.save(output) + return output.getvalue() + + def _generate_xlsx_simple(self, rows: list[list]) -> bytes: + """Generate Excel file content from simple rows.""" + try: + from openpyxl import Workbook + from openpyxl.styles import Font + except ImportError: + raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl") + + wb = Workbook() + ws = wb.active + ws.title = "Statistics" + + bold_font = Font(bold=True) + + for row_idx, row in enumerate(rows, 1): + for col_idx, value in enumerate(row, 1): + cell = ws.cell(row=row_idx, column=col_idx, value=value) + # Bold section headers + if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"): + cell.font = bold_font + + output = io.BytesIO() + wb.save(output) + return output.getvalue() diff --git a/backend/app/services/failure_analysis.py b/backend/app/services/failure_analysis.py new file mode 100644 index 000000000..8c5eff9a3 --- /dev/null +++ b/backend/app/services/failure_analysis.py @@ -0,0 +1,198 @@ +from datetime import datetime, timedelta +from collections import defaultdict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ + +from backend.app.models.archive import PrintArchive +from backend.app.models.printer import Printer + + +class FailureAnalysisService: + """Service for analyzing print failure patterns.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def analyze_failures( + self, + days: int = 30, + printer_id: int | None = None, + project_id: int | None = None, + ) -> dict: + """Analyze failure patterns across archives. + + Args: + days: Number of days to analyze + printer_id: Optional filter by printer + project_id: Optional filter by project + + Returns: + Dictionary with failure analysis results + """ + cutoff_date = datetime.utcnow() - timedelta(days=days) + + # Build base query + base_filter = [PrintArchive.created_at >= cutoff_date] + if printer_id: + base_filter.append(PrintArchive.printer_id == printer_id) + if project_id: + base_filter.append(PrintArchive.project_id == project_id) + + # Total counts + total_result = await self.db.execute( + select(func.count(PrintArchive.id)).where(and_(*base_filter)) + ) + total_prints = total_result.scalar() or 0 + + failed_result = await self.db.execute( + select(func.count(PrintArchive.id)).where( + and_(*base_filter, PrintArchive.status == "failed") + ) + ) + failed_prints = failed_result.scalar() or 0 + + failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0 + + # Failures by reason + reason_result = await self.db.execute( + select( + PrintArchive.failure_reason, + func.count(PrintArchive.id).label("count"), + ) + .where(and_(*base_filter, PrintArchive.status == "failed")) + .group_by(PrintArchive.failure_reason) + .order_by(func.count(PrintArchive.id).desc()) + ) + failures_by_reason = { + (row[0] or "Unknown"): row[1] + for row in reason_result.fetchall() + } + + # Failures by filament type + filament_result = await self.db.execute( + select( + PrintArchive.filament_type, + func.count(PrintArchive.id).label("count"), + ) + .where(and_(*base_filter, PrintArchive.status == "failed")) + .group_by(PrintArchive.filament_type) + .order_by(func.count(PrintArchive.id).desc()) + ) + failures_by_filament = { + (row[0] or "Unknown"): row[1] + for row in filament_result.fetchall() + } + + # Failures by printer + printer_result = await self.db.execute( + select( + PrintArchive.printer_id, + func.count(PrintArchive.id).label("count"), + ) + .where( + and_(*base_filter, PrintArchive.status == "failed", PrintArchive.printer_id.isnot(None)) + ) + .group_by(PrintArchive.printer_id) + .order_by(func.count(PrintArchive.id).desc()) + ) + failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()} + + # Get printer names + if failures_by_printer_id: + printers_result = await self.db.execute( + select(Printer.id, Printer.name).where( + Printer.id.in_(failures_by_printer_id.keys()) + ) + ) + printer_names = {row[0]: row[1] for row in printers_result.fetchall()} + failures_by_printer = { + printer_names.get(pid, f"Printer {pid}"): count + for pid, count in failures_by_printer_id.items() + } + else: + failures_by_printer = {} + + # Failures by hour of day + failed_archives_result = await self.db.execute( + select(PrintArchive.started_at) + .where( + and_( + *base_filter, + PrintArchive.status == "failed", + PrintArchive.started_at.isnot(None), + ) + ) + ) + failures_by_hour = defaultdict(int) + for (started_at,) in failed_archives_result.fetchall(): + if started_at: + hour = started_at.hour + failures_by_hour[hour] += 1 + # Convert to dict with all 24 hours + failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)} + + # Recent failures + recent_result = await self.db.execute( + select(PrintArchive) + .where(and_(*base_filter, PrintArchive.status == "failed")) + .order_by(PrintArchive.created_at.desc()) + .limit(10) + ) + recent_failures = [ + { + "id": a.id, + "print_name": a.print_name or a.filename, + "failure_reason": a.failure_reason, + "filament_type": a.filament_type, + "printer_id": a.printer_id, + "created_at": a.created_at.isoformat() if a.created_at else None, + } + for a in recent_result.scalars().all() + ] + + # Failure rate trend (by week) + trend_data = [] + for i in range(min(days // 7, 12)): # Up to 12 weeks + week_end = datetime.utcnow() - timedelta(weeks=i) + week_start = week_end - timedelta(weeks=1) + + week_filter = base_filter.copy() + week_filter[0] = and_( + PrintArchive.created_at >= week_start, + PrintArchive.created_at < week_end, + ) + + week_total = await self.db.execute( + select(func.count(PrintArchive.id)).where(and_(*week_filter)) + ) + week_failed = await self.db.execute( + select(func.count(PrintArchive.id)).where( + and_(*week_filter, PrintArchive.status == "failed") + ) + ) + + total = week_total.scalar() or 0 + failed = week_failed.scalar() or 0 + rate = (failed / total * 100) if total > 0 else 0 + + trend_data.append({ + "week_start": week_start.date().isoformat(), + "total_prints": total, + "failed_prints": failed, + "failure_rate": round(rate, 1), + }) + + trend_data.reverse() # Oldest first + + return { + "period_days": days, + "total_prints": total_prints, + "failed_prints": failed_prints, + "failure_rate": round(failure_rate, 1), + "failures_by_reason": failures_by_reason, + "failures_by_filament": failures_by_filament, + "failures_by_printer": failures_by_printer, + "failures_by_hour": failures_by_hour_complete, + "recent_failures": recent_failures, + "trend": trend_data, + } diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 712db26dd..48eb8eef3 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -60,6 +60,13 @@ "description": "View print queue", "url": "/queue", "icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }] + }, + { + "name": "Projects", + "short_name": "Projects", + "description": "View print projects", + "url": "/projects", + "icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }] } ] } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c93a4a64c..0042d2189 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage'; import { SettingsPage } from './pages/SettingsPage'; import { ProfilesPage } from './pages/ProfilesPage'; import { MaintenancePage } from './pages/MaintenancePage'; +import { ProjectsPage } from './pages/ProjectsPage'; import { CameraPage } from './pages/CameraPage'; import { ExternalLinkPage } from './pages/ExternalLinkPage'; import { useWebSocket } from './hooks/useWebSocket'; @@ -46,6 +47,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6ccd9d933..4c5b037c4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -176,6 +176,8 @@ export interface ArchiveDuplicate { export interface Archive { id: number; printer_id: number | null; + project_id: number | null; + project_name: string | null; filename: string; file_path: string; file_size: number; @@ -229,6 +231,31 @@ export interface ArchiveStats { total_energy_cost: number; } +export interface FailureAnalysis { + period_days: number; + total_prints: number; + failed_prints: number; + failure_rate: number; + failures_by_reason: Record; + failures_by_filament: Record; + failures_by_printer: Record; + failures_by_hour: Record; + recent_failures: Array<{ + id: number; + print_name: string; + failure_reason: string | null; + filament_type: string | null; + printer_id: number | null; + created_at: string | null; + }>; + trend: Array<{ + week_start: string; + total_prints: number; + failed_prints: number; + failure_rate: number; + }>; +} + export interface BulkUploadResult { uploaded: number; failed: number; @@ -236,6 +263,159 @@ export interface BulkUploadResult { errors: Array<{ filename: string; error: string }>; } +// Archive Comparison types +export interface ComparisonArchiveInfo { + id: number; + print_name: string; + status: string; + created_at: string | null; + printer_id: number | null; + project_name: string | null; +} + +export interface ComparisonField { + field: string; + label: string; + unit: string | null; + values: (string | number | null)[]; + raw_values: (string | number | null)[]; + has_difference: boolean; +} + +export interface SuccessCorrelationInsight { + field: string; + label: string; + insight: string; + success_avg?: number; + failed_avg?: number; + success_values?: string[]; + failed_values?: string[]; +} + +export interface SuccessCorrelation { + has_both_outcomes: boolean; + message?: string; + successful_count?: number; + failed_count?: number; + insights?: SuccessCorrelationInsight[]; +} + +export interface ArchiveComparison { + archives: ComparisonArchiveInfo[]; + comparison: ComparisonField[]; + differences: ComparisonField[]; + success_correlation: SuccessCorrelation; +} + +export interface SimilarArchive { + archive: { + id: number; + print_name: string; + status: string; + created_at: string | null; + }; + match_reason: string; + match_score: number; +} + +// Project types +export interface ProjectStats { + total_archives: number; + completed_prints: number; + failed_prints: number; + queued_prints: number; + in_progress_prints: number; + total_print_time_hours: number; + total_filament_grams: number; + progress_percent: number | null; +} + +export interface Project { + id: number; + name: string; + description: string | null; + color: string | null; + status: string; // active, completed, archived + target_count: number | null; + created_at: string; + updated_at: string; + stats?: ProjectStats; +} + +export interface ArchivePreview { + id: number; + print_name: string | null; + thumbnail_path: string | null; + status: string; +} + +export interface ProjectListItem { + id: number; + name: string; + description: string | null; + color: string | null; + status: string; + target_count: number | null; + created_at: string; + archive_count: number; + queue_count: number; + progress_percent: number | null; + archives: ArchivePreview[]; +} + +export interface ProjectCreate { + name: string; + description?: string; + color?: string; + target_count?: number; +} + +export interface ProjectUpdate { + name?: string; + description?: string; + color?: string; + status?: string; + target_count?: number; +} + +// API Key types +export interface APIKey { + id: number; + name: string; + key_prefix: string; + can_queue: boolean; + can_control_printer: boolean; + can_read_status: boolean; + printer_ids: number[] | null; + enabled: boolean; + last_used: string | null; + created_at: string; + expires_at: string | null; +} + +export interface APIKeyCreate { + name: string; + can_queue?: boolean; + can_control_printer?: boolean; + can_read_status?: boolean; + printer_ids?: number[] | null; + expires_at?: string | null; +} + +export interface APIKeyCreateResponse extends APIKey { + key: string; // Full key, only shown on creation +} + +export interface APIKeyUpdate { + name?: string; + can_queue?: boolean; + can_control_printer?: boolean; + can_read_status?: boolean; + printer_ids?: number[] | null; + enabled?: boolean; + expires_at?: string | null; +} + // Settings types export interface AppSettings { auto_archive: boolean; @@ -970,16 +1150,35 @@ export const api = { request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`), // Archives - getArchives: (printerId?: number, limit = 50, offset = 0) => { + getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => { const params = new URLSearchParams(); if (printerId) params.set('printer_id', String(printerId)); + if (projectId) params.set('project_id', String(projectId)); params.set('limit', String(limit)); params.set('offset', String(offset)); return request(`/archives/?${params}`); }, getArchive: (id: number) => request(`/archives/${id}`), + searchArchives: (query: string, options?: { + printerId?: number; + projectId?: number; + status?: string; + limit?: number; + offset?: number; + }) => { + const params = new URLSearchParams(); + params.set('q', query); + if (options?.printerId) params.set('printer_id', String(options.printerId)); + if (options?.projectId) params.set('project_id', String(options.projectId)); + if (options?.status) params.set('status', options.status); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.offset) params.set('offset', String(options.offset)); + return request(`/archives/search?${params}`); + }, + rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }), updateArchive: (id: number, data: { printer_id?: number | null; + project_id?: number | null; print_name?: string; is_favorite?: boolean; tags?: string; @@ -996,6 +1195,81 @@ export const api = { deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), getArchiveStats: () => request('/archives/stats'), + getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => { + const params = new URLSearchParams(); + if (options?.days) params.set('days', String(options.days)); + if (options?.printerId) params.set('printer_id', String(options.printerId)); + if (options?.projectId) params.set('project_id', String(options.projectId)); + return request(`/archives/analysis/failures?${params}`); + }, + compareArchives: (archiveIds: number[]) => + request(`/archives/compare?archive_ids=${archiveIds.join(',')}`), + findSimilarArchives: (archiveId: number, limit = 10) => + request(`/archives/${archiveId}/similar?limit=${limit}`), + exportArchives: async (options?: { + format?: 'csv' | 'xlsx'; + fields?: string[]; + printerId?: number; + projectId?: number; + status?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + }): Promise<{ blob: Blob; filename: string }> => { + const params = new URLSearchParams(); + if (options?.format) params.set('format', options.format); + if (options?.fields) params.set('fields', options.fields.join(',')); + if (options?.printerId) params.set('printer_id', String(options.printerId)); + if (options?.projectId) params.set('project_id', String(options.projectId)); + if (options?.status) params.set('status', options.status); + if (options?.dateFrom) params.set('date_from', options.dateFrom); + if (options?.dateTo) params.set('date_to', options.dateTo); + if (options?.search) params.set('search', options.search); + + const response = await fetch(`${API_BASE}/archives/export?${params}`); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = options?.format === 'xlsx' ? 'archives_export.xlsx' : 'archives_export.csv'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^"]+)"?/); + if (match) filename = match[1]; + } + + const blob = await response.blob(); + return { blob, filename }; + }, + exportStats: async (options?: { + format?: 'csv' | 'xlsx'; + days?: number; + printerId?: number; + projectId?: number; + }): Promise<{ blob: Blob; filename: string }> => { + const params = new URLSearchParams(); + if (options?.format) params.set('format', options.format); + if (options?.days) params.set('days', String(options.days)); + if (options?.printerId) params.set('printer_id', String(options.printerId)); + if (options?.projectId) params.set('project_id', String(options.projectId)); + + const response = await fetch(`${API_BASE}/archives/stats/export?${params}`); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = options?.format === 'xlsx' ? 'stats_export.xlsx' : 'stats_export.csv'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^"]+)"?/); + if (match) filename = match[1]; + } + + const blob = await response.blob(); + return { blob, filename }; + }, getArchiveDuplicates: (id: number) => request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`), backfillContentHashes: () => @@ -1567,4 +1841,56 @@ export const api = { deleteExternalLinkIcon: (id: number) => request(`/external-links/${id}/icon`, { method: 'DELETE' }), getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`, + + // Projects + getProjects: (status?: string) => { + const params = new URLSearchParams(); + if (status) params.set('status', status); + return request(`/projects/?${params}`); + }, + getProject: (id: number) => request(`/projects/${id}`), + createProject: (data: ProjectCreate) => + request('/projects/', { + method: 'POST', + body: JSON.stringify(data), + }), + updateProject: (id: number, data: ProjectUpdate) => + request(`/projects/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteProject: (id: number) => + request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }), + getProjectArchives: (id: number, limit = 100, offset = 0) => + request(`/projects/${id}/archives?limit=${limit}&offset=${offset}`), + addArchivesToProject: (projectId: number, archiveIds: number[]) => + request<{ message: string }>(`/projects/${projectId}/add-archives`, { + method: 'POST', + body: JSON.stringify({ archive_ids: archiveIds }), + }), + removeArchivesFromProject: (projectId: number, archiveIds: number[]) => + request<{ message: string }>(`/projects/${projectId}/remove-archives`, { + method: 'POST', + body: JSON.stringify({ archive_ids: archiveIds }), + }), + addQueueItemsToProject: (projectId: number, queueItemIds: number[]) => + request<{ message: string }>(`/projects/${projectId}/add-queue`, { + method: 'POST', + body: JSON.stringify({ queue_item_ids: queueItemIds }), + }), + + // API Keys + getAPIKeys: () => request('/api-keys/'), + createAPIKey: (data: APIKeyCreate) => + request('/api-keys/', { + method: 'POST', + body: JSON.stringify(data), + }), + updateAPIKey: (id: number, data: APIKeyUpdate) => + request(`/api-keys/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteAPIKey: (id: number) => + request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }), }; diff --git a/frontend/src/components/CompareArchivesModal.tsx b/frontend/src/components/CompareArchivesModal.tsx new file mode 100644 index 000000000..8ad587e57 --- /dev/null +++ b/frontend/src/components/CompareArchivesModal.tsx @@ -0,0 +1,190 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { X, Check, AlertTriangle, Loader2 } from 'lucide-react'; +import { api } from '../api/client'; +import type { ArchiveComparison } from '../api/client'; +import { Button } from './Button'; + +interface CompareArchivesModalProps { + archiveIds: number[]; + onClose: () => void; +} + +export function CompareArchivesModal({ archiveIds, onClose }: CompareArchivesModalProps) { + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const { data: comparison, isLoading, error } = useQuery({ + queryKey: ['archive-comparison', archiveIds], + queryFn: () => api.compareArchives(archiveIds), + }); + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

+ Compare Archives ({archiveIds.length}) +

+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ +

Failed to load comparison

+

+ {error instanceof Error ? error.message : 'Unknown error'} +

+
+ ) : comparison ? ( + + ) : null} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} + +function ComparisonContent({ comparison }: { comparison: ArchiveComparison }) { + return ( +
+ {/* Archive Headers */} +
+ + + + + {comparison.archives.map((archive) => ( + + ))} + + + + {comparison.comparison.map((field) => ( + + + {field.values.map((value, idx) => ( + + ))} + + ))} + +
+ Setting + +
+ {archive.print_name} +
+
+ {archive.status} +
+
+
+ {field.has_difference && ( + + )} + + {field.label} + +
+
+ {value ?? -} + {field.unit && value !== null && ( + {field.unit} + )} +
+
+ + {/* Differences Summary */} + {comparison.differences.length > 0 && ( +
+

+ + {comparison.differences.length} Difference{comparison.differences.length > 1 ? 's' : ''} Found +

+
    + {comparison.differences.slice(0, 5).map((diff) => ( +
  • + {diff.label}: {diff.values.join(' vs ')} {diff.unit || ''} +
  • + ))} + {comparison.differences.length > 5 && ( +
  • + ...and {comparison.differences.length - 5} more +
  • + )} +
+
+ )} + + {/* Success Correlation */} + {comparison.success_correlation.has_both_outcomes ? ( +
+

+ + Success/Failure Analysis +

+
+ + {comparison.success_correlation.successful_count} successful + + + {comparison.success_correlation.failed_count} failed + +
+ {comparison.success_correlation.insights && comparison.success_correlation.insights.length > 0 ? ( +
+ {comparison.success_correlation.insights.map((insight) => ( +
+ {insight.label}:{' '} + {insight.insight} +
+ ))} +
+ ) : ( +

No clear correlations found between settings and outcomes.

+ )} +
+ ) : ( +
+

{comparison.success_correlation.message || 'Need both successful and failed prints for correlation analysis.'}

+
+ )} +
+ ); +} diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index 943831d1f..c1198657e 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; export interface ContextMenuItem { label: string; @@ -7,6 +8,7 @@ export interface ContextMenuItem { danger?: boolean; disabled?: boolean; divider?: boolean; + submenu?: ContextMenuItem[]; } interface ContextMenuProps { @@ -18,6 +20,8 @@ interface ContextMenuProps { export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { const menuRef = useRef(null); + const [activeSubmenu, setActiveSubmenu] = useState(null); + const submenuTimeoutRef = useRef(null); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -44,6 +48,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); document.removeEventListener('scroll', handleScroll, true); + if (submenuTimeoutRef.current) { + clearTimeout(submenuTimeoutRef.current); + } }; }, [onClose]); @@ -69,10 +76,24 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { } }, [x, y]); + const handleMouseEnterSubmenu = (index: number) => { + if (submenuTimeoutRef.current) { + clearTimeout(submenuTimeoutRef.current); + submenuTimeoutRef.current = null; + } + setActiveSubmenu(index); + }; + + const handleMouseLeaveSubmenu = () => { + submenuTimeoutRef.current = window.setTimeout(() => { + setActiveSubmenu(null); + }, 150); + }; + return (
{items.map((item, index) => { @@ -80,27 +101,75 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { return
; } + const hasSubmenu = item.submenu && item.submenu.length > 0; + return ( - + + {/* Submenu */} + {hasSubmenu && activeSubmenu === index && ( +
{ + if (submenuTimeoutRef.current) { + clearTimeout(submenuTimeoutRef.current); + submenuTimeoutRef.current = null; + } + }} + onMouseLeave={() => handleMouseLeaveSubmenu()} + > + {item.submenu!.map((subItem, subIndex) => ( + + ))} +
+ )} +
); })}
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 28661cedd..e6ba17dc4 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -31,6 +31,8 @@ interface DashboardProps { widgets: DashboardWidget[]; storageKey: string; columns?: number; + hideControls?: boolean; + onResetLayout?: () => void; } interface LayoutState { @@ -124,7 +126,7 @@ function SortableWidget({ ); } -export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) { +export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout }: DashboardProps) { // Build default sizes from widget definitions const getDefaultSizes = () => { const sizes: Record = {}; @@ -233,6 +235,7 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) sizes: getDefaultSizes(), }; setLayout(defaultLayout); + onResetLayout?.(); }; // Get ordered widgets @@ -246,22 +249,24 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) return (
{/* Dashboard Controls */} -
- {hiddenWidgets.length > 0 && ( - + )} + - )} - -
+
+ )} {/* Hidden Widgets Panel */} {showHiddenPanel && hiddenWidgets.length > 0 && ( diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 77205adb5..fa36917c3 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react'; +import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; @@ -37,6 +37,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr const queryClient = useQueryClient(); const [printName, setPrintName] = useState(archive.print_name || ''); const [printerId, setPrinterId] = useState(archive.printer_id); + const [projectId, setProjectId] = useState(archive.project_id ?? null); const [notes, setNotes] = useState(archive.notes || ''); const [tags, setTags] = useState(archive.tags || ''); const [failureReason, setFailureReason] = useState(archive.failure_reason || ''); @@ -52,6 +53,11 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr queryFn: api.getPrinters, }); + const { data: projects } = useQuery({ + queryKey: ['projects'], + queryFn: () => api.getProjects(), + }); + // Get all archives to extract existing tags if not provided const { data: archives } = useQuery({ queryKey: ['archives'], @@ -96,6 +102,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr api.updateArchive(archive.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); onClose(); }, }); @@ -134,6 +141,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr updateMutation.mutate({ print_name: printName || undefined, printer_id: printerId, + project_id: projectId, notes: notes || undefined, tags: tags || undefined, failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined, @@ -191,6 +199,26 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr + {/* Project */} +
+ + +
+ {/* Notes */}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 22e9afae2..8f207276f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; @@ -23,6 +23,7 @@ export const defaultNavItems: NavItem[] = [ { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' }, { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, + { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 686c4be56..45e031401 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -7,6 +7,7 @@ export default { stats: 'Statistiken', profiles: 'Profile', maintenance: 'Wartung', + projects: 'Projekte', settings: 'Einstellungen', collapseSidebar: 'Seitenleiste einklappen', expandSidebar: 'Seitenleiste ausklappen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a853b8012..7b2f0347e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -7,6 +7,7 @@ export default { stats: 'Statistics', profiles: 'Profiles', maintenance: 'Maintenance', + projects: 'Projects', settings: 'Settings', collapseSidebar: 'Collapse sidebar', expandSidebar: 'Expand sidebar', diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 2688d28e3..00f7118e3 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -36,10 +36,14 @@ import { FileText, FileCode, MoreVertical, + FileSpreadsheet, + GitCompare, + Loader2, + FolderKanban, } from 'lucide-react'; import { api } from '../api/client'; import { useIsMobile } from '../hooks/useIsMobile'; -import type { Archive } from '../api/client'; +import type { Archive, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ModelViewerModal } from '../components/ModelViewerModal'; @@ -55,6 +59,7 @@ import { PhotoGalleryModal } from '../components/PhotoGalleryModal'; import { ProjectPageModal } from '../components/ProjectPageModal'; import { TimelapseViewer } from '../components/TimelapseViewer'; import { AddToQueueModal } from '../components/AddToQueueModal'; +import { CompareArchivesModal } from '../components/CompareArchivesModal'; import { useToast } from '../contexts/ToastContext'; function formatFileSize(bytes: number): string { @@ -86,12 +91,14 @@ function ArchiveCard({ isSelected, onSelect, selectionMode, + projects, }: { archive: Archive; printerName: string; isSelected: boolean; onSelect: (id: number) => void; selectionMode: boolean; + projects: ProjectListItem[] | undefined; }) { const queryClient = useQueryClient(); const { showToast } = useToast(); @@ -186,6 +193,18 @@ function ArchiveCard({ }, }); + const assignProjectMutation = useMutation({ + mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['archives'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + showToast('Project updated'); + }, + onError: () => { + showToast('Failed to update project', 'error'); + }, + }); + const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); @@ -311,6 +330,59 @@ function ArchiveCard({ icon: , onClick: () => setShowEdit(true), }, + ...(archive.project_id && archive.project_name ? [{ + label: `Go to Project: ${archive.project_name}`, + icon: , + onClick: () => window.location.href = '/projects', + }] : []), + { + label: 'Add to Project', + icon: , + onClick: () => {}, + submenu: (() => { + const items: ContextMenuItem[] = []; + + // Add "Remove from Project" if archive is in a project + if (archive.project_id) { + items.push({ + label: 'Remove from Project', + icon: , + onClick: () => assignProjectMutation.mutate(null), + }); + } + + // Add project options + if (!projects) { + items.push({ + label: 'Loading...', + icon: , + onClick: () => {}, + disabled: true, + }); + } else { + const activeProjects = projects.filter(p => p.status === 'active'); + if (activeProjects.length === 0) { + items.push({ + label: 'No projects available', + icon: , + onClick: () => {}, + disabled: true, + }); + } else { + activeProjects.forEach(p => { + items.push({ + label: p.name, + icon:
, + onClick: () => assignProjectMutation.mutate(p.id), + disabled: archive.project_id === p.id, + }); + }); + } + } + + return items; + })(), + }, { label: isSelected ? 'Deselect' : 'Select', icon: isSelected ? : , @@ -454,7 +526,21 @@ function ArchiveCard({

{archive.print_name || archive.filename}

-

{printerName}

+
+

{printerName}

+ {archive.project_name && ( + p.id === archive.project_id)?.color || '#6b7280'}20`, + color: projects?.find(p => p.id === archive.project_id)?.color || '#6b7280' + }} + title={`Project: ${archive.project_name}`} + > + {archive.project_name} + + )} +
{/* Stats */}
@@ -857,6 +943,9 @@ export function ArchivesPage() { const [viewMode, setViewMode] = useState('grid'); const [sortBy, setSortBy] = useState('date-desc'); const [collection, setCollection] = useState('all'); + const [showExportMenu, setShowExportMenu] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [showCompareModal, setShowCompareModal] = useState(false); const { data: archives, isLoading } = useQuery({ queryKey: ['archives', filterPrinter], @@ -868,6 +957,11 @@ export function ArchivesPage() { queryFn: api.getPrinters, }); + const { data: projects } = useQuery({ + queryKey: ['projects'], + queryFn: () => api.getProjects(), + }); + const bulkDeleteMutation = useMutation({ mutationFn: async (ids: number[]) => { await Promise.all(ids.map((id) => api.deleteArchive(id))); @@ -1176,6 +1270,93 @@ export function ArchivesPage() {

+ {/* Export dropdown */} +
+ + {showExportMenu && ( +
+ + +
+ )} +
+ {/* Compare button (only when 2-5 items selected) */} + {selectedIds.size >= 2 && selectedIds.size <= 5 && ( + + )} {!selectionMode && (
@@ -1560,6 +1742,18 @@ export function ArchivesPage() { onClose={() => setShowBatchTag(false)} /> )} + + {/* Compare Archives Modal */} + {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && ( + { + setShowCompareModal(false); + setSelectedIds(new Set()); + setIsSelectionMode(false); + }} + /> + )}
); } diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx new file mode 100644 index 000000000..9d7161709 --- /dev/null +++ b/frontend/src/pages/ProjectsPage.tsx @@ -0,0 +1,453 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FolderKanban, + Loader2, + Plus, + Trash2, + Edit3, + Archive, + ListTodo, + Package, +} from 'lucide-react'; +import { api } from '../api/client'; +import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client'; +import { Card, CardContent } from '../components/Card'; +import { Button } from '../components/Button'; +import { ConfirmModal } from '../components/ConfirmModal'; +import { useToast } from '../contexts/ToastContext'; + +const PROJECT_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#06b6d4', // cyan + '#3b82f6', // blue + '#8b5cf6', // violet + '#ec4899', // pink + '#6b7280', // gray +]; + +interface ProjectModalProps { + project?: ProjectListItem; + onClose: () => void; + onSave: (data: ProjectCreate | ProjectUpdate) => void; + isLoading: boolean; +} + +function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) { + const [name, setName] = useState(project?.name || ''); + const [description, setDescription] = useState(project?.description || ''); + const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]); + const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || ''); + const [status, setStatus] = useState(project?.status || 'active'); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ + name: name.trim(), + description: description.trim() || undefined, + color, + target_count: targetCount ? parseInt(targetCount, 10) : undefined, + ...(project && { status }), + }); + }; + + return ( +
+
+
+

+ {project ? 'Edit Project' : 'New Project'} +

+
+ +
+
+ + setName(e.target.value)} + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" + placeholder="e.g., Voron 2.4 Build" + required + /> +
+ +
+ +