mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
## 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
This commit is contained in:
@@ -45,16 +45,18 @@
|
||||
### 📦 Print Archive
|
||||
- Automatic 3MF archiving with metadata
|
||||
- 3D model preview (Three.js)
|
||||
- Duplicate detection
|
||||
- Duplicate detection & full-text search
|
||||
- Photo attachments & failure analysis
|
||||
- Re-print to any connected printer
|
||||
- Archive comparison (side-by-side diff)
|
||||
|
||||
### 📊 Monitoring & Stats
|
||||
- Real-time printer status via WebSocket
|
||||
- HMS error monitoring
|
||||
- Print success rates & trends
|
||||
- Filament usage tracking
|
||||
- Cost analytics
|
||||
- Cost analytics & failure analysis
|
||||
- CSV/Excel export
|
||||
|
||||
### ⏰ Scheduling & Automation
|
||||
- Print queue with drag-and-drop
|
||||
@@ -63,6 +65,12 @@
|
||||
- Auto power-on before print
|
||||
- Auto power-off after cooldown
|
||||
|
||||
### 📁 Projects
|
||||
- Group related prints (e.g., "Voron Build")
|
||||
- Track progress with target counts
|
||||
- Color-coded project badges
|
||||
- Assign archives via context menu
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -78,6 +86,7 @@
|
||||
- Bambu Cloud profile management
|
||||
- K-profiles (pressure advance)
|
||||
- External sidebar links
|
||||
- Webhooks & API keys
|
||||
|
||||
### 🛠️ Maintenance
|
||||
- Maintenance scheduling & tracking
|
||||
|
||||
@@ -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"}
|
||||
@@ -54,6 +54,8 @@ def archive_to_response(
|
||||
data = {
|
||||
"id": archive.id,
|
||||
"printer_id": archive.printer_id,
|
||||
"project_id": archive.project_id,
|
||||
"project_name": archive.project.name if archive.project else None,
|
||||
"filename": archive.filename,
|
||||
"file_path": archive.file_path,
|
||||
"file_size": archive.file_size,
|
||||
@@ -98,6 +100,7 @@ def archive_to_response(
|
||||
@router.get("/", response_model=list[ArchiveResponse])
|
||||
async def list_archives(
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -106,6 +109,7 @@ async def list_archives(
|
||||
service = ArchiveService(db)
|
||||
archives = await service.list_archives(
|
||||
printer_id=printer_id,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -121,6 +125,286 @@ async def list_archives(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[ArchiveResponse])
|
||||
async def search_archives(
|
||||
q: str = Query(..., min_length=2, description="Search query"),
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Full-text search across archives.
|
||||
|
||||
Searches print_name, filename, tags, notes, designer, and filament_type fields.
|
||||
Supports partial matches with wildcards (e.g., 'vor*' matches 'voron').
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# Prepare search query - add wildcard for partial matches
|
||||
search_term = q.strip()
|
||||
if not search_term.endswith('*'):
|
||||
search_term = f"{search_term}*"
|
||||
|
||||
# Build the FTS query
|
||||
# Using MATCH for FTS5 full-text search
|
||||
fts_query = text("""
|
||||
SELECT rowid FROM archive_fts
|
||||
WHERE archive_fts MATCH :search_term
|
||||
ORDER BY rank
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
try:
|
||||
result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
|
||||
matched_ids = [row[0] for row in result.fetchall()]
|
||||
except Exception as e:
|
||||
logger.warning(f"FTS search failed, falling back to LIKE search: {e}")
|
||||
# Fallback to LIKE search if FTS fails
|
||||
like_pattern = f"%{q}%"
|
||||
query = (
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project))
|
||||
.where(
|
||||
(PrintArchive.print_name.ilike(like_pattern)) |
|
||||
(PrintArchive.filename.ilike(like_pattern)) |
|
||||
(PrintArchive.tags.ilike(like_pattern)) |
|
||||
(PrintArchive.notes.ilike(like_pattern)) |
|
||||
(PrintArchive.designer.ilike(like_pattern)) |
|
||||
(PrintArchive.filament_type.ilike(like_pattern))
|
||||
)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
)
|
||||
|
||||
if printer_id:
|
||||
query = query.where(PrintArchive.printer_id == printer_id)
|
||||
if project_id:
|
||||
query = query.where(PrintArchive.project_id == project_id)
|
||||
if status:
|
||||
query = query.where(PrintArchive.status == status)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
result = await db.execute(query)
|
||||
archives = result.scalars().all()
|
||||
return [archive_to_response(a) for a in archives]
|
||||
|
||||
if not matched_ids:
|
||||
return []
|
||||
|
||||
# Fetch full archive records for matched IDs
|
||||
query = (
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project))
|
||||
.where(PrintArchive.id.in_(matched_ids))
|
||||
)
|
||||
|
||||
# Apply additional filters
|
||||
if printer_id:
|
||||
query = query.where(PrintArchive.printer_id == printer_id)
|
||||
if project_id:
|
||||
query = query.where(PrintArchive.project_id == project_id)
|
||||
if status:
|
||||
query = query.where(PrintArchive.status == status)
|
||||
|
||||
result = await db.execute(query)
|
||||
archives_dict = {a.id: a for a in result.scalars().all()}
|
||||
|
||||
# Preserve FTS ranking order and apply pagination
|
||||
ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
|
||||
paginated = ordered_archives[offset:offset + limit]
|
||||
|
||||
return [archive_to_response(a) for a in paginated]
|
||||
|
||||
|
||||
@router.post("/search/rebuild-index")
|
||||
async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
|
||||
"""Rebuild the full-text search index from existing archives.
|
||||
|
||||
Use this if search results seem incomplete or incorrect.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
# Clear and rebuild the FTS index
|
||||
await db.execute(text("DELETE FROM archive_fts"))
|
||||
|
||||
# Repopulate from print_archives
|
||||
await db.execute(text("""
|
||||
INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
|
||||
SELECT id, print_name, filename, tags, notes, designer, filament_type
|
||||
FROM print_archives
|
||||
"""))
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Count entries
|
||||
result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
|
||||
count = result.scalar() or 0
|
||||
|
||||
return {"message": f"Search index rebuilt with {count} entries"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rebuild search index: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/analysis/failures")
|
||||
async def analyze_failures(
|
||||
days: int = 30,
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Analyze failure patterns across prints.
|
||||
|
||||
Returns failure statistics including:
|
||||
- Overall failure rate
|
||||
- Failures by reason, filament type, printer
|
||||
- Time of day distribution
|
||||
- Recent failures
|
||||
- Weekly trend
|
||||
"""
|
||||
from backend.app.services.failure_analysis import FailureAnalysisService
|
||||
|
||||
service = FailureAnalysisService(db)
|
||||
return await service.analyze_failures(
|
||||
days=days,
|
||||
printer_id=printer_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compare")
|
||||
async def compare_archives(
|
||||
archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Compare multiple archives side by side.
|
||||
|
||||
Compares print settings, filament usage, and print times.
|
||||
Also analyzes correlation between settings and success/failure.
|
||||
|
||||
Args:
|
||||
archive_ids: Comma-separated list of 2-5 archive IDs to compare
|
||||
"""
|
||||
from backend.app.services.archive_comparison import ArchiveComparisonService
|
||||
|
||||
# Parse and validate archive IDs
|
||||
try:
|
||||
ids = [int(id.strip()) for id in archive_ids.split(",")]
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid archive IDs format")
|
||||
|
||||
if len(ids) < 2:
|
||||
raise HTTPException(400, "At least 2 archives required for comparison")
|
||||
if len(ids) > 5:
|
||||
raise HTTPException(400, "Maximum 5 archives can be compared at once")
|
||||
|
||||
service = ArchiveComparisonService(db)
|
||||
try:
|
||||
return await service.compare_archives(ids)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_archives(
|
||||
format: str = Query("csv", description="Export format: csv or xlsx"),
|
||||
fields: str | None = Query(None, description="Comma-separated field names"),
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
status: str | None = None,
|
||||
date_from: str | None = Query(None, description="Start date (ISO format)"),
|
||||
date_to: str | None = Query(None, description="End date (ISO format)"),
|
||||
search: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export archives to CSV or Excel format.
|
||||
|
||||
Returns a downloadable file with archive data.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi.responses import StreamingResponse
|
||||
from backend.app.services.export import ExportService
|
||||
|
||||
if format not in ("csv", "xlsx"):
|
||||
raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
|
||||
|
||||
# Parse fields
|
||||
field_list = None
|
||||
if fields:
|
||||
field_list = [f.strip() for f in fields.split(",")]
|
||||
|
||||
# Parse dates
|
||||
date_from_dt = None
|
||||
date_to_dt = None
|
||||
if date_from:
|
||||
try:
|
||||
date_from_dt = datetime.fromisoformat(date_from)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid date_from format")
|
||||
if date_to:
|
||||
try:
|
||||
date_to_dt = datetime.fromisoformat(date_to)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid date_to format")
|
||||
|
||||
service = ExportService(db)
|
||||
try:
|
||||
file_bytes, filename, content_type = await service.export_archives(
|
||||
format=format,
|
||||
fields=field_list,
|
||||
printer_id=printer_id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
date_from=date_from_dt,
|
||||
date_to=date_to_dt,
|
||||
search=search,
|
||||
)
|
||||
except ImportError as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(file_bytes),
|
||||
media_type=content_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/export")
|
||||
async def export_stats(
|
||||
format: str = Query("csv", description="Export format: csv or xlsx"),
|
||||
days: int = 30,
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export statistics summary to CSV or Excel format."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from backend.app.services.export import ExportService
|
||||
|
||||
if format not in ("csv", "xlsx"):
|
||||
raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
|
||||
|
||||
service = ExportService(db)
|
||||
try:
|
||||
file_bytes, filename, content_type = await service.export_stats(
|
||||
format=format,
|
||||
days=days,
|
||||
printer_id=printer_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
except ImportError as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(file_bytes),
|
||||
media_type=content_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ArchiveStats)
|
||||
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Get statistics across all archives."""
|
||||
@@ -279,15 +563,41 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
return archive_to_response(archive, duplicates)
|
||||
|
||||
|
||||
@router.get("/{archive_id}/similar")
|
||||
async def find_similar_archives(
|
||||
archive_id: int,
|
||||
limit: int = 10,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Find archives with similar settings for comparison.
|
||||
|
||||
Returns archives that match by:
|
||||
- Same print name (highest priority)
|
||||
- Same file content hash
|
||||
- Same filament type
|
||||
"""
|
||||
from backend.app.services.archive_comparison import ArchiveComparisonService
|
||||
|
||||
service = ArchiveComparisonService(db)
|
||||
try:
|
||||
return await service.find_similar_archives(archive_id, limit=limit)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.patch("/{archive_id}", response_model=ArchiveResponse)
|
||||
async def update_archive(
|
||||
archive_id: int,
|
||||
update_data: ArchiveUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update archive metadata (tags, notes, cost, is_favorite)."""
|
||||
"""Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintArchive).where(PrintArchive.id == archive_id)
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project))
|
||||
.where(PrintArchive.id == archive_id)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
@@ -297,8 +607,16 @@ async def update_archive(
|
||||
setattr(archive, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(archive)
|
||||
return archive
|
||||
|
||||
# Re-fetch with project relationship loaded after commit
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project))
|
||||
.where(PrintArchive.id == archive_id)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
|
||||
return archive_to_response(archive)
|
||||
|
||||
|
||||
@router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
|
||||
|
||||
async def init_db():
|
||||
# Import models to register them with SQLAlchemy
|
||||
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link # noqa: F401
|
||||
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -191,6 +191,72 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add project_id column to print_archives
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add project_id column to print_queue
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Create FTS5 virtual table for archive full-text search
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
|
||||
print_name,
|
||||
filename,
|
||||
tags,
|
||||
notes,
|
||||
designer,
|
||||
filament_type,
|
||||
content='print_archives',
|
||||
content_rowid='id'
|
||||
)
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Create triggers to keep FTS index in sync
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
|
||||
INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
|
||||
VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
|
||||
END
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
|
||||
INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
|
||||
VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
|
||||
END
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
|
||||
INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
|
||||
VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
|
||||
INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
|
||||
VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
|
||||
END
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
+4
-1
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
|
||||
from backend.app.core.database import init_db, async_session
|
||||
from sqlalchemy import select, or_
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links
|
||||
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook
|
||||
from backend.app.api.routes import settings as settings_routes
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import (
|
||||
@@ -1032,6 +1032,9 @@ app.include_router(updates.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(camera.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(external_links.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(projects.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(api_keys.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(webhook.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance,
|
||||
from backend.app.models.kprofile_note import KProfileNote
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
from backend.app.models.notification import NotificationLog
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.api_key import APIKey
|
||||
|
||||
__all__ = [
|
||||
"Printer",
|
||||
@@ -20,4 +22,6 @@ __all__ = [
|
||||
"KProfileNote",
|
||||
"NotificationTemplate",
|
||||
"NotificationLog",
|
||||
"Project",
|
||||
"APIKey",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,9 @@ class PrintArchive(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# File info
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
@@ -63,6 +66,8 @@ class PrintArchive(Base):
|
||||
|
||||
# Relationships
|
||||
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
|
||||
project: Mapped["Project | None"] = relationship(back_populates="archives")
|
||||
|
||||
|
||||
from backend.app.models.printer import Printer # noqa: E402, F811
|
||||
from backend.app.models.project import Project # noqa: E402, F811
|
||||
|
||||
@@ -19,6 +19,9 @@ class PrintQueueItem(Base):
|
||||
archive_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("print_archives.id", ondelete="CASCADE")
|
||||
)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# Scheduling
|
||||
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
|
||||
@@ -44,7 +47,9 @@ class PrintQueueItem(Base):
|
||||
# Relationships
|
||||
printer: Mapped["Printer"] = relationship()
|
||||
archive: Mapped["PrintArchive"] = relationship()
|
||||
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
|
||||
|
||||
|
||||
from backend.app.models.printer import Printer # noqa: E402
|
||||
from backend.app.models.archive import PrintArchive # noqa: E402
|
||||
from backend.app.models.project import Project # noqa: E402
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -13,6 +13,7 @@ class ArchiveBase(BaseModel):
|
||||
|
||||
class ArchiveUpdate(ArchiveBase):
|
||||
printer_id: int | None = None
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
class ArchiveDuplicate(BaseModel):
|
||||
@@ -26,6 +27,8 @@ class ArchiveDuplicate(BaseModel):
|
||||
class ArchiveResponse(BaseModel):
|
||||
id: int
|
||||
printer_id: int | None
|
||||
project_id: int | None = None
|
||||
project_name: str | None = None # Included for convenience
|
||||
filename: str
|
||||
file_path: str
|
||||
file_size: int
|
||||
|
||||
@@ -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]
|
||||
@@ -733,15 +733,25 @@ class ArchiveService:
|
||||
async def list_archives(
|
||||
self,
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[PrintArchive]:
|
||||
"""List archives with optional filtering."""
|
||||
query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
query = (
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project))
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
)
|
||||
|
||||
if printer_id:
|
||||
query = query.where(PrintArchive.printer_id == printer_id)
|
||||
|
||||
if project_id:
|
||||
query = query.where(PrintArchive.project_id == project_id)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -60,6 +60,13 @@
|
||||
"description": "View print queue",
|
||||
"url": "/queue",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Projects",
|
||||
"short_name": "Projects",
|
||||
"description": "View print projects",
|
||||
"url": "/projects",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { ProfilesPage } from './pages/ProfilesPage';
|
||||
import { MaintenancePage } from './pages/MaintenancePage';
|
||||
import { ProjectsPage } from './pages/ProjectsPage';
|
||||
import { CameraPage } from './pages/CameraPage';
|
||||
import { ExternalLinkPage } from './pages/ExternalLinkPage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
@@ -46,6 +47,7 @@ function App() {
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="maintenance" element={<MaintenancePage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="external/:id" element={<ExternalLinkPage />} />
|
||||
</Route>
|
||||
|
||||
+327
-1
@@ -176,6 +176,8 @@ export interface ArchiveDuplicate {
|
||||
export interface Archive {
|
||||
id: number;
|
||||
printer_id: number | null;
|
||||
project_id: number | null;
|
||||
project_name: string | null;
|
||||
filename: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
@@ -229,6 +231,31 @@ export interface ArchiveStats {
|
||||
total_energy_cost: number;
|
||||
}
|
||||
|
||||
export interface FailureAnalysis {
|
||||
period_days: number;
|
||||
total_prints: number;
|
||||
failed_prints: number;
|
||||
failure_rate: number;
|
||||
failures_by_reason: Record<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 {
|
||||
uploaded: number;
|
||||
failed: number;
|
||||
@@ -236,6 +263,159 @@ export interface BulkUploadResult {
|
||||
errors: Array<{ filename: string; error: string }>;
|
||||
}
|
||||
|
||||
// Archive Comparison types
|
||||
export interface ComparisonArchiveInfo {
|
||||
id: number;
|
||||
print_name: string;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
printer_id: number | null;
|
||||
project_name: string | null;
|
||||
}
|
||||
|
||||
export interface ComparisonField {
|
||||
field: string;
|
||||
label: string;
|
||||
unit: string | null;
|
||||
values: (string | number | null)[];
|
||||
raw_values: (string | number | null)[];
|
||||
has_difference: boolean;
|
||||
}
|
||||
|
||||
export interface SuccessCorrelationInsight {
|
||||
field: string;
|
||||
label: string;
|
||||
insight: string;
|
||||
success_avg?: number;
|
||||
failed_avg?: number;
|
||||
success_values?: string[];
|
||||
failed_values?: string[];
|
||||
}
|
||||
|
||||
export interface SuccessCorrelation {
|
||||
has_both_outcomes: boolean;
|
||||
message?: string;
|
||||
successful_count?: number;
|
||||
failed_count?: number;
|
||||
insights?: SuccessCorrelationInsight[];
|
||||
}
|
||||
|
||||
export interface ArchiveComparison {
|
||||
archives: ComparisonArchiveInfo[];
|
||||
comparison: ComparisonField[];
|
||||
differences: ComparisonField[];
|
||||
success_correlation: SuccessCorrelation;
|
||||
}
|
||||
|
||||
export interface SimilarArchive {
|
||||
archive: {
|
||||
id: number;
|
||||
print_name: string;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
match_reason: string;
|
||||
match_score: number;
|
||||
}
|
||||
|
||||
// Project types
|
||||
export interface ProjectStats {
|
||||
total_archives: number;
|
||||
completed_prints: number;
|
||||
failed_prints: number;
|
||||
queued_prints: number;
|
||||
in_progress_prints: number;
|
||||
total_print_time_hours: number;
|
||||
total_filament_grams: number;
|
||||
progress_percent: number | null;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
status: string; // active, completed, archived
|
||||
target_count: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
stats?: ProjectStats;
|
||||
}
|
||||
|
||||
export interface ArchivePreview {
|
||||
id: number;
|
||||
print_name: string | null;
|
||||
thumbnail_path: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProjectListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
status: string;
|
||||
target_count: number | null;
|
||||
created_at: string;
|
||||
archive_count: number;
|
||||
queue_count: number;
|
||||
progress_percent: number | null;
|
||||
archives: ArchivePreview[];
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
target_count?: number;
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
status?: string;
|
||||
target_count?: number;
|
||||
}
|
||||
|
||||
// API Key types
|
||||
export interface APIKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
can_queue: boolean;
|
||||
can_control_printer: boolean;
|
||||
can_read_status: boolean;
|
||||
printer_ids: number[] | null;
|
||||
enabled: boolean;
|
||||
last_used: string | null;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface APIKeyCreate {
|
||||
name: string;
|
||||
can_queue?: boolean;
|
||||
can_control_printer?: boolean;
|
||||
can_read_status?: boolean;
|
||||
printer_ids?: number[] | null;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
export interface APIKeyCreateResponse extends APIKey {
|
||||
key: string; // Full key, only shown on creation
|
||||
}
|
||||
|
||||
export interface APIKeyUpdate {
|
||||
name?: string;
|
||||
can_queue?: boolean;
|
||||
can_control_printer?: boolean;
|
||||
can_read_status?: boolean;
|
||||
printer_ids?: number[] | null;
|
||||
enabled?: boolean;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
// Settings types
|
||||
export interface AppSettings {
|
||||
auto_archive: boolean;
|
||||
@@ -970,16 +1150,35 @@ export const api = {
|
||||
request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
|
||||
|
||||
// Archives
|
||||
getArchives: (printerId?: number, limit = 50, offset = 0) => {
|
||||
getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => {
|
||||
const params = new URLSearchParams();
|
||||
if (printerId) params.set('printer_id', String(printerId));
|
||||
if (projectId) params.set('project_id', String(projectId));
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
return request<Archive[]>(`/archives/?${params}`);
|
||||
},
|
||||
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: {
|
||||
printer_id?: number | null;
|
||||
project_id?: number | null;
|
||||
print_name?: string;
|
||||
is_favorite?: boolean;
|
||||
tags?: string;
|
||||
@@ -996,6 +1195,81 @@ export const api = {
|
||||
deleteArchive: (id: number) =>
|
||||
request<void>(`/archives/${id}`, { method: 'DELETE' }),
|
||||
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) =>
|
||||
request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
|
||||
backfillContentHashes: () =>
|
||||
@@ -1567,4 +1841,56 @@ export const api = {
|
||||
deleteExternalLinkIcon: (id: number) =>
|
||||
request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
|
||||
getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
|
||||
|
||||
// Projects
|
||||
getProjects: (status?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
return request<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' }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
@@ -7,6 +8,7 @@ export interface ContextMenuItem {
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
submenu?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -18,6 +20,8 @@ interface ContextMenuProps {
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
||||
const submenuTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -44,6 +48,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('scroll', handleScroll, true);
|
||||
if (submenuTimeoutRef.current) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
@@ -69,10 +76,24 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
}
|
||||
}, [x, y]);
|
||||
|
||||
const handleMouseEnterSubmenu = (index: number) => {
|
||||
if (submenuTimeoutRef.current) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
submenuTimeoutRef.current = null;
|
||||
}
|
||||
setActiveSubmenu(index);
|
||||
};
|
||||
|
||||
const handleMouseLeaveSubmenu = () => {
|
||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||
setActiveSubmenu(null);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
{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" />;
|
||||
}
|
||||
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
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>
|
||||
|
||||
@@ -31,6 +31,8 @@ interface DashboardProps {
|
||||
widgets: DashboardWidget[];
|
||||
storageKey: string;
|
||||
columns?: number;
|
||||
hideControls?: boolean;
|
||||
onResetLayout?: () => void;
|
||||
}
|
||||
|
||||
interface LayoutState {
|
||||
@@ -124,7 +126,7 @@ function SortableWidget({
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) {
|
||||
export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout }: DashboardProps) {
|
||||
// Build default sizes from widget definitions
|
||||
const getDefaultSizes = () => {
|
||||
const sizes: Record<string, 1 | 2 | 4> = {};
|
||||
@@ -233,6 +235,7 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps)
|
||||
sizes: getDefaultSizes(),
|
||||
};
|
||||
setLayout(defaultLayout);
|
||||
onResetLayout?.();
|
||||
};
|
||||
|
||||
// Get ordered widgets
|
||||
@@ -246,22 +249,24 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 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 variant="secondary" size="sm" onClick={resetLayout}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset Layout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden Widgets Panel */}
|
||||
{showHiddenPanel && hiddenWidgets.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react';
|
||||
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { Archive } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -37,6 +37,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
const queryClient = useQueryClient();
|
||||
const [printName, setPrintName] = useState(archive.print_name || '');
|
||||
const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
|
||||
const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
|
||||
const [notes, setNotes] = useState(archive.notes || '');
|
||||
const [tags, setTags] = useState(archive.tags || '');
|
||||
const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
|
||||
@@ -52,6 +53,11 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => api.getProjects(),
|
||||
});
|
||||
|
||||
// Get all archives to extract existing tags if not provided
|
||||
const { data: archives } = useQuery({
|
||||
queryKey: ['archives'],
|
||||
@@ -96,6 +102,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
api.updateArchive(archive.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -134,6 +141,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
updateMutation.mutate({
|
||||
print_name: printName || undefined,
|
||||
printer_id: printerId,
|
||||
project_id: projectId,
|
||||
notes: notes || undefined,
|
||||
tags: tags || undefined,
|
||||
failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
|
||||
@@ -191,6 +199,26 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
</select>
|
||||
</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 */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -23,6 +23,7 @@ export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
|
||||
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
|
||||
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
|
||||
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
stats: 'Statistiken',
|
||||
profiles: 'Profile',
|
||||
maintenance: 'Wartung',
|
||||
projects: 'Projekte',
|
||||
settings: 'Einstellungen',
|
||||
collapseSidebar: 'Seitenleiste einklappen',
|
||||
expandSidebar: 'Seitenleiste ausklappen',
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
stats: 'Statistics',
|
||||
profiles: 'Profiles',
|
||||
maintenance: 'Maintenance',
|
||||
projects: 'Projects',
|
||||
settings: 'Settings',
|
||||
collapseSidebar: 'Collapse sidebar',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
|
||||
@@ -36,10 +36,14 @@ import {
|
||||
FileText,
|
||||
FileCode,
|
||||
MoreVertical,
|
||||
FileSpreadsheet,
|
||||
GitCompare,
|
||||
Loader2,
|
||||
FolderKanban,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import type { Archive } from '../api/client';
|
||||
import type { Archive, ProjectListItem } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { ModelViewerModal } from '../components/ModelViewerModal';
|
||||
@@ -55,6 +59,7 @@ import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
|
||||
import { ProjectPageModal } from '../components/ProjectPageModal';
|
||||
import { TimelapseViewer } from '../components/TimelapseViewer';
|
||||
import { AddToQueueModal } from '../components/AddToQueueModal';
|
||||
import { CompareArchivesModal } from '../components/CompareArchivesModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -86,12 +91,14 @@ function ArchiveCard({
|
||||
isSelected,
|
||||
onSelect,
|
||||
selectionMode,
|
||||
projects,
|
||||
}: {
|
||||
archive: Archive;
|
||||
printerName: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
selectionMode: boolean;
|
||||
projects: ProjectListItem[] | undefined;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
@@ -186,6 +193,18 @@ function ArchiveCard({
|
||||
},
|
||||
});
|
||||
|
||||
const assignProjectMutation = useMutation({
|
||||
mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
showToast('Project updated');
|
||||
},
|
||||
onError: () => {
|
||||
showToast('Failed to update project', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
@@ -311,6 +330,59 @@ function ArchiveCard({
|
||||
icon: <Pencil className="w-4 h-4" />,
|
||||
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',
|
||||
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">
|
||||
{archive.print_name || archive.filename}
|
||||
</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 */}
|
||||
<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 [sortBy, setSortBy] = useState<SortOption>('date-desc');
|
||||
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({
|
||||
queryKey: ['archives', filterPrinter],
|
||||
@@ -868,6 +957,11 @@ export function ArchivesPage() {
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => api.getProjects(),
|
||||
});
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
await Promise.all(ids.map((id) => api.deleteArchive(id)));
|
||||
@@ -1176,6 +1270,93 @@ export function ArchivesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<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 && (
|
||||
<Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
@@ -1402,6 +1583,7 @@ export function ArchivesPage() {
|
||||
isSelected={selectedIds.has(archive.id)}
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
projects={projects}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1560,6 +1742,18 @@ export function ArchivesPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { api } 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 [showLogViewer, setShowLogViewer] = useState(false);
|
||||
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
|
||||
const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
|
||||
@@ -113,6 +122,38 @@ export function SettingsPage() {
|
||||
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({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
@@ -362,6 +403,22 @@ export function SettingsPage() {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* General Tab */}
|
||||
@@ -1343,6 +1400,285 @@ export function SettingsPage() {
|
||||
</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 */}
|
||||
{showPlugModal && (
|
||||
<AddSmartPlugModal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Package,
|
||||
Clock,
|
||||
@@ -8,7 +9,15 @@ import {
|
||||
Printer,
|
||||
Target,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
TrendingDown,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { api } from '../api/client';
|
||||
import { PrintCalendar } from '../components/PrintCalendar';
|
||||
import { FilamentTrends } from '../components/FilamentTrends';
|
||||
@@ -311,7 +320,80 @@ function FilamentTrendsWidget({
|
||||
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() {
|
||||
const { showToast } = useToast();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [dashboardKey, setDashboardKey] = useState(0);
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['archiveStats'],
|
||||
queryFn: api.getArchiveStats,
|
||||
@@ -332,6 +414,25 @@ export function StatsPage() {
|
||||
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 printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
|
||||
const printDates = archives?.map((a) => a.created_at) || [];
|
||||
@@ -371,6 +472,12 @@ export function StatsPage() {
|
||||
component: <FilamentTypesWidget stats={stats} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'failure-analysis',
|
||||
title: 'Failure Analysis (30 days)',
|
||||
component: <FailureAnalysisWidget />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: '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 (
|
||||
<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>
|
||||
|
||||
<Dashboard widgets={widgets} storageKey="bambusy-dashboard-layout" />
|
||||
<Dashboard key={dashboardKey} widgets={widgets} storageKey="bambusy-dashboard-layout" hideControls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ aioftp>=0.22.0
|
||||
|
||||
# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
|
||||
|
||||
# Excel Export
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# Utilities
|
||||
python-multipart>=0.0.6
|
||||
aiofiles>=23.0.0
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
"description": "View print queue",
|
||||
"url": "/queue",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Projects",
|
||||
"short_name": "Projects",
|
||||
"description": "View print projects",
|
||||
"url": "/projects",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user