ソースを参照

## 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
maziggy 9 ヶ月 前
コミット
d1518083fc
41 ファイル変更4276 行追加58 行削除
  1. 11 2
      README.md
  2. 138 0
      backend/app/api/routes/api_keys.py
  3. 322 4
      backend/app/api/routes/archives.py
  4. 428 0
      backend/app/api/routes/projects.py
  5. 332 0
      backend/app/api/routes/webhook.py
  6. 114 0
      backend/app/core/auth.py
  7. 67 1
      backend/app/core/database.py
  8. 4 1
      backend/app/main.py
  9. 4 0
      backend/app/models/__init__.py
  10. 29 0
      backend/app/models/api_key.py
  11. 5 0
      backend/app/models/archive.py
  12. 5 0
      backend/app/models/print_queue.py
  13. 32 0
      backend/app/models/project.py
  14. 46 0
      backend/app/schemas/api_key.py
  15. 3 0
      backend/app/schemas/archive.py
  16. 85 0
      backend/app/schemas/project.py
  17. 11 1
      backend/app/services/archive.py
  18. 278 0
      backend/app/services/archive_comparison.py
  19. 335 0
      backend/app/services/export.py
  20. 198 0
      backend/app/services/failure_analysis.py
  21. 7 0
      frontend/public/manifest.json
  22. 2 0
      frontend/src/App.tsx
  23. 327 1
      frontend/src/api/client.ts
  24. 190 0
      frontend/src/components/CompareArchivesModal.tsx
  25. 89 20
      frontend/src/components/ContextMenu.tsx
  26. 21 16
      frontend/src/components/Dashboard.tsx
  27. 29 1
      frontend/src/components/EditArchiveModal.tsx
  28. 2 1
      frontend/src/components/Layout.tsx
  29. 1 0
      frontend/src/i18n/locales/de.ts
  30. 1 0
      frontend/src/i18n/locales/en.ts
  31. 196 2
      frontend/src/pages/ArchivesPage.tsx
  32. 453 0
      frontend/src/pages/ProjectsPage.tsx
  33. 338 2
      frontend/src/pages/SettingsPage.tsx
  34. 161 4
      frontend/src/pages/StatsPage.tsx
  35. 3 0
      requirements.txt
  36. 0 0
      static/assets/index-BN5iZvNL.js
  37. 0 0
      static/assets/index-Bwdh7UG9.css
  38. 0 0
      static/assets/index-Dm9m4fYz.css
  39. 0 0
      static/assets/index-Y4EG-tDv.js
  40. 2 2
      static/index.html
  41. 7 0
      static/manifest.json

+ 11 - 2
README.md

@@ -45,16 +45,18 @@
 ### 📦 Print Archive
 ### 📦 Print Archive
 - Automatic 3MF archiving with metadata
 - Automatic 3MF archiving with metadata
 - 3D model preview (Three.js)
 - 3D model preview (Three.js)
-- Duplicate detection
+- Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Photo attachments & failure analysis
 - Re-print to any connected printer
 - Re-print to any connected printer
+- Archive comparison (side-by-side diff)
 
 
 ### 📊 Monitoring & Stats
 ### 📊 Monitoring & Stats
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - HMS error monitoring
 - HMS error monitoring
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
-- Cost analytics
+- Cost analytics & failure analysis
+- CSV/Excel export
 
 
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
@@ -63,6 +65,12 @@
 - Auto power-on before print
 - Auto power-on before print
 - Auto power-off after cooldown
 - 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
+
 </td>
 </td>
 <td width="50%" valign="top">
 <td width="50%" valign="top">
 
 
@@ -78,6 +86,7 @@
 - Bambu Cloud profile management
 - Bambu Cloud profile management
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - External sidebar links
 - External sidebar links
+- Webhooks & API keys
 
 
 ### 🛠️ Maintenance
 ### 🛠️ Maintenance
 - Maintenance scheduling & tracking
 - Maintenance scheduling & tracking

+ 138 - 0
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"}

+ 322 - 4
backend/app/api/routes/archives.py

@@ -54,6 +54,8 @@ def archive_to_response(
     data = {
     data = {
         "id": archive.id,
         "id": archive.id,
         "printer_id": archive.printer_id,
         "printer_id": archive.printer_id,
+        "project_id": archive.project_id,
+        "project_name": archive.project.name if archive.project else None,
         "filename": archive.filename,
         "filename": archive.filename,
         "file_path": archive.file_path,
         "file_path": archive.file_path,
         "file_size": archive.file_size,
         "file_size": archive.file_size,
@@ -98,6 +100,7 @@ def archive_to_response(
 @router.get("/", response_model=list[ArchiveResponse])
 @router.get("/", response_model=list[ArchiveResponse])
 async def list_archives(
 async def list_archives(
     printer_id: int | None = None,
     printer_id: int | None = None,
+    project_id: int | None = None,
     limit: int = 50,
     limit: int = 50,
     offset: int = 0,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
@@ -106,6 +109,7 @@ async def list_archives(
     service = ArchiveService(db)
     service = ArchiveService(db)
     archives = await service.list_archives(
     archives = await service.list_archives(
         printer_id=printer_id,
         printer_id=printer_id,
+        project_id=project_id,
         limit=limit,
         limit=limit,
         offset=offset,
         offset=offset,
     )
     )
@@ -121,6 +125,286 @@ async def list_archives(
     return result
     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)
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(db: AsyncSession = Depends(get_db)):
 async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     """Get statistics across all archives."""
     """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)
     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)
 @router.patch("/{archive_id}", response_model=ArchiveResponse)
 async def update_archive(
 async def update_archive(
     archive_id: int,
     archive_id: int,
     update_data: ArchiveUpdate,
     update_data: ArchiveUpdate,
     db: AsyncSession = Depends(get_db),
     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(
     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()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:
@@ -297,8 +607,16 @@ async def update_archive(
         setattr(archive, field, value)
         setattr(archive, field, value)
 
 
     await db.commit()
     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)
 @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)

+ 428 - 0
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"}

+ 332 - 0
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

+ 114 - 0
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}"
+        )

+ 67 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 
 async def init_db():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # 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:
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
         await conn.run_sync(Base.metadata.create_all)
@@ -191,6 +191,72 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 4 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
 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.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
 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(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.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(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)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 
 
 

+ 4 - 0
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.kprofile_note import KProfileNote
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification import NotificationLog
+from backend.app.models.project import Project
+from backend.app.models.api_key import APIKey
 
 
 __all__ = [
 __all__ = [
     "Printer",
     "Printer",
@@ -20,4 +22,6 @@ __all__ = [
     "KProfileNote",
     "KProfileNote",
     "NotificationTemplate",
     "NotificationTemplate",
     "NotificationLog",
     "NotificationLog",
+    "Project",
+    "APIKey",
 ]
 ]

+ 29 - 0
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

+ 5 - 0
backend/app/models/archive.py

@@ -10,6 +10,9 @@ class PrintArchive(Base):
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=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
     # File info
     filename: Mapped[str] = mapped_column(String(255))
     filename: Mapped[str] = mapped_column(String(255))
@@ -63,6 +66,8 @@ class PrintArchive(Base):
 
 
     # Relationships
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     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.printer import Printer  # noqa: E402, F811
+from backend.app.models.project import Project  # noqa: E402, F811

+ 5 - 0
backend/app/models/print_queue.py

@@ -19,6 +19,9 @@ class PrintQueueItem(Base):
     archive_id: Mapped[int] = mapped_column(
     archive_id: Mapped[int] = mapped_column(
         ForeignKey("print_archives.id", ondelete="CASCADE")
         ForeignKey("print_archives.id", ondelete="CASCADE")
     )
     )
+    project_id: Mapped[int | None] = mapped_column(
+        ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
+    )
 
 
     # Scheduling
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
@@ -44,7 +47,9 @@ class PrintQueueItem(Base):
     # Relationships
     # Relationships
     printer: Mapped["Printer"] = relationship()
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive"] = 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.printer import Printer  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.project import Project  # noqa: E402

+ 32 - 0
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

+ 46 - 0
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

+ 3 - 0
backend/app/schemas/archive.py

@@ -13,6 +13,7 @@ class ArchiveBase(BaseModel):
 
 
 class ArchiveUpdate(ArchiveBase):
 class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
     printer_id: int | None = None
+    project_id: int | None = None
 
 
 
 
 class ArchiveDuplicate(BaseModel):
 class ArchiveDuplicate(BaseModel):
@@ -26,6 +27,8 @@ class ArchiveDuplicate(BaseModel):
 class ArchiveResponse(BaseModel):
 class ArchiveResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None
     printer_id: int | None
+    project_id: int | None = None
+    project_name: str | None = None  # Included for convenience
     filename: str
     filename: str
     file_path: str
     file_path: str
     file_size: int
     file_size: int

+ 85 - 0
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]

+ 11 - 1
backend/app/services/archive.py

@@ -733,15 +733,25 @@ class ArchiveService:
     async def list_archives(
     async def list_archives(
         self,
         self,
         printer_id: int | None = None,
         printer_id: int | None = None,
+        project_id: int | None = None,
         limit: int = 50,
         limit: int = 50,
         offset: int = 0,
         offset: int = 0,
     ) -> list[PrintArchive]:
     ) -> list[PrintArchive]:
         """List archives with optional filtering."""
         """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:
         if printer_id:
             query = query.where(PrintArchive.printer_id == 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)
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         result = await self.db.execute(query)
         return list(result.scalars().all())
         return list(result.scalars().all())

+ 278 - 0
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]

+ 335 - 0
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()

+ 198 - 0
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,
+        }

+ 7 - 0
frontend/public/manifest.json

@@ -60,6 +60,13 @@
       "description": "View print queue",
       "description": "View print queue",
       "url": "/queue",
       "url": "/queue",
       "icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
       "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" }]
     }
     }
   ]
   ]
 }
 }

