Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
ae9818c
feat(queue): show spool grams left in filament slot mapping
cimdDev Feb 23, 2026
f46c134
Bumped version
maziggy Feb 24, 2026
46f209e
Add SpoolBuddy AMS slot config, external slots, and dashboard redesign
maziggy Feb 24, 2026
8f3d194
Fix SpoolBuddy daemon crash when read_tag module is missing
maziggy Feb 24, 2026
541f892
Fix SpoolBuddy daemon failing to import hardware drivers
maziggy Feb 24, 2026
12fe074
Increase scale moving average window to reduce weight bouncing
maziggy Feb 24, 2026
2811e2e
Remove stability flipping as scale report trigger
maziggy Feb 24, 2026
1543753
Fix formatting of option elements in FilamentMapping
maziggy Feb 24, 2026
8c9c0a7
Minor Spoolbuddy frontend improvements
maziggy Feb 26, 2026
f7e65aa
Updated Spoolbuddy install script to strip down Raspbian
maziggy Feb 27, 2026
cc13166
Updated Spoolbuddy install script
maziggy Feb 27, 2026
42b07d8
Add API key auth support to /auth/me for SpoolBuddy kiosk
maziggy Feb 27, 2026
0200c5d
SpoolBuddy touch-friendly UI overhaul for 1024x600 kiosk display
maziggy Feb 27, 2026
c928be1
Updated CI
maziggy Feb 27, 2026
fe5bb1f
Fix naive-vs-aware datetime crash from 0.2.1 timezone migration
maziggy Feb 27, 2026
41dd80b
Fix naive-vs-aware datetime crash from 0.2.1 timezone migration
maziggy Feb 27, 2026
7eb7bf7
Fix queue badge showing on printers without matching filament (#486)
maziggy Feb 28, 2026
09e6388
Fix Windows install syntax error from LF line endings (#544)
maziggy Feb 28, 2026
7767851
Add SpoolBuddy tag detection modal and fix NFC reader silent failure
maziggy Feb 28, 2026
464c596
Add SpoolBuddy tag detection modal and fix NFC reader silent failure
maziggy Feb 28, 2026
2d814ea
Add SpoolBuddy tag detection modal and fix NFC reader polling
maziggy Feb 28, 2026
7a5c0b7
Add SpoolBuddy tag detection modal and fix NFC reader polling
maziggy Feb 28, 2026
d466a4a
Add SpoolBuddy tag detection modal and fix NFC reader polling
maziggy Feb 28, 2026
7bc549e
Fix SpoolBuddy scale tare & calibration not being applied
maziggy Feb 28, 2026
bf33dcc
Fix SpoolBuddy scale tare & calibration not being applied
maziggy Feb 28, 2026
49ddb57
Fix SpoolBuddy scale tare & calibration not being applied
maziggy Feb 28, 2026
04e64ff
Fix SpoolBuddy scale tare & calibration not being applied
maziggy Feb 28, 2026
6912344
Fix A1 Mini "unknown" status from non-UTF-8 MQTT payload (#549)
maziggy Feb 28, 2026
ce97a47
Add H2C dual nozzle variant O1C2 model support (#489)
maziggy Feb 28, 2026
1eeeb37
Updated commit message (now includes the test fix):
maziggy Feb 28, 2026
65c904b
Fix sidebar navigation not respecting user permissions
maziggy Feb 28, 2026
b4b8758
Uodated issue template
maziggy Feb 28, 2026
4c94c38
Updated README
maziggy Feb 28, 2026
f488c9f
Fix Windows install syntax error from multi-line for /f command (#544)
maziggy Mar 1, 2026
aa59e00
Separate firmware and software sections in Updates settings card
maziggy Mar 1, 2026
8843af6
Fix support package: mask subnet IPs, detect host mode, parse top-l…
maziggy Mar 1, 2026
f39464d
Fix virtual printer config changes ignored on running instances
maziggy Mar 1, 2026
8d98947
Fix queue 500 error when cancelled print exists (#558)
maziggy Mar 1, 2026
1cf40a5
Redesign SpoolBuddy dashboard: inline spool cards, full-screen AMS …
maziggy Mar 1, 2026
bf66f7a
Added delete tag button to inventory's edit spool modal
maziggy Mar 1, 2026
e5b2cee
Fix VP bind server rejecting TLS connections on port 3002 (#559)
maziggy Mar 1, 2026
4fb4e5f
Fix SpoolBuddy scale calibration lost after reboot
maziggy Mar 1, 2026
b2603c8
fix(stats): calculate success rate from completed and failed prints only
aneopsy Feb 28, 2026
792a86f
feat(stats): add new widgets, shared MetricToggle, and timeframe filt…
aneopsy Feb 28, 2026
1a7bdf2
test(stats): update StatsPage tests to match current widget structure
aneopsy Feb 28, 2026
5216beb
refactor(stats): improve Color Distribution card layout and toggle
aneopsy Mar 1, 2026
168c00f
feat(stats): add /archives/slim endpoint and fix dashboard bugs
aneopsy Mar 1, 2026
ff3d3a8
feat(stats): add hourly heatmap for short timeframes and fix timezone…
aneopsy Mar 1, 2026
42eb19a
Fix SpoolBuddy "Assign to AMS" slot showing empty fields in slicer
maziggy Mar 1, 2026
c2326b4
Add SpoolBuddy backend + frontend test coverage
maziggy Mar 1, 2026
7fab96e
Fix printer card losing print info when paused (#562)
maziggy Mar 1, 2026
449dd24
Merge branch '0.2.2b1' into statistic-timeframe
maziggy Mar 1, 2026
2b2a407
Fix num_weeks calculation to ensure at least one week
maziggy Mar 1, 2026
eaaa4a2
Merge pull request #561 from aneopsy/statistic-timeframe
maziggy Mar 1, 2026
6668ecd
Updated README
maziggy Mar 1, 2026
8f103f3
Add daily beta build publish script
maziggy Mar 1, 2026
533b305
Updated README
maziggy Mar 1, 2026
6a4d890
Add daily beta build publish script
maziggy Mar 1, 2026
a501643
Fix SpoolBuddy tag_type for linked spools + add inventory weight ch…
maziggy Mar 2, 2026
8b36a12
Fix SpoolBuddy AMS page fill levels, ext-R active bug, and layout
maziggy Mar 2, 2026
9f6acd6
Updated Spoolbuddy install script
maziggy Mar 2, 2026
bffbac5
Add on-screen virtual keyboard for SpoolBuddy kiosk UI
maziggy Mar 2, 2026
f2d28a0
Spoolbuddy frontend fixes
maziggy Mar 2, 2026
28fda7b
Display Controls (brightness + blanking)
maziggy Mar 2, 2026
48b492f
Spoolbuddy
maziggy Mar 2, 2026
628a814
Redesign SpoolBuddy settings page, add language/time format support
maziggy Mar 2, 2026
e1795ff
Add printer status badges, remove SpoolBuddy inventory page
maziggy Mar 2, 2026
f943420
Add SpoolBuddy NFC tag writing with OpenTag3D format
maziggy Mar 2, 2026
219214f
Fix K-profile greenlet error on auto-created RFID spools
maziggy Mar 2, 2026
e2ba768
Fix spurious error notifications from print_error status codes
maziggy Mar 2, 2026
3c52b5e
Updated docker-publish-daily-beta.sh
maziggy Mar 2, 2026
9cdda80
Added Simplified Chinese translation zh-CN (Issue #571)
maziggy Mar 3, 2026
97f6250
Add customizable low stock threshold, add low stock filter (#531)
Keybored02 Mar 3, 2026
0918c57
Add i18n keys for printer card drag-drop print overlay
maziggy Mar 3, 2026
fa9d377
[Feature]: Printer Page - Add a print button & Drop zone on the print…
aneopsy Mar 3, 2026
f45f3b0
Post work PR #569
maziggy Mar 3, 2026
d048d72
Fix AMS slot modal infinite scroll loop on Windows
aneopsy Mar 3, 2026
82178f8
Add energy cost to archive card (#573)
Keybored02 Mar 3, 2026
01b8fbb
Merge branch '0.2.2b1' into fix-ams-slot
aneopsy Mar 3, 2026
d4aa77f
Merge pull request #578 from aneopsy/fix-ams-slot
maziggy Mar 3, 2026
277db1b
Revert "Merge pull request #578 from aneopsy/fix-ams-slot"
maziggy Mar 3, 2026
2a740c5
Reverted commit d4aa77f50240f461e4f24e7e72a7d03aee05ee55
maziggy Mar 3, 2026
e3ebe86
Remove obsolete slicer_binary_path setting from database
maziggy Mar 3, 2026
23b2d97
Add Hungarian Forint (HUF) currency for filament cost tracking
maziggy Mar 3, 2026
330d771
Fix AMS slot showing wrong material for "Support for" profiles
maziggy Mar 3, 2026
6b92a99
Improve FTP upload progress and widen print modal
maziggy Mar 3, 2026
19a1a75
Fix archive card showing "Source" badge for sliced .3mf files
maziggy Mar 3, 2026
15521de
Refactor date and currency utility functions
aneopsy Mar 3, 2026
abe40f1
Consolidate duplicate utility functions into shared modules
aneopsy Mar 3, 2026
acae51b
Fix spurious 0300_0002 error notification via HMS array path (#583)
maziggy Mar 4, 2026
76fd8c5
Add SSDP model codes to firmware check mapping (#584)
maziggy Mar 4, 2026
6388b0d
Show ethernet indicator instead of WiFi signal for wired printers (…
maziggy Mar 4, 2026
ba2abe1
Merge branch '0.2.2b1' into refactor-utils
maziggy Mar 4, 2026
3524af4
Merge pull request #581 from aneopsy/refactor-utils
maziggy Mar 4, 2026
4c81a92
Post work PR #581
maziggy Mar 4, 2026
058f74a
Add in-app bug reporting with relay, debug log collection, and priv…
maziggy Mar 4, 2026
e69d0b8
[Fix]: AMS slot modal infinite scroll loop on Windows (#580)
aneopsy Mar 4, 2026
39a2315
Housekeeping
maziggy Mar 4, 2026
119f1da
Merge remote-tracking branch 'origin/main' into 0.2.2b1
maziggy Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Debug logging toggle with live indicator
- Live application log viewer with filtering
- Support bundle generator with comprehensive diagnostics (privacy-filtered)
- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), automatic diagnostic log collection (30s debug capture with printer push), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.

### 🔒 Optional Authentication
- Enable/disable authentication any time
Expand Down
128 changes: 115 additions & 13 deletions backend/app/api/routes/archives.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import logging
import zipfile
from datetime import date, datetime, time, timezone
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path

Expand All @@ -21,7 +22,7 @@
from backend.app.models.filament import Filament
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.user import User
from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
from backend.app.services.archive import ArchiveService
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf

Expand Down Expand Up @@ -122,6 +123,8 @@ def archive_to_response(
async def list_archives(
printer_id: int | None = None,
project_id: int | None = None,
date_from: date | None = Query(None),
date_to: date | None = Query(None),
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
Expand All @@ -132,6 +135,8 @@ async def list_archives(
archives = await service.list_archives(
printer_id=printer_id,
project_id=project_id,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
)
Expand All @@ -149,6 +154,78 @@ async def list_archives(
return result


@router.get("/slim", response_model=list[ArchiveSlim])
async def list_archives_slim(
date_from: date | None = Query(None),
date_to: date | None = Query(None),
limit: int = Query(default=10000, le=50000),
offset: int = 0,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Lightweight archive listing for stats/dashboard widgets.

Returns only the fields needed for client-side aggregation,
skipping duplicate detection, file paths, and extra_data.
"""
filters = []
if date_from:
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
filters.append(PrintArchive.created_at >= dt_from)
if date_to:
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
filters.append(PrintArchive.created_at <= dt_to)

query = (
select(
PrintArchive.printer_id,
PrintArchive.print_name,
PrintArchive.print_time_seconds,
PrintArchive.started_at,
PrintArchive.completed_at,
PrintArchive.filament_used_grams,
PrintArchive.filament_type,
PrintArchive.filament_color,
PrintArchive.status,
PrintArchive.cost,
PrintArchive.quantity,
PrintArchive.created_at,
)
.where(*filters)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
rows = result.all()

return [
{
"printer_id": r.printer_id,
"print_name": r.print_name,
"print_time_seconds": r.print_time_seconds,
"actual_time_seconds": (
int((r.completed_at - r.started_at).total_seconds())
if r.started_at
and r.completed_at
and r.status == "completed"
and (r.completed_at - r.started_at).total_seconds() > 0
else None
),
"filament_used_grams": r.filament_used_grams,
"filament_type": r.filament_type,
"filament_color": r.filament_color,
"status": r.status,
"started_at": r.started_at,
"completed_at": r.completed_at,
"cost": r.cost,
"quantity": r.quantity,
"created_at": r.created_at,
}
for r in rows
]


@router.get("/search", response_model=list[ArchiveResponse])
async def search_archives(
q: str = Query(..., min_length=2, description="Search query"),
Expand Down Expand Up @@ -277,7 +354,9 @@ async def rebuild_search_index(

@router.get("/analysis/failures")
async def analyze_failures(
days: int = 30,
days: int | None = None,
date_from: date | None = Query(None),
date_to: date | None = Query(None),
printer_id: int | None = None,
project_id: int | None = None,
db: AsyncSession = Depends(get_db),
Expand All @@ -297,6 +376,8 @@ async def analyze_failures(
service = FailureAnalysisService(db)
return await service.analyze_failures(
days=days,
date_from=date_from,
date_to=date_to,
printer_id=printer_id,
project_id=project_id,
)
Expand Down Expand Up @@ -440,25 +521,42 @@ async def export_stats(

@router.get("/stats", response_model=ArchiveStats)
async def get_archive_stats(
date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
):
"""Get statistics across all archives."""
# Build date filter conditions
base_conditions = []
if date_from:
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
base_conditions.append(PrintArchive.created_at >= dt_from)
if date_to:
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
base_conditions.append(PrintArchive.created_at <= dt_to)

# Total counts
total_result = await db.execute(select(func.count(PrintArchive.id)))
total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
total_prints = total_result.scalar() or 0

successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
successful_result = await db.execute(
select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
)
successful_prints = successful_result.scalar() or 0

failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
failed_result = await db.execute(
select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
)
failed_prints = failed_result.scalar() or 0

# Totals - use actual print time from timestamps (not slicer estimates)
# For archives with both started_at and completed_at, calculate actual duration
# Fall back to print_time_seconds only for archives without timestamps
archives_for_time = await db.execute(
select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds)
select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
*base_conditions
)
)
total_seconds = 0
for started_at, completed_at, print_time_seconds in archives_for_time.all():
Expand All @@ -473,15 +571,17 @@ async def get_archive_stats(
total_time = total_seconds / 3600 # Convert to hours

# Sum filament directly - filament_used_grams already contains the total for the print job
filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
filament_result = await db.execute(
select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
)
total_filament = filament_result.scalar() or 0

cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
total_cost = cost_result.scalar() or 0

# By filament type (split comma-separated values for multi-material prints)
filament_type_result = await db.execute(
select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None))
select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
)
prints_by_filament: dict[str, int] = {}
for (filament_types,) in filament_type_result.all():
Expand All @@ -493,15 +593,17 @@ async def get_archive_stats(

# By printer
printer_result = await db.execute(
select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
select(PrintArchive.printer_id, func.count(PrintArchive.id))
.where(*base_conditions)
.group_by(PrintArchive.printer_id)
)
prints_by_printer = {str(k): v for k, v in printer_result.all()}

# Time accuracy statistics
# Get all completed archives with both estimated and actual times
accuracy_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.status == "completed")
.where(PrintArchive.status == "completed", *base_conditions)
.where(PrintArchive.print_time_seconds.isnot(None))
.where(PrintArchive.started_at.isnot(None))
.where(PrintArchive.completed_at.isnot(None))
Expand Down Expand Up @@ -575,10 +677,10 @@ async def get_archive_stats(
total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
else:
# Print mode: sum up per-print energy from archives
energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
total_energy_kwh = energy_kwh_result.scalar() or 0

energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)))
energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
total_energy_cost = energy_cost_result.scalar() or 0

return ArchiveStats(
Expand Down
96 changes: 89 additions & 7 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
from datetime import timedelta
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from backend.app.api.routes.settings import get_external_login_url
from backend.app.core.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
ALGORITHM,
SECRET_KEY,
Permission,
RequirePermissionIfAuthEnabled,
_validate_api_key,
authenticate_user,
authenticate_user_by_email,
create_access_token,
get_current_active_user,
get_password_hash,
get_user_by_email,
get_user_by_username,
security,
)
from backend.app.core.database import get_db
from backend.app.core.permissions import ALL_PERMISSIONS
from backend.app.models.group import Group
from backend.app.models.settings import Settings
from backend.app.models.user import User
Expand Down Expand Up @@ -61,6 +68,21 @@ def _user_to_response(user: User) -> UserResponse:
)


def _api_key_to_user_response(api_key) -> UserResponse:
"""Create a synthetic admin UserResponse for a valid API key."""
return UserResponse(
id=0,
username=f"api-key:{api_key.key_prefix}",
email=None,
role="admin",
is_active=True,
is_admin=True,
groups=[],
permissions=sorted(ALL_PERMISSIONS),
created_at=api_key.created_at.isoformat(),
)


router = APIRouter(prefix="/auth", tags=["authentication"])


Expand Down Expand Up @@ -308,14 +330,74 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):

@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: User = Depends(get_current_active_user),
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
db: AsyncSession = Depends(get_db),
):
"""Get current user information."""
# Reload user with groups for proper permission calculation
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
return _user_to_response(user)
"""Get current user information.

Accepts JWT tokens (via Authorization: Bearer header) and API keys
(via X-API-Key header or Authorization: Bearer bb_xxx).
API keys return a synthetic admin user with all permissions.
"""
import jwt
from jwt.exceptions import PyJWTError as JWTError

# Check for API key via X-API-Key header
if x_api_key:
api_key = await _validate_api_key(db, x_api_key)
if api_key:
return _api_key_to_user_response(api_key)

# Check for Bearer token (could be JWT or API key)
if credentials is not None:
token = credentials.credentials
# Check if it's an API key (starts with bb_)
if token.startswith("bb_"):
api_key = await _validate_api_key(db, token)
if api_key:
return _api_key_to_user_response(api_key)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "Bearer"},
)

# Otherwise treat as JWT
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Reload with groups for proper permission calculation
result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
user = result.scalar_one()
return _user_to_response(user)

# No credentials provided
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)


@router.post("/logout")
Expand Down
Loading