## 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:
maziggy
2025-12-10 17:06:43 +00:00
parent 60ec159c97
commit d1518083fc
41 changed files with 12888 additions and 8670 deletions
+11 -2
View File
@@ -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
+138
View File
@@ -0,0 +1,138 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
from backend.app.schemas.api_key import (
APIKeyCreate,
APIKeyUpdate,
APIKeyResponse,
APIKeyCreateResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
@router.get("/", response_model=list[APIKeyResponse])
async def list_api_keys(db: AsyncSession = Depends(get_db)):
"""List all API keys (without full key values)."""
result = await db.execute(
select(APIKey).order_by(APIKey.created_at.desc())
)
return list(result.scalars().all())
@router.post("/", response_model=APIKeyCreateResponse)
async def create_api_key(
data: APIKeyCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new API key.
IMPORTANT: The full API key is only returned in this response.
Store it securely - it cannot be retrieved again.
"""
# Generate the key
full_key, key_hash, key_prefix = generate_api_key()
api_key = APIKey(
name=data.name,
key_hash=key_hash,
key_prefix=key_prefix,
can_queue=data.can_queue,
can_control_printer=data.can_control_printer,
can_read_status=data.can_read_status,
printer_ids=data.printer_ids,
expires_at=data.expires_at,
)
db.add(api_key)
await db.flush()
await db.refresh(api_key)
# Return with full key (only time it's shown)
return APIKeyCreateResponse(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
key=full_key, # Only returned on creation
can_queue=api_key.can_queue,
can_control_printer=api_key.can_control_printer,
can_read_status=api_key.can_read_status,
printer_ids=api_key.printer_ids,
enabled=api_key.enabled,
last_used=api_key.last_used,
created_at=api_key.created_at,
expires_at=api_key.expires_at,
)
@router.get("/{key_id}", response_model=APIKeyResponse)
async def get_api_key(
key_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get an API key by ID."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
return api_key
@router.patch("/{key_id}", response_model=APIKeyResponse)
async def update_api_key(
key_id: int,
data: APIKeyUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update an API key."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
# Update fields if provided
if data.name is not None:
api_key.name = data.name
if data.can_queue is not None:
api_key.can_queue = data.can_queue
if data.can_control_printer is not None:
api_key.can_control_printer = data.can_control_printer
if data.can_read_status is not None:
api_key.can_read_status = data.can_read_status
if data.printer_ids is not None:
api_key.printer_ids = data.printer_ids
if data.enabled is not None:
api_key.enabled = data.enabled
if data.expires_at is not None:
api_key.expires_at = data.expires_at
await db.flush()
await db.refresh(api_key)
return api_key
@router.delete("/{key_id}")
async def delete_api_key(
key_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete (revoke) an API key."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
await db.delete(api_key)
return {"message": "API key deleted"}
+322 -4
View File
@@ -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)
+428
View File
@@ -0,0 +1,428 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from backend.app.core.database import get_db
from backend.app.models.project import Project
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
ProjectStats,
BatchAddArchives,
BatchAddQueueItems,
ArchivePreview,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["projects"])
async def compute_project_stats(
db: AsyncSession, project_id: int, target_count: int | None = None
) -> ProjectStats:
"""Compute statistics for a project."""
# Count total archives
total_result = await db.execute(
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)
)
total_archives = total_result.scalar() or 0
# Count completed archives
completed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project_id,
PrintArchive.status == "completed"
)
)
completed_prints = completed_result.scalar() or 0
# Count failed archives
failed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project_id,
PrintArchive.status == "failed"
)
)
failed_prints = failed_result.scalar() or 0
# Sum print time and filament
sums_result = await db.execute(
select(
func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
).where(PrintArchive.project_id == project_id)
)
sums = sums_result.first()
# Count queued items
queued_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project_id,
PrintQueueItem.status == "pending"
)
)
queued_prints = queued_result.scalar() or 0
# Count in-progress items
in_progress_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project_id,
PrintQueueItem.status == "printing"
)
)
in_progress_prints = in_progress_result.scalar() or 0
# Calculate progress
progress_percent = None
if target_count and target_count > 0:
progress_percent = round((completed_prints / target_count) * 100, 1)
return ProjectStats(
total_archives=total_archives,
completed_prints=completed_prints,
failed_prints=failed_prints,
queued_prints=queued_prints,
in_progress_prints=in_progress_prints,
total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
total_filament_grams=round(sums.total_filament or 0, 2),
progress_percent=progress_percent,
)
@router.get("", response_model=list[ProjectListResponse])
@router.get("/", response_model=list[ProjectListResponse])
async def list_projects(
status: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""List all projects with basic stats."""
query = select(Project)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await db.execute(query)
projects = result.scalars().all()
# Compute quick stats for each project
response = []
for project in projects:
# Get archive count
archive_count_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project.id
)
)
archive_count = archive_count_result.scalar() or 0
# Get queue count
queue_count_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project.id,
PrintQueueItem.status.in_(["pending", "printing"]),
)
)
queue_count = queue_count_result.scalar() or 0
# Get completed count for progress
completed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project.id,
PrintArchive.status == "completed",
)
)
completed_count = completed_result.scalar() or 0
progress_percent = None
if project.target_count and project.target_count > 0:
progress_percent = round((completed_count / project.target_count) * 100, 1)
# Get archive previews (up to 6 most recent)
archives_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.project_id == project.id)
.order_by(PrintArchive.created_at.desc())
.limit(6)
)
archives = archives_result.scalars().all()
archive_previews = [
ArchivePreview(
id=a.id,
print_name=a.print_name,
thumbnail_path=a.thumbnail_path,
status=a.status,
)
for a in archives
]
response.append(
ProjectListResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
archive_count=archive_count,
queue_count=queue_count,
progress_percent=progress_percent,
archives=archive_previews,
)
)
return response
@router.post("/", response_model=ProjectResponse)
async def create_project(
data: ProjectCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new project."""
project = Project(
name=data.name,
description=data.description,
color=data.color,
target_count=data.target_count,
)
db.add(project)
await db.flush()
await db.refresh(project)
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get a project by ID with detailed stats."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
@router.patch("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
data: ProjectUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Update fields if provided
if data.name is not None:
project.name = data.name
if data.description is not None:
project.description = data.description
if data.color is not None:
project.color = data.color
if data.status is not None:
if data.status not in ["active", "completed", "archived"]:
raise HTTPException(status_code=400, detail="Invalid status")
project.status = data.status
if data.target_count is not None:
project.target_count = data.target_count
await db.flush()
await db.refresh(project)
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a project. Archives and queue items will have project_id set to NULL."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
return {"message": "Project deleted"}
@router.get("/{project_id}/archives")
async def list_project_archives(
project_id: int,
limit: int = 100,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
"""List archives in a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Get archives
query = (
select(PrintArchive)
.where(PrintArchive.project_id == project_id)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
archives = result.scalars().all()
# Import the response converter from archives module
from backend.app.api.routes.archives import archive_to_response
return [archive_to_response(a) for a in archives]
@router.get("/{project_id}/queue")
async def list_project_queue(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""List queue items in a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Get queue items
query = (
select(PrintQueueItem)
.where(PrintQueueItem.project_id == project_id)
.order_by(PrintQueueItem.position)
)
result = await db.execute(query)
items = result.scalars().all()
return items
@router.post("/{project_id}/add-archives")
async def add_archives_to_project(
project_id: int,
data: BatchAddArchives,
db: AsyncSession = Depends(get_db),
):
"""Batch add archives to a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Update archives
updated = 0
for archive_id in data.archive_ids:
result = await db.execute(
select(PrintArchive).where(PrintArchive.id == archive_id)
)
archive = result.scalar_one_or_none()
if archive:
archive.project_id = project_id
updated += 1
return {"message": f"Added {updated} archives to project"}
@router.post("/{project_id}/add-queue")
async def add_queue_items_to_project(
project_id: int,
data: BatchAddQueueItems,
db: AsyncSession = Depends(get_db),
):
"""Batch add queue items to a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Update queue items
updated = 0
for item_id in data.queue_item_ids:
result = await db.execute(
select(PrintQueueItem).where(PrintQueueItem.id == item_id)
)
item = result.scalar_one_or_none()
if item:
item.project_id = project_id
updated += 1
return {"message": f"Added {updated} queue items to project"}
@router.post("/{project_id}/remove-archives")
async def remove_archives_from_project(
project_id: int,
data: BatchAddArchives,
db: AsyncSession = Depends(get_db),
):
"""Remove archives from a project (sets project_id to NULL)."""
updated = 0
for archive_id in data.archive_ids:
result = await db.execute(
select(PrintArchive).where(
PrintArchive.id == archive_id,
PrintArchive.project_id == project_id,
)
)
archive = result.scalar_one_or_none()
if archive:
archive.project_id = None
updated += 1
return {"message": f"Removed {updated} archives from project"}
+332
View File
@@ -0,0 +1,332 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from backend.app.core.database import get_db
from backend.app.core.auth import get_api_key, check_permission, check_printer_access
from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook", tags=["webhook"])
# Request schemas
class QueueAddRequest(BaseModel):
archive_id: int
printer_id: int
project_id: int | None = None
scheduled_time: str | None = None # ISO format datetime
require_previous_success: bool = False
auto_off_after: bool = False
class QueueAddResponse(BaseModel):
id: int
archive_id: int
printer_id: int
position: int
status: str
message: str
class PrinterStatusResponse(BaseModel):
id: int
name: str
connected: bool
state: str | None
current_print: str | None
progress: float | None
remaining_time: int | None
class QueueStatusResponse(BaseModel):
printer_id: int
printer_name: str
pending: int
printing: int
items: list[dict]
# Webhook endpoints
@router.post("/queue/add", response_model=QueueAddResponse)
async def webhook_add_to_queue(
data: QueueAddRequest,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Add a print to the queue via webhook.
Requires 'can_queue' permission.
"""
check_permission(api_key, 'queue')
check_printer_access(api_key, data.printer_id)
# Verify archive exists
result = await db.execute(
select(PrintArchive).where(PrintArchive.id == data.archive_id)
)
archive = result.scalar_one_or_none()
if not archive:
raise HTTPException(status_code=404, detail="Archive not found")
# Verify printer exists
result = await db.execute(
select(Printer).where(Printer.id == data.printer_id)
)
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Get next position
result = await db.execute(
select(PrintQueueItem.position)
.where(
PrintQueueItem.printer_id == data.printer_id,
PrintQueueItem.status == "pending",
)
.order_by(PrintQueueItem.position.desc())
.limit(1)
)
max_position = result.scalar()
next_position = (max_position or 0) + 1
# Parse scheduled time if provided
scheduled_time = None
if data.scheduled_time:
from datetime import datetime
try:
scheduled_time = datetime.fromisoformat(data.scheduled_time.replace('Z', '+00:00'))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
# Create queue item
queue_item = PrintQueueItem(
printer_id=data.printer_id,
archive_id=data.archive_id,
project_id=data.project_id,
position=next_position,
scheduled_time=scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
)
db.add(queue_item)
await db.flush()
await db.refresh(queue_item)
return QueueAddResponse(
id=queue_item.id,
archive_id=queue_item.archive_id,
printer_id=queue_item.printer_id,
position=queue_item.position,
status=queue_item.status,
message=f"Added to queue at position {queue_item.position}",
)
@router.post("/printer/{printer_id}/start")
async def webhook_start_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Start the next queued print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, 'control_printer')
check_printer_access(api_key, printer_id)
# Get printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Get next pending queue item
result = await db.execute(
select(PrintQueueItem)
.where(
PrintQueueItem.printer_id == printer_id,
PrintQueueItem.status == "pending",
)
.order_by(PrintQueueItem.position)
.limit(1)
)
queue_item = result.scalar_one_or_none()
if not queue_item:
raise HTTPException(status_code=404, detail="No pending prints in queue")
# Check if printer is ready
status = printer_manager.get_status(printer_id)
if not status or not status.get("connected"):
raise HTTPException(status_code=503, detail="Printer not connected")
if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
raise HTTPException(
status_code=409,
detail=f"Printer is busy (state: {status.get('state')})"
)
# Start the print
try:
await printer_manager.start_print(printer_id, queue_item.archive_id)
except Exception as e:
logger.error(f"Failed to start print: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {"message": "Print started", "queue_item_id": queue_item.id}
@router.post("/printer/{printer_id}/stop")
async def webhook_stop_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
):
"""Stop the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, 'control_printer')
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
if not status or not status.get("connected"):
raise HTTPException(status_code=503, detail="Printer not connected")
if status.get("state") != "RUNNING":
raise HTTPException(status_code=409, detail="No print in progress")
try:
await printer_manager.stop_print(printer_id)
except Exception as e:
logger.error(f"Failed to stop print: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {"message": "Print stopped"}
@router.post("/printer/{printer_id}/cancel")
async def webhook_cancel_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
):
"""Cancel the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, 'control_printer')
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
if not status or not status.get("connected"):
raise HTTPException(status_code=503, detail="Printer not connected")
if status.get("state") not in ["RUNNING", "PAUSE"]:
raise HTTPException(status_code=409, detail="No print to cancel")
try:
await printer_manager.cancel_print(printer_id)
except Exception as e:
logger.error(f"Failed to cancel print: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {"message": "Print cancelled"}
@router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
async def webhook_get_printer_status(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Get status of a printer.
Requires 'can_read_status' permission.
"""
check_permission(api_key, 'read_status')
check_printer_access(api_key, printer_id)
# Get printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
status = printer_manager.get_status(printer_id)
return PrinterStatusResponse(
id=printer.id,
name=printer.name,
connected=status.get("connected", False) if status else False,
state=status.get("state") if status else None,
current_print=status.get("current_print") if status else None,
progress=status.get("progress") if status else None,
remaining_time=status.get("remaining_time") if status else None,
)
@router.get("/queue", response_model=list[QueueStatusResponse])
async def webhook_get_queue_status(
printer_id: int | None = None,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Get queue status for all printers or a specific printer.
Requires 'can_read_status' permission.
"""
check_permission(api_key, 'read_status')
# Get printers
if printer_id:
check_printer_access(api_key, printer_id)
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printers = result.scalars().all()
else:
result = await db.execute(select(Printer))
printers = result.scalars().all()
# Filter by allowed printers if limited
if api_key.printer_ids:
printers = [p for p in printers if p.id in api_key.printer_ids]
response = []
for printer in printers:
# Get queue items
result = await db.execute(
select(PrintQueueItem)
.where(
PrintQueueItem.printer_id == printer.id,
PrintQueueItem.status.in_(["pending", "printing"]),
)
.order_by(PrintQueueItem.position)
)
items = result.scalars().all()
pending_count = sum(1 for i in items if i.status == "pending")
printing_count = sum(1 for i in items if i.status == "printing")
response.append(QueueStatusResponse(
printer_id=printer.id,
printer_name=printer.name,
pending=pending_count,
printing=printing_count,
items=[
{
"id": item.id,
"archive_id": item.archive_id,
"position": item.position,
"status": item.status,
}
for item in items
],
))
return response
+114
View File
@@ -0,0 +1,114 @@
import hashlib
import secrets
from datetime import datetime
from typing import Optional
from fastapi import Header, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.api_key import APIKey
def generate_api_key() -> tuple[str, str, str]:
"""Generate a new API key.
Returns:
Tuple of (full_key, key_hash, key_prefix)
"""
# Generate a random 32-byte key and encode as hex (64 chars)
full_key = f"bb_{secrets.token_hex(32)}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
key_prefix = full_key[:11] # "bb_" + first 8 chars of token
return full_key, key_hash, key_prefix
def hash_api_key(key: str) -> str:
"""Hash an API key for comparison."""
return hashlib.sha256(key.encode()).hexdigest()
async def get_api_key(
x_api_key: str = Header(..., alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> APIKey:
"""Verify API key and return the key record.
Raises HTTPException if key is invalid, disabled, or expired.
"""
key_hash = hash_api_key(x_api_key)
result = await db.execute(
select(APIKey).where(APIKey.key_hash == key_hash)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
if not api_key.enabled:
raise HTTPException(status_code=403, detail="API key is disabled")
if api_key.expires_at and api_key.expires_at < datetime.utcnow():
raise HTTPException(status_code=403, detail="API key has expired")
# Update last_used timestamp
api_key.last_used = datetime.utcnow()
return api_key
async def get_optional_api_key(
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> Optional[APIKey]:
"""Get API key if provided, return None otherwise."""
if not x_api_key:
return None
try:
return await get_api_key(x_api_key, db)
except HTTPException:
return None
def check_permission(api_key: APIKey, permission: str) -> None:
"""Check if API key has a specific permission.
Args:
api_key: The API key record
permission: One of 'queue', 'control_printer', 'read_status'
Raises HTTPException if permission is denied.
"""
permission_map = {
'queue': api_key.can_queue,
'control_printer': api_key.can_control_printer,
'read_status': api_key.can_read_status,
}
if permission not in permission_map:
raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
if not permission_map[permission]:
raise HTTPException(
status_code=403,
detail=f"API key does not have '{permission}' permission"
)
def check_printer_access(api_key: APIKey, printer_id: int) -> None:
"""Check if API key has access to a specific printer.
Args:
api_key: The API key record
printer_id: The printer ID to check
Raises HTTPException if access is denied.
"""
if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
raise HTTPException(
status_code=403,
detail=f"API key does not have access to printer {printer_id}"
)
+67 -1
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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",
]
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Text, JSON, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class APIKey(Base):
"""API key for external webhook access."""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100)) # User-friendly name
key_hash: Mapped[str] = mapped_column(String(64)) # SHA256 hash of the key
key_prefix: Mapped[str] = mapped_column(String(8)) # First 8 chars for identification
# Permissions
can_queue: Mapped[bool] = mapped_column(Boolean, default=True) # Add to queue
can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False) # Start/stop/cancel
can_read_status: Mapped[bool] = mapped_column(Boolean, default=True) # Query status
# Optional scope limits
printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) # null = all printers
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Optional expiry
+5
View File
@@ -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
+5
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class Project(Base):
"""Project to group related prints (e.g., 'Voron Build' with multiple parts)."""
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(20), nullable=True) # Hex color for UI
status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived
target_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Optional target number of prints
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# Relationships
archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.print_queue import PrintQueueItem # noqa: E402
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from pydantic import BaseModel
class APIKeyCreate(BaseModel):
"""Schema for creating a new API key."""
name: str
can_queue: bool = True
can_control_printer: bool = False
can_read_status: bool = True
printer_ids: list[int] | None = None # null = all printers
expires_at: datetime | None = None
class APIKeyUpdate(BaseModel):
"""Schema for updating an API key."""
name: str | None = None
can_queue: bool | None = None
can_control_printer: bool | None = None
can_read_status: bool | None = None
printer_ids: list[int] | None = None
enabled: bool | None = None
expires_at: datetime | None = None
class APIKeyResponse(BaseModel):
"""Schema for API key response (without full key)."""
id: int
name: str
key_prefix: str # First 8 chars for identification
can_queue: bool
can_control_printer: bool
can_read_status: bool
printer_ids: list[int] | None
enabled: bool
last_used: datetime | None
created_at: datetime
expires_at: datetime | None
class Config:
from_attributes = True
class APIKeyCreateResponse(APIKeyResponse):
"""Response when creating a key - includes full key (shown only once)."""
key: str # Full API key, only shown on creation
+3
View File
@@ -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
+85
View File
@@ -0,0 +1,85 @@
from datetime import datetime
from pydantic import BaseModel
class ProjectCreate(BaseModel):
"""Schema for creating a new project."""
name: str
description: str | None = None
color: str | None = None
target_count: int | None = None
class ProjectUpdate(BaseModel):
"""Schema for updating a project."""
name: str | None = None
description: str | None = None
color: str | None = None
status: str | None = None # active, completed, archived
target_count: int | None = None
class ProjectStats(BaseModel):
"""Statistics for a project."""
total_archives: int = 0
completed_prints: int = 0
failed_prints: int = 0
queued_prints: int = 0
in_progress_prints: int = 0
total_print_time_hours: float = 0.0
total_filament_grams: float = 0.0
progress_percent: float | None = None # Based on target_count
class ProjectResponse(BaseModel):
"""Schema for project response."""
id: int
name: str
description: str | None
color: str | None
status: str
target_count: int | None
created_at: datetime
updated_at: datetime
stats: ProjectStats | None = None
class Config:
from_attributes = True
class ArchivePreview(BaseModel):
"""Minimal archive data for project preview."""
id: int
print_name: str | None
thumbnail_path: str | None
status: str
class ProjectListResponse(BaseModel):
"""Schema for project list item (lighter weight)."""
id: int
name: str
description: str | None
color: str | None
status: str
target_count: int | None
created_at: datetime
# Quick stats
archive_count: int = 0
queue_count: int = 0
progress_percent: float | None = None
# Preview of archives (up to 5)
archives: list[ArchivePreview] = []
class Config:
from_attributes = True
class BatchAddArchives(BaseModel):
"""Schema for batch adding archives to a project."""
archive_ids: list[int]
class BatchAddQueueItems(BaseModel):
"""Schema for batch adding queue items to a project."""
queue_item_ids: list[int]
+11 -1
View File
@@ -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())
+278
View File
@@ -0,0 +1,278 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from backend.app.models.archive import PrintArchive
class ArchiveComparisonService:
"""Service for comparing print archives."""
# Fields to compare
COMPARABLE_FIELDS = [
("layer_height", "Layer Height", "mm"),
("nozzle_diameter", "Nozzle Diameter", "mm"),
("bed_temperature", "Bed Temperature", "°C"),
("nozzle_temperature", "Nozzle Temperature", "°C"),
("filament_type", "Filament Type", None),
("filament_used_grams", "Filament Used", "g"),
("print_time_seconds", "Print Time", "s"),
("total_layers", "Total Layers", None),
("status", "Status", None),
]
def __init__(self, db: AsyncSession):
self.db = db
async def compare_archives(self, archive_ids: list[int]) -> dict:
"""Compare multiple archives side by side.
Args:
archive_ids: List of 2-5 archive IDs to compare
Returns:
Dictionary with comparison results
"""
if len(archive_ids) < 2:
raise ValueError("At least 2 archives required for comparison")
if len(archive_ids) > 5:
raise ValueError("Maximum 5 archives can be compared at once")
# Fetch archives
result = await self.db.execute(
select(PrintArchive)
.options(selectinload(PrintArchive.project))
.where(PrintArchive.id.in_(archive_ids))
)
archives = {a.id: a for a in result.scalars().all()}
if len(archives) != len(archive_ids):
missing = set(archive_ids) - set(archives.keys())
raise ValueError(f"Archives not found: {missing}")
# Preserve order from input
ordered_archives = [archives[id] for id in archive_ids]
# Build basic info for each archive
archive_info = [
{
"id": a.id,
"print_name": a.print_name or a.filename,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
"printer_id": a.printer_id,
"project_name": a.project.name if a.project else None,
}
for a in ordered_archives
]
# Build field comparison
comparison = []
differences = []
for field_name, display_name, unit in self.COMPARABLE_FIELDS:
values = [getattr(a, field_name) for a in ordered_archives]
# Format values for display
formatted_values = []
for v in values:
if v is None:
formatted_values.append(None)
elif field_name == "print_time_seconds":
# Format as human-readable time
hours = int(v) // 3600
minutes = (int(v) % 3600) // 60
formatted_values.append(f"{hours}h {minutes}m" if hours else f"{minutes}m")
elif isinstance(v, float):
formatted_values.append(round(v, 2))
else:
formatted_values.append(v)
# Check if values differ
non_none_values = [v for v in values if v is not None]
has_difference = len(set(str(v) for v in non_none_values)) > 1 if non_none_values else False
field_data = {
"field": field_name,
"label": display_name,
"unit": unit,
"values": formatted_values,
"raw_values": values,
"has_difference": has_difference,
}
comparison.append(field_data)
if has_difference:
differences.append(field_data)
# Analyze success/failure correlation
success_correlation = self._analyze_success_correlation(ordered_archives)
return {
"archives": archive_info,
"comparison": comparison,
"differences": differences,
"success_correlation": success_correlation,
}
def _analyze_success_correlation(self, archives: list[PrintArchive]) -> dict:
"""Analyze what settings correlate with success/failure."""
successful = [a for a in archives if a.status == "completed"]
failed = [a for a in archives if a.status == "failed"]
if not successful or not failed:
return {
"has_both_outcomes": False,
"message": "Need both successful and failed prints to analyze correlation",
}
# Find settings that differ between successful and failed
insights = []
for field_name, display_name, unit in self.COMPARABLE_FIELDS:
if field_name == "status":
continue
success_values = [getattr(a, field_name) for a in successful if getattr(a, field_name) is not None]
failed_values = [getattr(a, field_name) for a in failed if getattr(a, field_name) is not None]
if not success_values or not failed_values:
continue
# For numeric fields, compare averages
if isinstance(success_values[0], (int, float)):
success_avg = sum(success_values) / len(success_values)
failed_avg = sum(failed_values) / len(failed_values)
if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):
direction = "higher" if success_avg > failed_avg else "lower"
insights.append({
"field": field_name,
"label": display_name,
"success_avg": round(success_avg, 2),
"failed_avg": round(failed_avg, 2),
"insight": f"Successful prints had {direction} {display_name}",
})
else:
# For categorical fields, check if success uses different values
success_set = set(str(v) for v in success_values)
failed_set = set(str(v) for v in failed_values)
if success_set != failed_set:
insights.append({
"field": field_name,
"label": display_name,
"success_values": list(success_set),
"failed_values": list(failed_set),
"insight": f"Different {display_name} used in successful vs failed prints",
})
return {
"has_both_outcomes": True,
"successful_count": len(successful),
"failed_count": len(failed),
"insights": insights,
}
async def find_similar_archives(
self,
archive_id: int,
limit: int = 10,
) -> list[dict]:
"""Find archives with similar settings for comparison.
Args:
archive_id: The archive to find similar ones for
limit: Maximum number of results
Returns:
List of similar archives with match reasons
"""
# Get the reference archive
result = await self.db.execute(
select(PrintArchive).where(PrintArchive.id == archive_id)
)
reference = result.scalar_one_or_none()
if not reference:
raise ValueError("Archive not found")
# Find similar archives
similar = []
# By same print name
if reference.print_name:
result = await self.db.execute(
select(PrintArchive)
.where(
PrintArchive.id != archive_id,
PrintArchive.print_name == reference.print_name,
)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
)
for a in result.scalars().all():
similar.append({
"archive": {
"id": a.id,
"print_name": a.print_name or a.filename,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
},
"match_reason": "Same print name",
"match_score": 100,
})
# By content hash
if reference.content_hash and len(similar) < limit:
result = await self.db.execute(
select(PrintArchive)
.where(
PrintArchive.id != archive_id,
PrintArchive.content_hash == reference.content_hash,
)
.order_by(PrintArchive.created_at.desc())
.limit(limit - len(similar))
)
for a in result.scalars().all():
if not any(s["archive"]["id"] == a.id for s in similar):
similar.append({
"archive": {
"id": a.id,
"print_name": a.print_name or a.filename,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
},
"match_reason": "Same file content",
"match_score": 95,
})
# By same filament type
if reference.filament_type and len(similar) < limit:
result = await self.db.execute(
select(PrintArchive)
.where(
PrintArchive.id != archive_id,
PrintArchive.filament_type == reference.filament_type,
)
.order_by(PrintArchive.created_at.desc())
.limit(limit - len(similar))
)
for a in result.scalars().all():
if not any(s["archive"]["id"] == a.id for s in similar):
similar.append({
"archive": {
"id": a.id,
"print_name": a.print_name or a.filename,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
},
"match_reason": f"Same filament type ({reference.filament_type})",
"match_score": 50,
})
# Sort by match score
similar.sort(key=lambda x: x["match_score"], reverse=True)
return similar[:limit]
+335
View File
@@ -0,0 +1,335 @@
import csv
import io
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from backend.app.models.archive import PrintArchive
from backend.app.models.project import Project
class ExportService:
"""Service for exporting archive data to CSV/Excel formats."""
# Default fields to export
DEFAULT_FIELDS = [
"id",
"print_name",
"filename",
"status",
"printer_id",
"project_name",
"filament_type",
"filament_used_grams",
"print_time_seconds",
"layer_height",
"nozzle_diameter",
"bed_temperature",
"nozzle_temperature",
"total_layers",
"cost",
"designer",
"tags",
"notes",
"failure_reason",
"started_at",
"completed_at",
"created_at",
]
# Field labels for headers
FIELD_LABELS = {
"id": "ID",
"print_name": "Print Name",
"filename": "Filename",
"status": "Status",
"printer_id": "Printer ID",
"project_name": "Project",
"filament_type": "Filament Type",
"filament_used_grams": "Filament (g)",
"print_time_seconds": "Print Time (s)",
"layer_height": "Layer Height (mm)",
"nozzle_diameter": "Nozzle (mm)",
"bed_temperature": "Bed Temp (°C)",
"nozzle_temperature": "Nozzle Temp (°C)",
"total_layers": "Total Layers",
"cost": "Cost",
"designer": "Designer",
"tags": "Tags",
"notes": "Notes",
"failure_reason": "Failure Reason",
"started_at": "Started At",
"completed_at": "Completed At",
"created_at": "Created At",
}
def __init__(self, db: AsyncSession):
self.db = db
async def export_archives(
self,
format: str = "csv",
fields: list[str] | None = None,
printer_id: int | None = None,
project_id: int | None = None,
status: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
search: str | None = None,
) -> tuple[bytes, str, str]:
"""Export archives to CSV or Excel format.
Args:
format: Export format ('csv' or 'xlsx')
fields: List of fields to include (None = all default fields)
printer_id: Filter by printer
project_id: Filter by project
status: Filter by status
date_from: Filter by start date
date_to: Filter by end date
search: Search filter
Returns:
Tuple of (file_bytes, filename, content_type)
"""
# Build query
query = (
select(PrintArchive)
.options(selectinload(PrintArchive.project))
.order_by(PrintArchive.created_at.desc())
)
# Apply filters
if printer_id:
query = query.where(PrintArchive.printer_id == printer_id)
if project_id:
query = query.where(PrintArchive.project_id == project_id)
if status:
query = query.where(PrintArchive.status == status)
if date_from:
query = query.where(PrintArchive.created_at >= date_from)
if date_to:
query = query.where(PrintArchive.created_at <= date_to)
if search:
like_pattern = f"%{search}%"
query = query.where(
(PrintArchive.print_name.ilike(like_pattern)) |
(PrintArchive.filename.ilike(like_pattern)) |
(PrintArchive.tags.ilike(like_pattern)) |
(PrintArchive.notes.ilike(like_pattern)) |
(PrintArchive.designer.ilike(like_pattern))
)
# Execute query
result = await self.db.execute(query)
archives = list(result.scalars().all())
# Determine fields to export
export_fields = fields if fields else self.DEFAULT_FIELDS
# Convert to rows
rows = []
for archive in archives:
row = self._archive_to_row(archive, export_fields)
rows.append(row)
# Generate headers
headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
# Generate file
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if format == "xlsx":
file_bytes = self._generate_xlsx(headers, rows, export_fields)
filename = f"archives_export_{timestamp}.xlsx"
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
file_bytes = self._generate_csv(headers, rows)
filename = f"archives_export_{timestamp}.csv"
content_type = "text/csv"
return file_bytes, filename, content_type
async def export_stats(
self,
format: str = "csv",
days: int = 30,
printer_id: int | None = None,
project_id: int | None = None,
) -> tuple[bytes, str, str]:
"""Export statistics summary to CSV or Excel format.
Args:
format: Export format ('csv' or 'xlsx')
days: Number of days to include in stats
printer_id: Filter by printer
project_id: Filter by project
Returns:
Tuple of (file_bytes, filename, content_type)
"""
from backend.app.services.failure_analysis import FailureAnalysisService
# Get failure analysis data (includes stats)
analysis_service = FailureAnalysisService(self.db)
analysis = await analysis_service.analyze_failures(
days=days,
printer_id=printer_id,
project_id=project_id,
)
# Build stats rows
rows = [
["Metric", "Value"],
["Period (days)", analysis["period_days"]],
["Total Prints", analysis["total_prints"]],
["Failed Prints", analysis["failed_prints"]],
["Failure Rate (%)", analysis["failure_rate"]],
[""],
["Failures by Reason", ""],
]
for reason, count in analysis["failures_by_reason"].items():
rows.append([reason, count])
rows.append([""])
rows.append(["Failures by Filament", ""])
for filament, count in analysis["failures_by_filament"].items():
rows.append([filament, count])
rows.append([""])
rows.append(["Failures by Printer", ""])
for printer, count in analysis["failures_by_printer"].items():
rows.append([printer, count])
rows.append([""])
rows.append(["Weekly Trend", ""])
rows.append(["Week", "Total", "Failed", "Rate (%)"])
for week in analysis["trend"]:
rows.append([
week["week_start"],
week["total_prints"],
week["failed_prints"],
week["failure_rate"],
])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if format == "xlsx":
file_bytes = self._generate_xlsx_simple(rows)
filename = f"stats_export_{timestamp}.xlsx"
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
file_bytes = self._generate_csv_simple(rows)
filename = f"stats_export_{timestamp}.csv"
content_type = "text/csv"
return file_bytes, filename, content_type
def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:
"""Convert an archive to a row of values."""
row = []
for field in fields:
if field == "project_name":
value = archive.project.name if archive.project else None
elif field in ("started_at", "completed_at", "created_at"):
value = getattr(archive, field)
if value:
value = value.isoformat()
else:
value = getattr(archive, field, None)
row.append(value)
return row
def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:
"""Generate CSV file content."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(headers)
writer.writerows(rows)
return output.getvalue().encode("utf-8")
def _generate_csv_simple(self, rows: list[list]) -> bytes:
"""Generate CSV file content from simple rows (no separate headers)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerows(rows)
return output.getvalue().encode("utf-8")
def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:
"""Generate Excel file content."""
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
except ImportError:
raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
wb = Workbook()
ws = wb.active
ws.title = "Archives"
# Header style
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center")
# Write headers
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# Write data
for row_idx, row in enumerate(rows, 2):
for col_idx, value in enumerate(row, 1):
ws.cell(row=row_idx, column=col_idx, value=value)
# Auto-adjust column widths
for col_idx, field in enumerate(fields, 1):
column_letter = get_column_letter(col_idx)
max_length = len(headers[col_idx - 1])
for row in rows:
cell_value = row[col_idx - 1]
if cell_value is not None:
max_length = max(max_length, len(str(cell_value)))
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
# Freeze header row
ws.freeze_panes = "A2"
output = io.BytesIO()
wb.save(output)
return output.getvalue()
def _generate_xlsx_simple(self, rows: list[list]) -> bytes:
"""Generate Excel file content from simple rows."""
try:
from openpyxl import Workbook
from openpyxl.styles import Font
except ImportError:
raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
wb = Workbook()
ws = wb.active
ws.title = "Statistics"
bold_font = Font(bold=True)
for row_idx, row in enumerate(rows, 1):
for col_idx, value in enumerate(row, 1):
cell = ws.cell(row=row_idx, column=col_idx, value=value)
# Bold section headers
if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"):
cell.font = bold_font
output = io.BytesIO()
wb.save(output)
return output.getvalue()
+198
View File
@@ -0,0 +1,198 @@
from datetime import datetime, timedelta
from collections import defaultdict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from backend.app.models.archive import PrintArchive
from backend.app.models.printer import Printer
class FailureAnalysisService:
"""Service for analyzing print failure patterns."""
def __init__(self, db: AsyncSession):
self.db = db
async def analyze_failures(
self,
days: int = 30,
printer_id: int | None = None,
project_id: int | None = None,
) -> dict:
"""Analyze failure patterns across archives.
Args:
days: Number of days to analyze
printer_id: Optional filter by printer
project_id: Optional filter by project
Returns:
Dictionary with failure analysis results
"""
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Build base query
base_filter = [PrintArchive.created_at >= cutoff_date]
if printer_id:
base_filter.append(PrintArchive.printer_id == printer_id)
if project_id:
base_filter.append(PrintArchive.project_id == project_id)
# Total counts
total_result = await self.db.execute(
select(func.count(PrintArchive.id)).where(and_(*base_filter))
)
total_prints = total_result.scalar() or 0
failed_result = await self.db.execute(
select(func.count(PrintArchive.id)).where(
and_(*base_filter, PrintArchive.status == "failed")
)
)
failed_prints = failed_result.scalar() or 0
failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
# Failures by reason
reason_result = await self.db.execute(
select(
PrintArchive.failure_reason,
func.count(PrintArchive.id).label("count"),
)
.where(and_(*base_filter, PrintArchive.status == "failed"))
.group_by(PrintArchive.failure_reason)
.order_by(func.count(PrintArchive.id).desc())
)
failures_by_reason = {
(row[0] or "Unknown"): row[1]
for row in reason_result.fetchall()
}
# Failures by filament type
filament_result = await self.db.execute(
select(
PrintArchive.filament_type,
func.count(PrintArchive.id).label("count"),
)
.where(and_(*base_filter, PrintArchive.status == "failed"))
.group_by(PrintArchive.filament_type)
.order_by(func.count(PrintArchive.id).desc())
)
failures_by_filament = {
(row[0] or "Unknown"): row[1]
for row in filament_result.fetchall()
}
# Failures by printer
printer_result = await self.db.execute(
select(
PrintArchive.printer_id,
func.count(PrintArchive.id).label("count"),
)
.where(
and_(*base_filter, PrintArchive.status == "failed", PrintArchive.printer_id.isnot(None))
)
.group_by(PrintArchive.printer_id)
.order_by(func.count(PrintArchive.id).desc())
)
failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
# Get printer names
if failures_by_printer_id:
printers_result = await self.db.execute(
select(Printer.id, Printer.name).where(
Printer.id.in_(failures_by_printer_id.keys())
)
)
printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
failures_by_printer = {
printer_names.get(pid, f"Printer {pid}"): count
for pid, count in failures_by_printer_id.items()
}
else:
failures_by_printer = {}
# Failures by hour of day
failed_archives_result = await self.db.execute(
select(PrintArchive.started_at)
.where(
and_(
*base_filter,
PrintArchive.status == "failed",
PrintArchive.started_at.isnot(None),
)
)
)
failures_by_hour = defaultdict(int)
for (started_at,) in failed_archives_result.fetchall():
if started_at:
hour = started_at.hour
failures_by_hour[hour] += 1
# Convert to dict with all 24 hours
failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
# Recent failures
recent_result = await self.db.execute(
select(PrintArchive)
.where(and_(*base_filter, PrintArchive.status == "failed"))
.order_by(PrintArchive.created_at.desc())
.limit(10)
)
recent_failures = [
{
"id": a.id,
"print_name": a.print_name or a.filename,
"failure_reason": a.failure_reason,
"filament_type": a.filament_type,
"printer_id": a.printer_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in recent_result.scalars().all()
]
# Failure rate trend (by week)
trend_data = []
for i in range(min(days // 7, 12)): # Up to 12 weeks
week_end = datetime.utcnow() - timedelta(weeks=i)
week_start = week_end - timedelta(weeks=1)
week_filter = base_filter.copy()
week_filter[0] = and_(
PrintArchive.created_at >= week_start,
PrintArchive.created_at < week_end,
)
week_total = await self.db.execute(
select(func.count(PrintArchive.id)).where(and_(*week_filter))
)
week_failed = await self.db.execute(
select(func.count(PrintArchive.id)).where(
and_(*week_filter, PrintArchive.status == "failed")
)
)
total = week_total.scalar() or 0
failed = week_failed.scalar() or 0
rate = (failed / total * 100) if total > 0 else 0
trend_data.append({
"week_start": week_start.date().isoformat(),
"total_prints": total,
"failed_prints": failed,
"failure_rate": round(rate, 1),
})
trend_data.reverse() # Oldest first
return {
"period_days": days,
"total_prints": total_prints,
"failed_prints": failed_prints,
"failure_rate": round(failure_rate, 1),
"failures_by_reason": failures_by_reason,
"failures_by_filament": failures_by_filament,
"failures_by_printer": failures_by_printer,
"failures_by_hour": failures_by_hour_complete,
"recent_failures": recent_failures,
"trend": trend_data,
}
+7
View File
@@ -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" }]
}
]
}
+2
View File
@@ -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
View File
@@ -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>
);
}
+89 -20
View File
@@ -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>
+21 -16
View File
@@ -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 && (
+29 -1
View File
@@ -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>
+2 -1
View File
@@ -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' },
];
+1
View File
@@ -7,6 +7,7 @@ export default {
stats: 'Statistiken',
profiles: 'Profile',
maintenance: 'Wartung',
projects: 'Projekte',
settings: 'Einstellungen',
collapseSidebar: 'Seitenleiste einklappen',
expandSidebar: 'Seitenleiste ausklappen',
+1
View File
@@ -7,6 +7,7 @@ export default {
stats: 'Statistics',
profiles: 'Profiles',
maintenance: 'Maintenance',
projects: 'Projects',
settings: 'Settings',
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
+196 -2
View File
@@ -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>
);
}
+453
View File
@@ -0,0 +1,453 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FolderKanban,
Loader2,
Plus,
Trash2,
Edit3,
Archive,
ListTodo,
Package,
} from 'lucide-react';
import { api } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { useToast } from '../contexts/ToastContext';
const PROJECT_COLORS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#06b6d4', // cyan
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6b7280', // gray
];
interface ProjectModalProps {
project?: ProjectListItem;
onClose: () => void;
onSave: (data: ProjectCreate | ProjectUpdate) => void;
isLoading: boolean;
}
function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
const [name, setName] = useState(project?.name || '');
const [description, setDescription] = useState(project?.description || '');
const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
const [status, setStatus] = useState(project?.status || 'active');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name: name.trim(),
description: description.trim() || undefined,
color,
target_count: targetCount ? parseInt(targetCount, 10) : undefined,
...(project && { status }),
});
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
<div className="p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">
{project ? 'Edit Project' : 'New Project'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., Voron 2.4 Build"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
placeholder="Optional description..."
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Color
</label>
<div className="flex gap-2 flex-wrap">
{PROJECT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-8 h-8 rounded-full transition-transform ${
color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Target Print Count (optional)
</label>
<input
type="number"
value={targetCount}
onChange={(e) => setTargetCount(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., 50 parts to print"
min="1"
/>
</div>
{project && (
<div>
<label className="block text-sm font-medium text-white mb-1">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : project ? (
'Save'
) : (
'Create'
)}
</Button>
</div>
</form>
</div>
</div>
);
}
interface ProjectCardProps {
project: ProjectListItem;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
}
function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
const progressPercent = project.progress_percent ?? 0;
const isCompleted = project.status === 'completed';
const isArchived = project.status === 'archived';
return (
<Card className="hover:border-bambu-gray/30 transition-colors cursor-pointer" onClick={onClick}>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div>
<h3 className="font-medium text-white">{project.name}</h3>
{project.description && (
<p className="text-sm text-bambu-gray/70 mt-0.5 line-clamp-1">
{project.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{isCompleted && (
<span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded">
Completed
</span>
)}
{isArchived && (
<span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded">
Archived
</span>
)}
<Button variant="ghost" size="sm" onClick={onEdit} className="p-1">
<Edit3 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onDelete} className="p-1 text-red-400 hover:text-red-300">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Progress bar */}
{project.target_count && (
<div className="mb-3">
<div className="flex justify-between text-xs text-bambu-gray mb-1">
<span>{project.archive_count} / {project.target_count} prints</span>
<span>{progressPercent.toFixed(0)}%</span>
</div>
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300"
style={{
width: `${Math.min(progressPercent, 100)}%`,
backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
</div>
</div>
)}
{/* Archive thumbnails */}
{project.archives && project.archives.length > 0 && (
<div className="mb-3">
<div className="flex gap-2">
{project.archives.slice(0, 5).map((archive) => (
<a
key={archive.id}
href={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
onClick={(e) => e.stopPropagation()}
className="relative w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
title={archive.print_name || 'Unknown'}
>
{archive.thumbnail_path ? (
<img
src={`/api/v1/archives/${archive.id}/thumbnail`}
alt={archive.print_name || ''}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
<Package className="w-6 h-6" />
</div>
)}
{archive.status === 'failed' && (
<div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
<span className="text-white text-xs font-bold">✗</span>
</div>
)}
</a>
))}
{project.archive_count > 5 && (
<div className="w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-sm text-bambu-gray border border-bambu-dark-tertiary">
+{project.archive_count - 5}
</div>
)}
</div>
</div>
)}
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-bambu-gray">
<div className="flex items-center gap-1" title="Archives">
<Archive className="w-4 h-4" />
<span>{project.archive_count}</span>
</div>
<div className="flex items-center gap-1" title="Queued">
<ListTodo className="w-4 h-4" />
<span>{project.queue_count}</span>
</div>
</div>
</CardContent>
</Card>
);
}
export function ProjectsPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [showModal, setShowModal] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
const [statusFilter, setStatusFilter] = useState<string>('active');
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const { data: projects, isLoading } = useQuery({
queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
});
const createMutation = useMutation({
mutationFn: (data: ProjectCreate) => api.createProject(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setShowModal(false);
showToast('Project created', 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
api.updateProject(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setShowModal(false);
setEditingProject(undefined);
showToast('Project updated', 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteProject(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setDeleteConfirm(null);
showToast('Project deleted', 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handleSave = (data: ProjectCreate | ProjectUpdate) => {
if (editingProject) {
updateMutation.mutate({ id: editingProject.id, data });
} else {
createMutation.mutate(data as ProjectCreate);
}
};
const handleEdit = (project: ProjectListItem) => {
setEditingProject(project);
setShowModal(true);
};
const handleClick = (project: ProjectListItem) => {
// Open edit modal when clicking on card
handleEdit(project);
};
const handleDeleteClick = (id: number) => {
setDeleteConfirm(id);
};
const handleDeleteConfirm = () => {
if (deleteConfirm !== null) {
deleteMutation.mutate(deleteConfirm);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FolderKanban className="w-6 h-6 text-bambu-green" />
<h1 className="text-2xl font-bold text-white">Projects</h1>
</div>
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div>
{/* Filters */}
<div className="flex gap-2">
{['active', 'completed', 'archived', 'all'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
statusFilter === status
? 'bg-bambu-green text-white'
: 'bg-bambu-card text-bambu-gray hover:bg-bambu-gray/20'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
) : projects?.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<FolderKanban className="w-12 h-12 text-bambu-gray/50 mx-auto mb-4" />
<p className="text-bambu-gray">No projects found</p>
<p className="text-bambu-gray/70 text-sm mt-1">
Create a project to group related prints together
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects?.map((project) => (
<ProjectCard
key={project.id}
project={project}
onClick={() => handleClick(project)}
onEdit={() => handleEdit(project)}
onDelete={() => handleDeleteClick(project.id)}
/>
))}
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm !== null && (
<ConfirmModal
title="Delete Project"
message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
confirmText="Delete Project"
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
)}
{/* Modal */}
{showModal && (
<ProjectModal
project={editingProject}
onClose={() => {
setShowModal(false);
setEditingProject(undefined);
}}
onSave={handleSave}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
</div>
);
}
+338 -2
View File
@@ -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
+161 -4
View File
@@ -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>
);
}
+3
View File
@@ -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
View File
@@ -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>
+7
View File
@@ -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" }]
}
]
}