+ 2 - 0
frontend/src/App.tsx

@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { MaintenancePage } from './pages/MaintenancePage';
+import { ProjectsPage } from './pages/ProjectsPage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
@@ -46,6 +47,7 @@ function App() {
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
+                  <Route path="projects" element={<ProjectsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
                 </Route>

+ 327 - 1
frontend/src/api/client.ts

@@ -176,6 +176,8 @@ export interface ArchiveDuplicate {
 export interface Archive {
 export interface Archive {
   id: number;
   id: number;
   printer_id: number | null;
   printer_id: number | null;
+  project_id: number | null;
+  project_name: string | null;
   filename: string;
   filename: string;
   file_path: string;
   file_path: string;
   file_size: number;
   file_size: number;
@@ -229,6 +231,31 @@ export interface ArchiveStats {
   total_energy_cost: number;
   total_energy_cost: number;
 }
 }
 
 
+export interface FailureAnalysis {
+  period_days: number;
+  total_prints: number;
+  failed_prints: number;
+  failure_rate: number;
+  failures_by_reason: Record<string, number>;
+  failures_by_filament: Record<string, number>;
+  failures_by_printer: Record<string, number>;
+  failures_by_hour: Record<number, number>;
+  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 {
 export interface BulkUploadResult {
   uploaded: number;
   uploaded: number;
   failed: number;
   failed: number;
@@ -236,6 +263,159 @@ export interface BulkUploadResult {
   errors: Array<{ filename: string; error: string }>;
   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
 // Settings types
 export interface AppSettings {
 export interface AppSettings {
   auto_archive: boolean;
   auto_archive: boolean;
@@ -970,16 +1150,35 @@ export const api = {
     request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
     request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
 
 
   // Archives
   // Archives
-  getArchives: (printerId?: number, limit = 50, offset = 0) => {
+  getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (printerId) params.set('printer_id', String(printerId));
     if (printerId) params.set('printer_id', String(printerId));
+    if (projectId) params.set('project_id', String(projectId));
     params.set('limit', String(limit));
     params.set('limit', String(limit));
     params.set('offset', String(offset));
     params.set('offset', String(offset));
     return request<Archive[]>(`/archives/?${params}`);
     return request<Archive[]>(`/archives/?${params}`);
   },
   },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   getArchive: (id: number) => request<Archive>(`/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<Archive[]>(`/archives/search?${params}`);
+  },
+  rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }),
   updateArchive: (id: number, data: {
   updateArchive: (id: number, data: {
     printer_id?: number | null;
     printer_id?: number | null;
+    project_id?: number | null;
     print_name?: string;
     print_name?: string;
     is_favorite?: boolean;
     is_favorite?: boolean;
     tags?: string;
     tags?: string;
@@ -996,6 +1195,81 @@ export const api = {
   deleteArchive: (id: number) =>
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
   getArchiveStats: () => request<ArchiveStats>('/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<FailureAnalysis>(`/archives/analysis/failures?${params}`);
+  },
+  compareArchives: (archiveIds: number[]) =>
+    request<ArchiveComparison>(`/archives/compare?archive_ids=${archiveIds.join(',')}`),
+  findSimilarArchives: (archiveId: number, limit = 10) =>
+    request<SimilarArchive[]>(`/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) =>
   getArchiveDuplicates: (id: number) =>
     request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
     request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
   backfillContentHashes: () =>
   backfillContentHashes: () =>
@@ -1567,4 +1841,56 @@ export const api = {
   deleteExternalLinkIcon: (id: number) =>
   deleteExternalLinkIcon: (id: number) =>
     request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
     request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
   getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
   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<ProjectListItem[]>(`/projects/?${params}`);
+  },
+  getProject: (id: number) => request<Project>(`/projects/${id}`),
+  createProject: (data: ProjectCreate) =>
+    request<Project>('/projects/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateProject: (id: number, data: ProjectUpdate) =>
+    request<Project>(`/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<Archive[]>(`/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<APIKey[]>('/api-keys/'),
+  createAPIKey: (data: APIKeyCreate) =>
+    request<APIKeyCreateResponse>('/api-keys/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateAPIKey: (id: number, data: APIKeyUpdate) =>
+    request<APIKey>(`/api-keys/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteAPIKey: (id: number) =>
+    request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),
 };
 };

+ 190 - 0
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 (
+    <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col border border-bambu-dark-tertiary" onClick={(e) => e.stopPropagation()}>
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <h3 className="text-lg font-semibold text-white">
+            Compare Archives ({archiveIds.length})
+          </h3>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white p-1"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-auto p-4 bg-bambu-dark-secondary">
+          {isLoading ? (
+            <div className="flex items-center justify-center py-12">
+              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+            </div>
+          ) : error ? (
+            <div className="text-center py-12 text-red-400">
+              <AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" />
+              <p>Failed to load comparison</p>
+              <p className="text-sm text-bambu-gray mt-2">
+                {error instanceof Error ? error.message : 'Unknown error'}
+              </p>
+            </div>
+          ) : comparison ? (
+            <ComparisonContent comparison={comparison} />
+          ) : null}
+        </div>
+
+        {/* Footer */}
+        <div className="p-4 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose} className="w-full">
+            Close
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ComparisonContent({ comparison }: { comparison: ArchiveComparison }) {
+  return (
+    <div className="space-y-6">
+      {/* Archive Headers */}
+      <div className="overflow-x-auto">
+        <table className="w-full">
+          <thead>
+            <tr>
+              <th className="text-left text-sm text-bambu-gray font-medium pb-2 pr-4 min-w-[150px]">
+                Setting
+              </th>
+              {comparison.archives.map((archive) => (
+                <th
+                  key={archive.id}
+                  className="text-left text-sm font-medium pb-2 px-2 min-w-[120px]"
+                >
+                  <div className="text-white truncate max-w-[150px]" title={archive.print_name}>
+                    {archive.print_name}
+                  </div>
+                  <div className={`text-xs ${
+                    archive.status === 'completed' ? 'text-bambu-green' :
+                    archive.status === 'failed' ? 'text-red-400' : 'text-bambu-gray'
+                  }`}>
+                    {archive.status}
+                  </div>
+                </th>
+              ))}
+            </tr>
+          </thead>
+          <tbody className="divide-y divide-bambu-gray/20">
+            {comparison.comparison.map((field) => (
+              <tr
+                key={field.field}
+                className={field.has_difference ? 'bg-yellow-500/5' : ''}
+              >
+                <td className="py-2 pr-4 text-sm">
+                  <div className="flex items-center gap-2">
+                    {field.has_difference && (
+                      <AlertTriangle className="w-3 h-3 text-yellow-400 flex-shrink-0" />
+                    )}
+                    <span className={field.has_difference ? 'text-yellow-400' : 'text-bambu-gray'}>
+                      {field.label}
+                    </span>
+                  </div>
+                </td>
+                {field.values.map((value, idx) => (
+                  <td key={idx} className="py-2 px-2 text-sm text-white">
+                    {value ?? <span className="text-bambu-gray/50">-</span>}
+                    {field.unit && value !== null && (
+                      <span className="text-bambu-gray ml-1">{field.unit}</span>
+                    )}
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+
+      {/* Differences Summary */}
+      {comparison.differences.length > 0 && (
+        <div className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
+          <h4 className="text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2">
+            <AlertTriangle className="w-4 h-4" />
+            {comparison.differences.length} Difference{comparison.differences.length > 1 ? 's' : ''} Found
+          </h4>
+          <ul className="text-sm text-white/80 space-y-1">
+            {comparison.differences.slice(0, 5).map((diff) => (
+              <li key={diff.field}>
+                <span className="text-yellow-400">{diff.label}</span>: {diff.values.join(' vs ')} {diff.unit || ''}
+              </li>
+            ))}
+            {comparison.differences.length > 5 && (
+              <li className="text-bambu-gray">
+                ...and {comparison.differences.length - 5} more
+              </li>
+            )}
+          </ul>
+        </div>
+      )}
+
+      {/* Success Correlation */}
+      {comparison.success_correlation.has_both_outcomes ? (
+        <div className="p-4 bg-bambu-dark rounded-lg">
+          <h4 className="text-sm font-medium text-white mb-3 flex items-center gap-2">
+            <Check className="w-4 h-4 text-bambu-green" />
+            Success/Failure Analysis
+          </h4>
+          <div className="flex items-center gap-4 text-sm mb-3">
+            <span className="text-bambu-green">
+              {comparison.success_correlation.successful_count} successful
+            </span>
+            <span className="text-red-400">
+              {comparison.success_correlation.failed_count} failed
+            </span>
+          </div>
+          {comparison.success_correlation.insights && comparison.success_correlation.insights.length > 0 ? (
+            <div className="space-y-2">
+              {comparison.success_correlation.insights.map((insight) => (
+                <div key={insight.field} className="text-sm p-2 bg-bambu-dark-secondary rounded">
+                  <span className="text-white font-medium">{insight.label}:</span>{' '}
+                  <span className="text-white/80">{insight.insight}</span>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-sm text-bambu-gray">No clear correlations found between settings and outcomes.</p>
+          )}
+        </div>
+      ) : (
+        <div className="p-4 bg-bambu-dark rounded-lg text-sm text-bambu-gray">
+          <p>{comparison.success_correlation.message || 'Need both successful and failed prints for correlation analysis.'}</p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 89 - 20
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 {
 export interface ContextMenuItem {
   label: string;
   label: string;
@@ -7,6 +8,7 @@ export interface ContextMenuItem {
   danger?: boolean;
   danger?: boolean;
   disabled?: boolean;
   disabled?: boolean;
   divider?: boolean;
   divider?: boolean;
+  submenu?: ContextMenuItem[];
 }
 }
 
 
 interface ContextMenuProps {
 interface ContextMenuProps {
@@ -18,6 +20,8 @@ interface ContextMenuProps {
 
 
 export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
 export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
   const menuRef = useRef<HTMLDivElement>(null);
   const menuRef = useRef<HTMLDivElement>(null);
+  const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
+  const submenuTimeoutRef = useRef<number | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     const handleClickOutside = (e: MouseEvent) => {
     const handleClickOutside = (e: MouseEvent) => {
@@ -44,6 +48,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
       document.removeEventListener('mousedown', handleClickOutside);
       document.removeEventListener('mousedown', handleClickOutside);
       document.removeEventListener('keydown', handleEscape);
       document.removeEventListener('keydown', handleEscape);
       document.removeEventListener('scroll', handleScroll, true);
       document.removeEventListener('scroll', handleScroll, true);
+      if (submenuTimeoutRef.current) {
+        clearTimeout(submenuTimeoutRef.current);
+      }
     };
     };
   }, [onClose]);
   }, [onClose]);
 
 
@@ -69,10 +76,24 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
     }
     }
   }, [x, y]);
   }, [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 (
   return (
     <div
     <div
       ref={menuRef}
       ref={menuRef}
-      className="fixed z-50 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden"
+      className="fixed z-50 min-w-[180px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1"
       style={{ left: x, top: y }}
       style={{ left: x, top: y }}
     >
     >
       {items.map((item, index) => {
       {items.map((item, index) => {
@@ -80,27 +101,75 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
           return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
           return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
         }
         }
 
 
+        const hasSubmenu = item.submenu && item.submenu.length > 0;
+
         return (
         return (
-          <button
+          <div
             key={index}
             key={index}
-            onClick={() => {
-              if (!item.disabled) {
-                item.onClick();
-                onClose();
-              }
-            }}
-            disabled={item.disabled}
-            className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
-              item.disabled
-                ? 'text-bambu-gray cursor-not-allowed'
-                : item.danger
-                ? 'text-red-400 hover:bg-red-400/10'
-                : 'text-white hover:bg-bambu-dark-tertiary'
-            }`}
+            className="relative"
+            onMouseEnter={() => hasSubmenu && handleMouseEnterSubmenu(index)}
+            onMouseLeave={() => hasSubmenu && handleMouseLeaveSubmenu()}
           >
           >
-            {item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
-            {item.label}
-          </button>
+            <button
+              onClick={() => {
+                if (hasSubmenu) {
+                  // Toggle submenu on click as well
+                  setActiveSubmenu(activeSubmenu === index ? null : index);
+                } else if (!item.disabled) {
+                  item.onClick();
+                  onClose();
+                }
+              }}
+              disabled={item.disabled}
+              className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
+                item.disabled
+                  ? 'text-bambu-gray cursor-not-allowed'
+                  : item.danger
+                  ? 'text-red-400 hover:bg-red-400/10'
+                  : 'text-white hover:bg-bambu-dark-tertiary'
+              } ${hasSubmenu && activeSubmenu === index ? 'bg-bambu-dark-tertiary' : ''}`}
+            >
+              {item.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{item.icon}</span>}
+              <span className="flex-1">{item.label}</span>
+              {hasSubmenu && <ChevronRight className="w-4 h-4 text-bambu-gray" />}
+            </button>
+            {/* Submenu */}
+            {hasSubmenu && activeSubmenu === index && (
+              <div
+                className="absolute left-full top-0 ml-1 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60]"
+                onMouseEnter={() => {
+                  if (submenuTimeoutRef.current) {
+                    clearTimeout(submenuTimeoutRef.current);
+                    submenuTimeoutRef.current = null;
+                  }
+                }}
+                onMouseLeave={() => handleMouseLeaveSubmenu()}
+              >
+                {item.submenu!.map((subItem, subIndex) => (
+                  <button
+                    key={subIndex}
+                    onClick={() => {
+                      if (!subItem.disabled) {
+                        subItem.onClick();
+                        onClose();
+                      }
+                    }}
+                    disabled={subItem.disabled}
+                    className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
+                      subItem.disabled
+                        ? 'text-bambu-gray cursor-not-allowed'
+                        : subItem.danger
+                        ? 'text-red-400 hover:bg-red-400/10'
+                        : 'text-white hover:bg-bambu-dark-tertiary'
+                    }`}
+                  >
+                    {subItem.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{subItem.icon}</span>}
+                    {subItem.label}
+                  </button>
+                ))}
+              </div>
+            )}
+          </div>
         );
         );
       })}
       })}
     </div>
     </div>

+ 21 - 16
frontend/src/components/Dashboard.tsx

@@ -31,6 +31,8 @@ interface DashboardProps {
   widgets: DashboardWidget[];
   widgets: DashboardWidget[];
   storageKey: string;
   storageKey: string;
   columns?: number;
   columns?: number;
+  hideControls?: boolean;
+  onResetLayout?: () => void;
 }
 }
 
 
 interface LayoutState {
 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
   // Build default sizes from widget definitions
   const getDefaultSizes = () => {
   const getDefaultSizes = () => {
     const sizes: Record<string, 1 | 2 | 4> = {};
     const sizes: Record<string, 1 | 2 | 4> = {};
@@ -233,6 +235,7 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps)
       sizes: getDefaultSizes(),
       sizes: getDefaultSizes(),
     };
     };
     setLayout(defaultLayout);
     setLayout(defaultLayout);
+    onResetLayout?.();
   };
   };
 
 
   // Get ordered widgets
   // Get ordered widgets
@@ -246,22 +249,24 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps)
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
       {/* Dashboard Controls */}
       {/* Dashboard Controls */}
-      <div className="flex items-center justify-end gap-2">
-        {hiddenWidgets.length > 0 && (
-          <Button
-            variant="secondary"
-            size="sm"
-            onClick={() => setShowHiddenPanel(!showHiddenPanel)}
-          >
-            <Eye className="w-4 h-4" />
-            {hiddenWidgets.length} Hidden
+      {!hideControls && (
+        <div className="flex items-center justify-end gap-2">
+          {hiddenWidgets.length > 0 && (
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setShowHiddenPanel(!showHiddenPanel)}
+            >
+              <Eye className="w-4 h-4" />
+              {hiddenWidgets.length} Hidden
+            </Button>
+          )}
+          <Button variant="secondary" size="sm" onClick={resetLayout}>
+            <RotateCcw className="w-4 h-4" />
+            Reset Layout
           </Button>
           </Button>
-        )}
-        <Button variant="secondary" size="sm" onClick={resetLayout}>
-          <RotateCcw className="w-4 h-4" />
-          Reset Layout
-        </Button>
-      </div>
+        </div>
+      )}
 
 
       {/* Hidden Widgets Panel */}
       {/* Hidden Widgets Panel */}
       {showHiddenPanel && hiddenWidgets.length > 0 && (
       {showHiddenPanel && hiddenWidgets.length > 0 && (

+ 29 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 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 { api } from '../api/client';
 import type { Archive } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -37,6 +37,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [printName, setPrintName] = useState(archive.print_name || '');
   const [printName, setPrintName] = useState(archive.print_name || '');
   const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
   const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
+  const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
   const [notes, setNotes] = useState(archive.notes || '');
   const [notes, setNotes] = useState(archive.notes || '');
   const [tags, setTags] = useState(archive.tags || '');
   const [tags, setTags] = useState(archive.tags || '');
   const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
   const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
@@ -52,6 +53,11 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  const { data: projects } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => api.getProjects(),
+  });
+
   // Get all archives to extract existing tags if not provided
   // Get all archives to extract existing tags if not provided
   const { data: archives } = useQuery({
   const { data: archives } = useQuery({
     queryKey: ['archives'],
     queryKey: ['archives'],
@@ -96,6 +102,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       api.updateArchive(archive.id, data),
       api.updateArchive(archive.id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
       onClose();
       onClose();
     },
     },
   });
   });
@@ -134,6 +141,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     updateMutation.mutate({
     updateMutation.mutate({
       print_name: printName || undefined,
       print_name: printName || undefined,
       printer_id: printerId,
       printer_id: printerId,
+      project_id: projectId,
       notes: notes || undefined,
       notes: notes || undefined,
       tags: tags || undefined,
       tags: tags || undefined,
       failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
       failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
@@ -191,6 +199,26 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             </select>
             </select>
           </div>
           </div>
 
 
+          {/* Project */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <FolderKanban className="w-4 h-4 inline mr-1" />
+              Project
+            </label>
+            <select
+              value={projectId ?? ''}
+              onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            >
+              <option value="">No project</option>
+              {projects?.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+          </div>
+
           {/* Notes */}
           {/* Notes */}
           <div>
           <div>
             <label className="block text-sm text-bambu-gray mb-1">Notes</label>
             <label className="block text-sm text-bambu-gray mb-1">Notes</label>

+ 2 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 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 { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -23,6 +23,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
   { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { 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' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 ];
 
 

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

@@ -7,6 +7,7 @@ export default {
     stats: 'Statistiken',
     stats: 'Statistiken',
     profiles: 'Profile',
     profiles: 'Profile',
     maintenance: 'Wartung',
     maintenance: 'Wartung',
+    projects: 'Projekte',
     settings: 'Einstellungen',
     settings: 'Einstellungen',
     collapseSidebar: 'Seitenleiste einklappen',
     collapseSidebar: 'Seitenleiste einklappen',
     expandSidebar: 'Seitenleiste ausklappen',
     expandSidebar: 'Seitenleiste ausklappen',

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

@@ -7,6 +7,7 @@ export default {
     stats: 'Statistics',
     stats: 'Statistics',
     profiles: 'Profiles',
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
+    projects: 'Projects',
     settings: 'Settings',
     settings: 'Settings',
     collapseSidebar: 'Collapse sidebar',
     collapseSidebar: 'Collapse sidebar',
     expandSidebar: 'Expand sidebar',
     expandSidebar: 'Expand sidebar',

+ 196 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -36,10 +36,14 @@ import {
   FileText,
   FileText,
   FileCode,
   FileCode,
   MoreVertical,
   MoreVertical,
+  FileSpreadsheet,
+  GitCompare,
+  Loader2,
+  FolderKanban,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { useIsMobile } from '../hooks/useIsMobile';
 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 { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
@@ -55,6 +59,7 @@ import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { AddToQueueModal } from '../components/AddToQueueModal';
 import { AddToQueueModal } from '../components/AddToQueueModal';
+import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
@@ -86,12 +91,14 @@ function ArchiveCard({
   isSelected,
   isSelected,
   onSelect,
   onSelect,
   selectionMode,
   selectionMode,
+  projects,
 }: {
 }: {
   archive: Archive;
   archive: Archive;
   printerName: string;
   printerName: string;
   isSelected: boolean;
   isSelected: boolean;
   onSelect: (id: number) => void;
   onSelect: (id: number) => void;
   selectionMode: boolean;
   selectionMode: boolean;
+  projects: ProjectListItem[] | undefined;
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   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) => {
   const handleContextMenu = (e: React.MouseEvent) => {
     e.preventDefault();
     e.preventDefault();
     setContextMenu({ x: e.clientX, y: e.clientY });
     setContextMenu({ x: e.clientX, y: e.clientY });
@@ -311,6 +330,59 @@ function ArchiveCard({
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
     },
     },
+    ...(archive.project_id && archive.project_name ? [{
+      label: `Go to Project: ${archive.project_name}`,
+      icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
+      onClick: () => window.location.href = '/projects',
+    }] : []),
+    {
+      label: 'Add to Project',
+      icon: <FolderKanban className="w-4 h-4" />,
+      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: <X className="w-4 h-4" />,
+            onClick: () => assignProjectMutation.mutate(null),
+          });
+        }
+
+        // Add project options
+        if (!projects) {
+          items.push({
+            label: 'Loading...',
+            icon: <Loader2 className="w-4 h-4 animate-spin" />,
+            onClick: () => {},
+            disabled: true,
+          });
+        } else {
+          const activeProjects = projects.filter(p => p.status === 'active');
+          if (activeProjects.length === 0) {
+            items.push({
+              label: 'No projects available',
+              icon: <FolderKanban className="w-4 h-4 opacity-50" />,
+              onClick: () => {},
+              disabled: true,
+            });
+          } else {
+            activeProjects.forEach(p => {
+              items.push({
+                label: p.name,
+                icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
+                onClick: () => assignProjectMutation.mutate(p.id),
+                disabled: archive.project_id === p.id,
+              });
+            });
+          }
+        }
+
+        return items;
+      })(),
+    },
     {
     {
       label: isSelected ? 'Deselect' : 'Select',
       label: isSelected ? 'Deselect' : 'Select',
       icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
       icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
@@ -454,7 +526,21 @@ function ArchiveCard({
         <h3 className="font-medium text-white mb-1 truncate">
         <h3 className="font-medium text-white mb-1 truncate">
           {archive.print_name || archive.filename}
           {archive.print_name || archive.filename}
         </h3>
         </h3>
-        <p className="text-xs text-bambu-gray mb-3">{printerName}</p>
+        <div className="flex items-center gap-2 mb-3">
+          <p className="text-xs text-bambu-gray">{printerName}</p>
+          {archive.project_name && (
+            <span
+              className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"
+              style={{
+                backgroundColor: `${projects?.find(p => 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}
+            </span>
+          )}
+        </div>
 
 
         {/* Stats */}
         {/* Stats */}
         <div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
         <div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
@@ -857,6 +943,9 @@ export function ArchivesPage() {
   const [viewMode, setViewMode] = useState<ViewMode>('grid');
   const [viewMode, setViewMode] = useState<ViewMode>('grid');
   const [sortBy, setSortBy] = useState<SortOption>('date-desc');
   const [sortBy, setSortBy] = useState<SortOption>('date-desc');
   const [collection, setCollection] = useState<Collection>('all');
   const [collection, setCollection] = useState<Collection>('all');
+  const [showExportMenu, setShowExportMenu] = useState(false);
+  const [isExporting, setIsExporting] = useState(false);
+  const [showCompareModal, setShowCompareModal] = useState(false);
 
 
   const { data: archives, isLoading } = useQuery({
   const { data: archives, isLoading } = useQuery({
     queryKey: ['archives', filterPrinter],
     queryKey: ['archives', filterPrinter],
@@ -868,6 +957,11 @@ export function ArchivesPage() {
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  const { data: projects } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => api.getProjects(),
+  });
+
   const bulkDeleteMutation = useMutation({
   const bulkDeleteMutation = useMutation({
     mutationFn: async (ids: number[]) => {
     mutationFn: async (ids: number[]) => {
       await Promise.all(ids.map((id) => api.deleteArchive(id)));
       await Promise.all(ids.map((id) => api.deleteArchive(id)));
@@ -1176,6 +1270,93 @@ export function ArchivesPage() {
           </p>
           </p>
         </div>
         </div>
         <div className="flex items-center gap-3">
         <div className="flex items-center gap-3">
+          {/* Export dropdown */}
+          <div className="relative">
+            <Button
+              variant="secondary"
+              onClick={() => setShowExportMenu(!showExportMenu)}
+              disabled={isExporting}
+            >
+              {isExporting ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <FileSpreadsheet className="w-4 h-4" />
+              )}
+              Export
+            </Button>
+            {showExportMenu && (
+              <div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
+                <button
+                  className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg"
+                  onClick={async () => {
+                    setShowExportMenu(false);
+                    setIsExporting(true);
+                    try {
+                      const { blob, filename } = await api.exportArchives({
+                        format: 'csv',
+                        printerId: filterPrinter || undefined,
+                        status: collection === 'failed' ? 'failed' : undefined,
+                        search: search || undefined,
+                      });
+                      const url = URL.createObjectURL(blob);
+                      const a = document.createElement('a');
+                      a.href = url;
+                      a.download = filename;
+                      a.click();
+                      URL.revokeObjectURL(url);
+                      showToast('Export downloaded');
+                    } catch (err) {
+                      showToast('Export failed', 'error');
+                    } finally {
+                      setIsExporting(false);
+                    }
+                  }}
+                >
+                  <FileText className="w-4 h-4" />
+                  Export as CSV
+                </button>
+                <button
+                  className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
+                  onClick={async () => {
+                    setShowExportMenu(false);
+                    setIsExporting(true);
+                    try {
+                      const { blob, filename } = await api.exportArchives({
+                        format: 'xlsx',
+                        printerId: filterPrinter || undefined,
+                        status: collection === 'failed' ? 'failed' : undefined,
+                        search: search || undefined,
+                      });
+                      const url = URL.createObjectURL(blob);
+                      const a = document.createElement('a');
+                      a.href = url;
+                      a.download = filename;
+                      a.click();
+                      URL.revokeObjectURL(url);
+                      showToast('Export downloaded');
+                    } catch (err) {
+                      showToast('Export failed', 'error');
+                    } finally {
+                      setIsExporting(false);
+                    }
+                  }}
+                >
+                  <FileSpreadsheet className="w-4 h-4" />
+                  Export as Excel
+                </button>
+              </div>
+            )}
+          </div>
+          {/* Compare button (only when 2-5 items selected) */}
+          {selectedIds.size >= 2 && selectedIds.size <= 5 && (
+            <Button
+              variant="secondary"
+              onClick={() => setShowCompareModal(true)}
+            >
+              <GitCompare className="w-4 h-4" />
+              Compare ({selectedIds.size})
+            </Button>
+          )}
           {!selectionMode && (
           {!selectionMode && (
             <Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
             <Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
               <CheckSquare className="w-4 h-4" />
               <CheckSquare className="w-4 h-4" />
@@ -1402,6 +1583,7 @@ export function ArchivesPage() {
               isSelected={selectedIds.has(archive.id)}
               isSelected={selectedIds.has(archive.id)}
               onSelect={toggleSelect}
               onSelect={toggleSelect}
               selectionMode={selectionMode}
               selectionMode={selectionMode}
+              projects={projects}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -1560,6 +1742,18 @@ export function ArchivesPage() {
           onClose={() => setShowBatchTag(false)}
           onClose={() => setShowBatchTag(false)}
         />
         />
       )}
       )}
+
+      {/* Compare Archives Modal */}
+      {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && (
+        <CompareArchivesModal
+          archiveIds={Array.from(selectedIds)}
+          onClose={() => {
+            setShowCompareModal(false);
+            setSelectedIds(new Set());
+            setIsSelectionMode(false);
+          }}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 453 - 0
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 (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">
+            {project ? 'Edit Project' : 'New Project'}
+          </h2>
+        </div>
+
+        <form onSubmit={handleSubmit} className="p-4 space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Name
+            </label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => 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
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Description
+            </label>
+            <textarea
+              value={description}
+              onChange={(e) => setDescription(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 resize-none"
+              placeholder="Optional description..."
+              rows={2}
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Color
+            </label>
+            <div className="flex gap-2 flex-wrap">
+              {PROJECT_COLORS.map((c) => (
+                <button
+                  key={c}
+                  type="button"
+                  onClick={() => setColor(c)}
+                  className={`w-8 h-8 rounded-full transition-transform ${
+                    color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
+                  }`}
+                  style={{ backgroundColor: c }}
+                />
+              ))}
+            </div>
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Target Print Count (optional)
+            </label>
+            <input
+              type="number"
+              value={targetCount}
+              onChange={(e) => setTargetCount(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., 50 parts to print"
+              min="1"
+            />
+          </div>
+
+          {project && (
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Status
+              </label>
+              <select
+                value={status}
+                onChange={(e) => setStatus(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+              >
+                <option value="active">Active</option>
+                <option value="completed">Completed</option>
+                <option value="archived">Archived</option>
+              </select>
+            </div>
+          )}
+
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={!name.trim() || isLoading}>
+              {isLoading ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : project ? (
+                'Save'
+              ) : (
+                'Create'
+              )}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
+
+interface ProjectCardProps {
+  project: ProjectListItem;
+  onClick: () => void;
+  onEdit: () => void;
+  onDelete: () => void;
+}
+
+function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
+  const progressPercent = project.progress_percent ?? 0;
+  const isCompleted = project.status === 'completed';
+  const isArchived = project.status === 'archived';
+
+  return (
+    <Card className="hover:border-bambu-gray/30 transition-colors cursor-pointer" onClick={onClick}>
+      <CardContent className="p-4">
+        <div className="flex items-start justify-between mb-3">
+          <div className="flex items-center gap-3">
+            <div
+              className="w-3 h-3 rounded-full flex-shrink-0"
+              style={{ backgroundColor: project.color || '#6b7280' }}
+            />
+            <div>
+              <h3 className="font-medium text-white">{project.name}</h3>
+              {project.description && (
+                <p className="text-sm text-bambu-gray/70 mt-0.5 line-clamp-1">
+                  {project.description}
+                </p>
+              )}
+            </div>
+          </div>
+          <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
+            {isCompleted && (
+              <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded">
+                Completed
+              </span>
+            )}
+            {isArchived && (
+              <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded">
+                Archived
+              </span>
+            )}
+            <Button variant="ghost" size="sm" onClick={onEdit} className="p-1">
+              <Edit3 className="w-4 h-4" />
+            </Button>
+            <Button variant="ghost" size="sm" onClick={onDelete} className="p-1 text-red-400 hover:text-red-300">
+              <Trash2 className="w-4 h-4" />
+            </Button>
+          </div>
+        </div>
+
+        {/* Progress bar */}
+        {project.target_count && (
+          <div className="mb-3">
+            <div className="flex justify-between text-xs text-bambu-gray mb-1">
+              <span>{project.archive_count} / {project.target_count} prints</span>
+              <span>{progressPercent.toFixed(0)}%</span>
+            </div>
+            <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
+              <div
+                className="h-full transition-all duration-300"
+                style={{
+                  width: `${Math.min(progressPercent, 100)}%`,
+                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                }}
+              />
+            </div>
+          </div>
+        )}
+
+        {/* Archive thumbnails */}
+        {project.archives && project.archives.length > 0 && (
+          <div className="mb-3">
+            <div className="flex gap-2">
+              {project.archives.slice(0, 5).map((archive) => (
+                <a
+                  key={archive.id}
+                  href={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
+                  onClick={(e) => e.stopPropagation()}
+                  className="relative w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                  title={archive.print_name || 'Unknown'}
+                >
+                  {archive.thumbnail_path ? (
+                    <img
+                      src={`/api/v1/archives/${archive.id}/thumbnail`}
+                      alt={archive.print_name || ''}
+                      className="w-full h-full object-cover"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+                      <Package className="w-6 h-6" />
+                    </div>
+                  )}
+                  {archive.status === 'failed' && (
+                    <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
+                      <span className="text-white text-xs font-bold">✗</span>
+                    </div>
+                  )}
+                </a>
+              ))}
+              {project.archive_count > 5 && (
+                <div className="w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-sm text-bambu-gray border border-bambu-dark-tertiary">
+                  +{project.archive_count - 5}
+                </div>
+              )}
+            </div>
+          </div>
+        )}
+
+        {/* Stats */}
+        <div className="flex items-center gap-4 text-sm text-bambu-gray">
+          <div className="flex items-center gap-1" title="Archives">
+            <Archive className="w-4 h-4" />
+            <span>{project.archive_count}</span>
+          </div>
+          <div className="flex items-center gap-1" title="Queued">
+            <ListTodo className="w-4 h-4" />
+            <span>{project.queue_count}</span>
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+export function ProjectsPage() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [showModal, setShowModal] = useState(false);
+  const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
+  const [statusFilter, setStatusFilter] = useState<string>('active');
+  const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
+
+  const { data: projects, isLoading } = useQuery({
+    queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
+    queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
+  });
+
+  const createMutation = useMutation({
+    mutationFn: (data: ProjectCreate) => api.createProject(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      setShowModal(false);
+      showToast('Project created', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
+      api.updateProject(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      setShowModal(false);
+      setEditingProject(undefined);
+      showToast('Project updated', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteProject(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      setDeleteConfirm(null);
+      showToast('Project deleted', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleSave = (data: ProjectCreate | ProjectUpdate) => {
+    if (editingProject) {
+      updateMutation.mutate({ id: editingProject.id, data });
+    } else {
+      createMutation.mutate(data as ProjectCreate);
+    }
+  };
+
+  const handleEdit = (project: ProjectListItem) => {
+    setEditingProject(project);
+    setShowModal(true);
+  };
+
+  const handleClick = (project: ProjectListItem) => {
+    // Open edit modal when clicking on card
+    handleEdit(project);
+  };
+
+  const handleDeleteClick = (id: number) => {
+    setDeleteConfirm(id);
+  };
+
+  const handleDeleteConfirm = () => {
+    if (deleteConfirm !== null) {
+      deleteMutation.mutate(deleteConfirm);
+    }
+  };
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-3">
+          <FolderKanban className="w-6 h-6 text-bambu-green" />
+          <h1 className="text-2xl font-bold text-white">Projects</h1>
+        </div>
+        <Button onClick={() => setShowModal(true)}>
+          <Plus className="w-4 h-4 mr-2" />
+          New Project
+        </Button>
+      </div>
+
+      {/* Filters */}
+      <div className="flex gap-2">
+        {['active', 'completed', 'archived', 'all'].map((status) => (
+          <button
+            key={status}
+            onClick={() => setStatusFilter(status)}
+            className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
+              statusFilter === status
+                ? 'bg-bambu-green text-white'
+                : 'bg-bambu-card text-bambu-gray hover:bg-bambu-gray/20'
+            }`}
+          >
+            {status.charAt(0).toUpperCase() + status.slice(1)}
+          </button>
+        ))}
+      </div>
+
+      {/* Content */}
+      {isLoading ? (
+        <div className="flex items-center justify-center py-12">
+          <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+        </div>
+      ) : projects?.length === 0 ? (
+        <Card>
+          <CardContent className="py-12 text-center">
+            <FolderKanban className="w-12 h-12 text-bambu-gray/50 mx-auto mb-4" />
+            <p className="text-bambu-gray">No projects found</p>
+            <p className="text-bambu-gray/70 text-sm mt-1">
+              Create a project to group related prints together
+            </p>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          {projects?.map((project) => (
+            <ProjectCard
+              key={project.id}
+              project={project}
+              onClick={() => handleClick(project)}
+              onEdit={() => handleEdit(project)}
+              onDelete={() => handleDeleteClick(project.id)}
+            />
+          ))}
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deleteConfirm !== null && (
+        <ConfirmModal
+          title="Delete Project"
+          message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
+          confirmText="Delete Project"
+          variant="danger"
+          onConfirm={handleDeleteConfirm}
+          onCancel={() => setDeleteConfirm(null)}
+        />
+      )}
+
+      {/* Modal */}
+      {showModal && (
+        <ProjectModal
+          project={editingProject}
+          onClose={() => {
+            setShowModal(false);
+            setEditingProject(undefined);
+          }}
+          onSave={handleSave}
+          isLoading={createMutation.isPending || updateMutation.isPending}
+        />
+      )}
+    </div>
+  );
+}

+ 338 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
@@ -33,7 +33,16 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
-  const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
+  const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys'>('general');
+  const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
+  const [newAPIKeyName, setNewAPIKeyName] = useState('');
+  const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
+    can_queue: true,
+    can_control_printer: false,
+    can_read_status: true,
+  });
+  const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
+  const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
 
 
   // Confirm modal states
   // Confirm modal states
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
@@ -113,6 +122,38 @@ export function SettingsPage() {
     queryFn: api.getNotificationProviders,
     queryFn: api.getNotificationProviders,
   });
   });
 
 
+  const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
+    queryKey: ['api-keys'],
+    queryFn: api.getAPIKeys,
+    enabled: activeTab === 'apikeys',
+  });
+
+  const createAPIKeyMutation = useMutation({
+    mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean }) =>
+      api.createAPIKey(data),
+    onSuccess: (data) => {
+      setCreatedAPIKey(data.key || null);
+      setShowCreateAPIKey(false);
+      setNewAPIKeyName('');
+      queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+      showToast('API key created');
+    },
+    onError: (error: Error) => {
+      showToast(`Failed to create API key: ${error.message}`, 'error');
+    },
+  });
+
+  const deleteAPIKeyMutation = useMutation({
+    mutationFn: (id: number) => api.deleteAPIKey(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+      showToast('API key deleted');
+    },
+    onError: (error: Error) => {
+      showToast(`Failed to delete API key: ${error.message}`, 'error');
+    },
+  });
+
   const { data: printers } = useQuery({
   const { data: printers } = useQuery({
     queryKey: ['printers'],
     queryKey: ['printers'],
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
@@ -362,6 +403,22 @@ export function SettingsPage() {
             </span>
             </span>
           )}
           )}
         </button>
         </button>
+        <button
+          onClick={() => setActiveTab('apikeys')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+            activeTab === 'apikeys'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-white border-transparent'
+          }`}
+        >
+          <Key className="w-4 h-4" />
+          API Keys
+          {apiKeys && apiKeys.length > 0 && (
+            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
+              {apiKeys.length}
+            </span>
+          )}
+        </button>
       </div>
       </div>
 
 
       {/* General Tab */}
       {/* General Tab */}
@@ -1343,6 +1400,285 @@ export function SettingsPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {/* API Keys Tab */}
+      {activeTab === 'apikeys' && (
+        <div className="max-w-3xl">
+          <div className="flex items-start justify-between gap-4 mb-6">
+            <div className="flex-1">
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Key className="w-5 h-5 text-bambu-green" />
+                API Keys
+              </h2>
+              <p className="text-sm text-bambu-gray mt-1">
+                Create API keys for external integrations and webhooks. Use these keys to control your printers from automation tools like Home Assistant.
+              </p>
+            </div>
+            <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
+              <Plus className="w-4 h-4" />
+              Create Key
+            </Button>
+          </div>
+
+          {/* Created Key Display */}
+          {createdAPIKey && (
+            <Card className="mb-6 border-bambu-green">
+              <CardContent className="py-4">
+                <div className="flex items-start gap-3">
+                  <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
+                  <div className="flex-1">
+                    <p className="text-white font-medium mb-1">API Key Created Successfully</p>
+                    <p className="text-sm text-bambu-gray mb-2">
+                      Copy this key now - it won't be shown again!
+                    </p>
+                    <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
+                      <code className="flex-1 text-sm text-bambu-green font-mono break-all">
+                        {createdAPIKey}
+                      </code>
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={async () => {
+                          try {
+                            if (navigator.clipboard && navigator.clipboard.writeText) {
+                              await navigator.clipboard.writeText(createdAPIKey);
+                            } else {
+                              // Fallback for non-HTTPS contexts
+                              const textArea = document.createElement('textarea');
+                              textArea.value = createdAPIKey;
+                              textArea.style.position = 'fixed';
+                              textArea.style.left = '-999999px';
+                              document.body.appendChild(textArea);
+                              textArea.select();
+                              document.execCommand('copy');
+                              document.body.removeChild(textArea);
+                            }
+                            showToast('Key copied to clipboard');
+                          } catch {
+                            showToast('Failed to copy key', 'error');
+                          }
+                        }}
+                      >
+                        <Copy className="w-4 h-4" />
+                      </Button>
+                    </div>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      className="mt-3"
+                      onClick={() => setCreatedAPIKey(null)}
+                    >
+                      Dismiss
+                    </Button>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+          )}
+
+          {/* Create Key Form */}
+          {showCreateAPIKey && (
+            <Card className="mb-6">
+              <CardHeader>
+                <h3 className="text-base font-semibold text-white">Create New API Key</h3>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">Key Name</label>
+                  <input
+                    type="text"
+                    value={newAPIKeyName}
+                    onChange={(e) => setNewAPIKeyName(e.target.value)}
+                    placeholder="e.g., Home Assistant, OctoPrint"
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-2">Permissions</label>
+                  <div className="space-y-2">
+                    <label className="flex items-center gap-3 cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={newAPIKeyPermissions.can_read_status}
+                        onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
+                        className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                      />
+                      <div>
+                        <span className="text-white">Read Status</span>
+                        <p className="text-xs text-bambu-gray">View printer status and queue</p>
+                      </div>
+                    </label>
+                    <label className="flex items-center gap-3 cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={newAPIKeyPermissions.can_queue}
+                        onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
+                        className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                      />
+                      <div>
+                        <span className="text-white">Manage Queue</span>
+                        <p className="text-xs text-bambu-gray">Add and remove items from print queue</p>
+                      </div>
+                    </label>
+                    <label className="flex items-center gap-3 cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={newAPIKeyPermissions.can_control_printer}
+                        onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
+                        className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                      />
+                      <div>
+                        <span className="text-white">Control Printer</span>
+                        <p className="text-xs text-bambu-gray">Pause, resume, and stop prints</p>
+                      </div>
+                    </label>
+                  </div>
+                </div>
+                <div className="flex items-center gap-2 pt-2">
+                  <Button
+                    onClick={() => createAPIKeyMutation.mutate({
+                      name: newAPIKeyName || 'Unnamed Key',
+                      ...newAPIKeyPermissions,
+                    })}
+                    disabled={createAPIKeyMutation.isPending}
+                  >
+                    {createAPIKeyMutation.isPending ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Plus className="w-4 h-4" />
+                    )}
+                    Create Key
+                  </Button>
+                  <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
+                    Cancel
+                  </Button>
+                </div>
+              </CardContent>
+            </Card>
+          )}
+
+          {/* Existing Keys List */}
+          {apiKeysLoading ? (
+            <div className="flex justify-center py-12">
+              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+            </div>
+          ) : apiKeys && apiKeys.length > 0 ? (
+            <div className="space-y-3">
+              {apiKeys.map((key) => (
+                <Card key={key.id}>
+                  <CardContent className="py-3">
+                    <div className="flex items-center justify-between">
+                      <div className="flex items-center gap-3">
+                        <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+                        <div>
+                          <p className="text-white font-medium">{key.name}</p>
+                          <p className="text-xs text-bambu-gray">
+                            {key.key_prefix}••••••••
+                            {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
+                          </p>
+                        </div>
+                      </div>
+                      <div className="flex items-center gap-2">
+                        <div className="flex gap-1 text-xs">
+                          {key.can_read_status && (
+                            <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">Read</span>
+                          )}
+                          {key.can_queue && (
+                            <span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded">Queue</span>
+                          )}
+                          {key.can_control_printer && (
+                            <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">Control</span>
+                          )}
+                        </div>
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
+                        >
+                          <Trash2 className="w-4 h-4 text-red-400" />
+                        </Button>
+                      </div>
+                    </div>
+                  </CardContent>
+                </Card>
+              ))}
+            </div>
+          ) : (
+            <Card>
+              <CardContent className="py-12">
+                <div className="text-center text-bambu-gray">
+                  <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
+                  <p className="text-lg font-medium text-white mb-2">No API keys</p>
+                  <p className="text-sm mb-4">Create an API key to integrate with external services.</p>
+                  <Button onClick={() => setShowCreateAPIKey(true)}>
+                    <Plus className="w-4 h-4" />
+                    Create Your First Key
+                  </Button>
+                </div>
+              </CardContent>
+            </Card>
+          )}
+
+          {/* Webhook Documentation */}
+          <Card className="mt-6">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white">Webhook Endpoints</h3>
+            </CardHeader>
+            <CardContent className="space-y-3 text-sm">
+              <p className="text-bambu-gray">
+                Use your API key in the <code className="text-bambu-green">X-API-Key</code> header.
+              </p>
+              <div className="space-y-2 font-mono text-xs">
+                <div className="p-2 bg-bambu-dark rounded">
+                  <span className="text-blue-400">GET</span>{' '}
+                  <span className="text-white">/api/v1/webhook/status</span>
+                  <span className="text-bambu-gray"> - Get all printer status</span>
+                </div>
+                <div className="p-2 bg-bambu-dark rounded">
+                  <span className="text-blue-400">GET</span>{' '}
+                  <span className="text-white">/api/v1/webhook/status/:id</span>
+                  <span className="text-bambu-gray"> - Get specific printer status</span>
+                </div>
+                <div className="p-2 bg-bambu-dark rounded">
+                  <span className="text-green-400">POST</span>{' '}
+                  <span className="text-white">/api/v1/webhook/queue</span>
+                  <span className="text-bambu-gray"> - Add to print queue</span>
+                </div>
+                <div className="p-2 bg-bambu-dark rounded">
+                  <span className="text-orange-400">POST</span>{' '}
+                  <span className="text-white">/api/v1/webhook/printer/:id/pause</span>
+                  <span className="text-bambu-gray"> - Pause print</span>
+                </div>
+                <div className="p-2 bg-bambu-dark rounded">
+                  <span className="text-orange-400">POST</span>{' '}
+                  <span className="text-white">/api/v1/webhook/printer/:id/resume</span>
+                  <span className="text-bambu-gray"> - Resume print</span>
+                </div>
+                <div className="p-2 bg-bambu-dark rounded">
+                  <span className="text-red-400">POST</span>{' '}
+                  <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
+                  <span className="text-bambu-gray"> - Stop print</span>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
+      {/* Delete API Key Confirmation */}
+      {showDeleteAPIKeyConfirm !== null && (
+        <ConfirmModal
+          title="Delete API Key"
+          message="Are you sure you want to delete this API key? Any integrations using this key will stop working."
+          confirmText="Delete Key"
+          variant="danger"
+          onConfirm={() => {
+            deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);
+            setShowDeleteAPIKeyConfirm(null);
+          }}
+          onCancel={() => setShowDeleteAPIKeyConfirm(null)}
+        />
+      )}
+
       {/* Smart Plug Modal */}
       {/* Smart Plug Modal */}
       {showPlugModal && (
       {showPlugModal && (
         <AddSmartPlugModal
         <AddSmartPlugModal

+ 161 - 4
frontend/src/pages/StatsPage.tsx

@@ -1,4 +1,5 @@
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
+import { useState } from 'react';
 import {
 import {
   Package,
   Package,
   Clock,
   Clock,
@@ -8,7 +9,15 @@ import {
   Printer,
   Printer,
   Target,
   Target,
   Zap,
   Zap,
+  AlertTriangle,
+  TrendingDown,
+  FileSpreadsheet,
+  FileText,
+  Loader2,
+  RotateCcw,
 } from 'lucide-react';
 } from 'lucide-react';
+import { Button } from '../components/Button';
+import { useToast } from '../contexts/ToastContext';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { FilamentTrends } from '../components/FilamentTrends';
@@ -311,7 +320,80 @@ function FilamentTrendsWidget({
   return <FilamentTrends archives={archives} currency={currency} />;
   return <FilamentTrends archives={archives} currency={currency} />;
 }
 }
 
 
+function FailureAnalysisWidget() {
+  const { data: analysis, isLoading } = useQuery({
+    queryKey: ['failureAnalysis'],
+    queryFn: () => api.getFailureAnalysis({ days: 30 }),
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex justify-center py-4">
+        <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (!analysis || analysis.total_prints === 0) {
+    return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
+  }
+
+  const topReasons = Object.entries(analysis.failures_by_reason)
+    .sort(([, a], [, b]) => b - a)
+    .slice(0, 5);
+
+  return (
+    <div className="space-y-4">
+      {/* Summary */}
+      <div className="flex items-center gap-4">
+        <div className="flex items-center gap-2">
+          <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
+          <span className="text-2xl font-bold text-white">{analysis.failure_rate.toFixed(1)}%</span>
+          <span className="text-sm text-bambu-gray">failure rate</span>
+        </div>
+        <div className="text-sm text-bambu-gray">
+          {analysis.failed_prints} / {analysis.total_prints} prints failed
+        </div>
+      </div>
+
+      {/* Top Failure Reasons */}
+      {topReasons.length > 0 && (
+        <div className="space-y-2">
+          <p className="text-xs text-bambu-gray font-medium">Top Failure Reasons</p>
+          {topReasons.map(([reason, count]) => (
+            <div key={reason} className="flex items-center justify-between text-sm">
+              <span className="text-white truncate max-w-[200px]">{reason || 'Unknown'}</span>
+              <span className="text-bambu-gray">{count}</span>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Trend indicator */}
+      {analysis.trend && analysis.trend.length >= 2 && (
+        <div className="pt-2 border-t border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 text-sm">
+            <TrendingDown className={`w-4 h-4 ${
+              analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
+                ? 'text-bambu-green'
+                : 'text-red-400'
+            }`} />
+            <span className="text-bambu-gray">
+              Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
+            </span>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
 export function StatsPage() {
 export function StatsPage() {
+  const { showToast } = useToast();
+  const [isExporting, setIsExporting] = useState(false);
+  const [showExportMenu, setShowExportMenu] = useState(false);
+  const [dashboardKey, setDashboardKey] = useState(0);
+
   const { data: stats, isLoading } = useQuery({
   const { data: stats, isLoading } = useQuery({
     queryKey: ['archiveStats'],
     queryKey: ['archiveStats'],
     queryFn: api.getArchiveStats,
     queryFn: api.getArchiveStats,
@@ -332,6 +414,25 @@ export function StatsPage() {
     queryFn: api.getSettings,
     queryFn: api.getSettings,
   });
   });
 
 
+  const handleExport = async (format: 'csv' | 'xlsx') => {
+    setShowExportMenu(false);
+    setIsExporting(true);
+    try {
+      const { blob, filename } = await api.exportStats({ format, days: 90 });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = filename;
+      a.click();
+      URL.revokeObjectURL(url);
+      showToast('Export downloaded');
+    } catch (err) {
+      showToast('Export failed', 'error');
+    } finally {
+      setIsExporting(false);
+    }
+  };
+
   const currency = settings?.currency || '$';
   const currency = settings?.currency || '$';
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
   const printDates = archives?.map((a) => a.created_at) || [];
   const printDates = archives?.map((a) => a.created_at) || [];
@@ -371,6 +472,12 @@ export function StatsPage() {
       component: <FilamentTypesWidget stats={stats} />,
       component: <FilamentTypesWidget stats={stats} />,
       defaultSize: 1,
       defaultSize: 1,
     },
     },
+    {
+      id: 'failure-analysis',
+      title: 'Failure Analysis (30 days)',
+      component: <FailureAnalysisWidget />,
+      defaultSize: 1,
+    },
     {
     {
       id: 'print-activity',
       id: 'print-activity',
       title: 'Print Activity',
       title: 'Print Activity',
@@ -391,14 +498,64 @@ export function StatsPage() {
     },
     },
   ];
   ];
 
 
+  const handleResetLayout = () => {
+    localStorage.removeItem('bambusy-dashboard-layout');
+    setDashboardKey(prev => prev + 1);
+    showToast('Layout reset');
+  };
+
   return (
   return (
     <div className="p-4 md:p-8">
     <div className="p-4 md:p-8">
-      <div className="mb-6">
-        <h1 className="text-2xl font-bold text-white">Dashboard</h1>
-        <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h1 className="text-2xl font-bold text-white">Dashboard</h1>
+          <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
+        </div>
+        <div className="flex items-center gap-2">
+          <Button
+            variant="secondary"
+            onClick={handleResetLayout}
+          >
+            <RotateCcw className="w-4 h-4" />
+            Reset Layout
+          </Button>
+          {/* Export dropdown */}
+          <div className="relative">
+            <Button
+              variant="secondary"
+              onClick={() => setShowExportMenu(!showExportMenu)}
+              disabled={isExporting}
+            >
+              {isExporting ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <FileSpreadsheet className="w-4 h-4" />
+              )}
+              Export Stats
+            </Button>
+            {showExportMenu && (
+              <div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
+                <button
+                  className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg"
+                  onClick={() => handleExport('csv')}
+                >
+                  <FileText className="w-4 h-4" />
+                  Export as CSV
+                </button>
+                <button
+                  className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
+                  onClick={() => handleExport('xlsx')}
+                >
+                  <FileSpreadsheet className="w-4 h-4" />
+                  Export as Excel
+                </button>
+              </div>
+            )}
+          </div>
+        </div>
       </div>
       </div>
 
 
-      <Dashboard widgets={widgets} storageKey="bambusy-dashboard-layout" />
+      <Dashboard key={dashboardKey} widgets={widgets} storageKey="bambusy-dashboard-layout" hideControls />
     </div>
     </div>
   );
   );
 }
 }

+ 3 - 0
requirements.txt

@@ -17,6 +17,9 @@ aioftp>=0.22.0
 
 
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 
 
+# Excel Export
+openpyxl>=3.1.0
+
 # Utilities
 # Utilities
 python-multipart>=0.0.6
 python-multipart>=0.0.6
 aiofiles>=23.0.0
 aiofiles>=23.0.0

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-BN5iZvNL.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-Bwdh7UG9.css


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-Dm9m4fYz.css


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-Y4EG-tDv.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BN5iZvNL.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Dm9m4fYz.css">
+    <script type="module" crossorigin src="/assets/index-Y4EG-tDv.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bwdh7UG9.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 7 - 0
static/manifest.json

@@ -60,6 +60,13 @@
       "description": "View print queue",
       "description": "View print queue",
       "url": "/queue",
       "url": "/queue",
       "icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
       "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" }]
     }
     }
   ]
   ]
 }
 }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません