Compare commits

...

11 Commits

Author SHA1 Message Date
Martin Hohenberg
ff2b618414 Duplicate job 2026-07-06 17:17:22 +02:00
Martin Hohenberg
38e6d38a2f Show open Vikunja task count as badge on To-Do sidebar link
Fetched async via /api/todos/count so it doesn't block page render.
Badge hidden when count is zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:16:19 +02:00
Martin Hohenberg
f713fbe2fb Count cancelled jobs as done in bracket progress bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:15:43 +02:00
Martin Hohenberg
7dec15d777 Exclude cancelled jobs from bracket progress bar count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:26:02 +02:00
Martin Hohenberg
5270d96b92 Sort jobs: internal always last within each priority stratum
Order: priority customer > priority internal > normal customer > normal internal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:21:19 +02:00
Martin Hohenberg
5a61a35839 Default customer to 'internal' when left blank
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:19:52 +02:00
Martin Hohenberg
dc8918d323 Isolate STL analysis in child process; fix 3MF memory usage
- _load_geometry(): compute extents/volume from individual Scene meshes
  without concatenating — avoids the RAM spike that crashed the container
- analyse_in_background(): spawns a child process (spawn context, not fork)
  with a 120s timeout; OOM/crash in child no longer takes down the app
- _failed set: tracks files that timed out or crashed; spinner stops and
  a warning with inline override form is shown instead of looping forever
- analysis_failed state passed to template; override form shown immediately
  when analysis failed (no expand needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:32:15 +02:00
Martin Hohenberg
6a1f146d8e Background STL analysis with computing spinner; manual geometry override
- analyse_in_background() runs in FastAPI BackgroundTasks with its own
  DB session; _in_progress set prevents duplicate concurrent analyses
- Job detail shows a spinner + auto-reloads every 3s while computing
- 'Compute now' button also triggers background analysis
- StlCache.manually_set flag (migration 0011): entries marked manual are
  never overwritten by auto-analysis
- 'Override values' collapsible form on job detail lets user set
  bounding box and volume directly from slicer data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:07:44 +02:00
Martin Hohenberg
fc13d905db Add networkx and lxml for trimesh 3MF support
Both are required by trimesh's threemf loader as soft dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:59:43 +02:00
Martin Hohenberg
ab52721c7f Fix 3MF analysis: concatenate scene geometries instead of force="mesh"
trimesh loads 3MF files as a Scene; force="mesh" fails on those.
Detect Scene and concatenate all geometries before computing extents/volume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:56:21 +02:00
Martin Hohenberg
1e74b3a149 Auto-analyse STL on job detail load; warn if file missing
- If file exists but has no cache entry, run analyse() on page load
- If file doesn't exist on disk, show a red alert in the File & Geometry card
- Hide Download/Compute buttons when file is missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:54:21 +02:00
12 changed files with 321 additions and 22 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
"""stl_cache manually_set flag
Revision ID: 0011
Revises: 0010
Create Date: 2026-06-19
"""
from alembic import op
import sqlalchemy as sa
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("stl_cache", sa.Column("manually_set", sa.Boolean(), server_default="0", nullable=False))
def downgrade():
op.drop_column("stl_cache", "manually_set")

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, String, func from sqlalchemy import Boolean, DateTime, Float, String, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -15,4 +15,5 @@ class StlCache(Base):
cube_y: Mapped[float | None] = mapped_column(Float) # mm cube_y: Mapped[float | None] = mapped_column(Float) # mm
cube_z: Mapped[float | None] = mapped_column(Float) # mm cube_z: Mapped[float | None] = mapped_column(Float) # mm
volume_ccm: Mapped[float | None] = mapped_column(Float) # cm³ volume_ccm: Mapped[float | None] = mapped_column(Float) # cm³
manually_set: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

View File

@@ -4,6 +4,12 @@ from app.vikunja import create_task, delete_task, get_tasks, update_task
router = APIRouter(prefix="/todos", tags=["todos"]) router = APIRouter(prefix="/todos", tags=["todos"])
@router.get("/count")
def count_todos():
tasks = get_tasks()
return {"open": sum(1 for t in tasks if not t.get("done"))}
@router.get("/") @router.get("/")
def list_todos(): def list_todos():
return get_tasks() return get_tasks()

View File

@@ -1,10 +1,11 @@
import math import math
import os import os
import re
import uuid as _uuid import uuid as _uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import case, or_ from sqlalchemy import case, or_
@@ -26,7 +27,7 @@ from app.estimation import estimate # also used in start_job
from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll, from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
get_stock, log_print_on_roll, spool_counts) get_stock, log_print_on_roll, spool_counts)
from app.stl import get_stl_dir, list_stl_files from app.stl import get_stl_dir, list_stl_files
from app.stl_analysis import analyse, get_cache_map # noqa: F401 used in job_detail route from app.stl_analysis import analyse_in_background, get_cache_map, is_computing, is_failed # noqa: F401
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -90,10 +91,17 @@ def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_
printer = db.query(Printer).filter(Printer.id == printer_id).first() printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer: if not printer:
raise HTTPException(status_code=404, detail="Printer not found") raise HTTPException(status_code=404, detail="Printer not found")
current_job = (
db.query(PrintJob)
.filter(PrintJob.printer_id == printer_id, PrintJob.status == JobStatus.printing)
.order_by(PrintJob.started_at.desc())
.first()
)
return templates.TemplateResponse("printer_detail.html", { return templates.TemplateResponse("printer_detail.html", {
"request": request, "request": request,
"active": "printers", "active": "printers",
"printer": printer, "printer": printer,
"current_job": current_job,
}) })
@@ -155,13 +163,14 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
or_(~PrintJob.status.in_(finished), PrintJob.created_at >= cutoff) or_(~PrintJob.status.in_(finished), PrintJob.created_at >= cutoff)
) )
# Sort: printing → queued → rest, then priority, then oldest first # Sort: printing → queued → rest, then priority flag, then internal last, then oldest first
status_order = case( status_order = case(
(PrintJob.status == JobStatus.printing, 0), (PrintJob.status == JobStatus.printing, 0),
(PrintJob.status == JobStatus.queued, 1), (PrintJob.status == JobStatus.queued, 1),
else_=2, else_=2,
) )
jobs = query.order_by(status_order, PrintJob.priority.desc(), PrintJob.created_at.asc()).all() internal_last = case((PrintJob.customer == "internal", 1), else_=0)
jobs = query.order_by(status_order, PrintJob.priority.desc(), internal_last, PrintJob.created_at.asc()).all()
stl_files = list_stl_files() stl_files = list_stl_files()
stl_cache = get_cache_map(stl_files, db) stl_cache = get_cache_map(stl_files, db)
@@ -221,7 +230,7 @@ async def create_job(
filename = stl_select filename = stl_select
amount = max(1, amount) amount = max(1, amount)
customer_str = customer.strip() or None customer_str = customer.strip() or "internal"
job_name = Path(filename).stem if filename else (customer_str or "Unnamed") job_name = Path(filename).stem if filename else (customer_str or "Unnamed")
# Resolve bracket — auto-create one when printing multiple copies # Resolve bracket — auto-create one when printing multiple copies
@@ -260,18 +269,37 @@ async def create_job(
@router.get("/jobs/{job_id}", response_class=HTMLResponse) @router.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): def job_detail(job_id: int, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
from fastapi import HTTPException from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first() job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job: if not job:
raise HTTPException(status_code=404, detail="Job not found") raise HTTPException(status_code=404, detail="Job not found")
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
file_missing = False
computing = False
stl = None
if job.file_name:
file_path = get_stl_dir() / job.file_name
if not file_path.exists():
file_missing = True
else:
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl is None and not job.file_name.endswith(".gcode"):
if is_failed(job.file_name):
file_missing = False # file exists, analysis just failed
elif not is_computing(job.file_name):
background_tasks.add_task(analyse_in_background, job.file_name)
computing = not is_failed(job.file_name)
estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None
return templates.TemplateResponse("job_detail.html", { return templates.TemplateResponse("job_detail.html", {
"request": request, "request": request,
"active": "jobs", "active": "jobs",
"job": job, "job": job,
"stl": stl, "stl": stl,
"file_missing": file_missing,
"computing": computing,
"analysis_failed": job.file_name and is_failed(job.file_name),
"estimation": estimation, "estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(), "printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(), "brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
@@ -301,7 +329,7 @@ def edit_job(
job = db.query(PrintJob).filter(PrintJob.id == job_id).first() job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job: if not job:
raise HTTPException(status_code=404, detail="Job not found") raise HTTPException(status_code=404, detail="Job not found")
job.customer = customer.strip() or None job.customer = customer.strip() or "internal"
job.job_material = job_material or None job.job_material = job_material or None
job.job_color = job_color or None job.job_color = job_color or None
job.priority = bool(priority) job.priority = bool(priority)
@@ -315,6 +343,45 @@ def edit_job(
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/duplicate")
def duplicate_job(job_id: int, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
copy = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=job.name,
file_name=job.file_name,
printer_id=None,
bracket_id=job.bracket_id,
customer=job.customer,
job_material=job.job_material,
job_color=job.job_color,
priority=job.priority,
status=JobStatus.queued,
notes=job.notes,
)
db.add(copy)
# If the original was in a bracket, extend the "×N" count in its name to match.
if job.bracket and job.bracket.name:
job.bracket.name = re.sub(
r"×(\d+)\s*$",
lambda m: f"×{int(m.group(1)) + 1}",
job.bracket.name,
)
db.flush()
db.add(JobLog(job_id=copy.id, message=f"Job created (duplicate of #{job.id})"))
db.commit()
if copy.bracket_id:
return RedirectResponse(url=f"/jobs?bracket_id={copy.bracket_id}", status_code=HTTP_303_SEE_OTHER)
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
@router.get("/files/{filename}") @router.get("/files/{filename}")
def download_file(filename: str): def download_file(filename: str):
from fastapi import HTTPException from fastapi import HTTPException
@@ -445,14 +512,47 @@ def finish_job(job_id: int, db: Session = Depends(get_db)):
@router.post("/jobs/{job_id}/analyse") @router.post("/jobs/{job_id}/analyse")
def analyse_job_file(job_id: int, db: Session = Depends(get_db)): def analyse_job_file(job_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
from fastapi import HTTPException from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first() job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job or not job.file_name: if not job or not job.file_name:
raise HTTPException(status_code=404, detail="Job or file not found") raise HTTPException(status_code=404, detail="Job or file not found")
db.query(StlCache).filter_by(filename=job.file_name).delete() db.query(StlCache).filter_by(filename=job.file_name).delete()
db.commit() db.commit()
analyse(job.file_name, db) background_tasks.add_task(analyse_in_background, job.file_name)
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/geometry")
def set_geometry(
job_id: int,
volume_ccm: float = Form(...),
cube_x: float = Form(...),
cube_y: float = Form(...),
cube_z: float = Form(...),
db: Session = Depends(get_db),
):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job or not job.file_name:
raise HTTPException(status_code=404, detail="Job or file not found")
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl:
stl.volume_ccm = volume_ccm
stl.cube_x = cube_x
stl.cube_y = cube_y
stl.cube_z = cube_z
stl.manually_set = True
else:
db.add(StlCache(
filename=job.file_name,
volume_ccm=volume_ccm,
cube_x=cube_x,
cube_y=cube_y,
cube_z=cube_z,
manually_set=True,
))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)

View File

@@ -1,6 +1,9 @@
"""STL/3MF geometry analysis with background process isolation."""
import logging import logging
import multiprocessing as _mp
from pathlib import Path from pathlib import Path
import numpy as np
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.stl_cache import StlCache from app.models.stl_cache import StlCache
@@ -8,6 +11,27 @@ from app.models.stl_cache import StlCache
logger = logging.getLogger("pops") logger = logging.getLogger("pops")
_ANALYSABLE = {".stl", ".3mf"} _ANALYSABLE = {".stl", ".3mf"}
_TIMEOUT = 120 # seconds before we give up
_in_progress: set[str] = set()
_failed: set[str] = set()
def _load_geometry(filepath: str):
"""Return (extents_array, volume_mm3). Handles Scene without concatenating."""
import trimesh
loaded = trimesh.load(filepath)
if isinstance(loaded, trimesh.Scene):
meshes = list(loaded.geometry.values())
if not meshes:
raise ValueError("3MF scene contains no meshes")
bounds = np.array([m.bounds for m in meshes]) # (N, 2, 3)
extents = bounds[:, 1, :].max(axis=0) - bounds[:, 0, :].min(axis=0)
volume_mm3 = sum(abs(float(m.volume)) for m in meshes)
else:
extents = np.array(loaded.extents)
volume_mm3 = abs(float(loaded.volume))
return extents, volume_mm3
def analyse(filename: str, db: Session) -> StlCache | None: def analyse(filename: str, db: Session) -> StlCache | None:
@@ -22,10 +46,7 @@ def analyse(filename: str, db: Session) -> StlCache | None:
return None return None
try: try:
import trimesh extents, volume_mm3 = _load_geometry(str(filepath))
mesh = trimesh.load(str(filepath), force="mesh")
extents = mesh.extents # [size_x, size_y, size_z] in mm
volume_mm3 = abs(mesh.volume) # mm³ (negative for inverted normals)
entry = StlCache( entry = StlCache(
filename=filename, filename=filename,
cube_x=round(float(extents[0]), 2), cube_x=round(float(extents[0]), 2),
@@ -41,6 +62,50 @@ def analyse(filename: str, db: Session) -> StlCache | None:
return None return None
def _worker(filename: str) -> None:
"""Child process entry point — creates its own DB session."""
from app.db import SessionLocal
db = SessionLocal()
try:
analyse(filename, db)
finally:
db.close()
def analyse_in_background(filename: str) -> None:
"""Spawn an isolated child process for analysis; kill it after _TIMEOUT."""
if filename in _in_progress:
return
_in_progress.add(filename)
_failed.discard(filename)
try:
ctx = _mp.get_context("spawn")
p = ctx.Process(target=_worker, args=(filename,), daemon=True)
p.start()
p.join(timeout=_TIMEOUT)
if p.is_alive():
logger.warning("STL analysis timed out for %s — killing process", filename)
p.kill()
p.join()
_failed.add(filename)
elif p.exitcode != 0:
logger.warning("STL analysis process exited with code %s for %s", p.exitcode, filename)
_failed.add(filename)
except Exception:
logger.exception("STL background task error for %s", filename)
_failed.add(filename)
finally:
_in_progress.discard(filename)
def is_computing(filename: str) -> bool:
return filename in _in_progress
def is_failed(filename: str) -> bool:
return filename in _failed
def get_cache_map(filenames: list[str], db: Session) -> dict[str, StlCache]: def get_cache_map(filenames: list[str], db: Session) -> dict[str, StlCache]:
"""Bulk-fetch cached entries for a list of filenames.""" """Bulk-fetch cached entries for a list of filenames."""
rows = db.query(StlCache).filter(StlCache.filename.in_(filenames)).all() rows = db.query(StlCache).filter(StlCache.filename.in_(filenames)).all()

View File

@@ -60,7 +60,7 @@
<li><a href="/printers" class="nav-link {% if active == 'printers' %}active{% endif %}"><i class="bi bi-printer me-2"></i>Printers</a></li> <li><a href="/printers" class="nav-link {% if active == 'printers' %}active{% endif %}"><i class="bi bi-printer me-2"></i>Printers</a></li>
<li><a href="/jobs" class="nav-link {% if active == 'jobs' %}active{% endif %}"><i class="bi bi-list-task me-2"></i>Jobs</a></li> <li><a href="/jobs" class="nav-link {% if active == 'jobs' %}active{% endif %}"><i class="bi bi-list-task me-2"></i>Jobs</a></li>
<li><a href="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</a></li> <li><a href="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %}"><i class="bi bi-check2-square me-2"></i>To-Do</a></li> <li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %} d-flex align-items-center justify-content-between"><span><i class="bi bi-check2-square me-2"></i>To-Do</span><span id="todo-count-badge" class="badge rounded-pill bg-primary ms-1" style="display:none!important"></span></a></li>
<li><a href="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li> <li><a href="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
</ul> </ul>
</nav> </nav>
@@ -71,5 +71,14 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
fetch('/api/todos/count').then(r => r.json()).then(d => {
if (d.open > 0) {
const b = document.getElementById('todo-count-badge');
b.textContent = d.open;
b.style.removeProperty('display');
}
}).catch(() => {});
</script>
</body> </body>
</html> </html>

View File

@@ -23,7 +23,7 @@
<tbody> <tbody>
{% for b in brackets %} {% for b in brackets %}
{% set total = b.jobs | length %} {% set total = b.jobs | length %}
{% set done = b.jobs | selectattr('status.value', 'equalto', 'done') | list | length %} {% set done = b.jobs | selectattr('status.value', 'in', ['done', 'cancelled']) | list | length %}
{% set pct = (done / total * 100) | int if total else 0 %} {% set pct = (done / total * 100) | int if total else 0 %}
<tr> <tr>
<td><a href="/jobs?bracket_id={{ b.id }}" class="text-white text-decoration-none fw-medium">{{ b.name }}</a></td> <td><a href="/jobs?bracket_id={{ b.id }}" class="text-white text-decoration-none fw-medium">{{ b.name }}</a></td>

View File

@@ -103,7 +103,7 @@
<div class="card h-100"> <div class="card h-100">
<div class="card-header border-secondary d-flex align-items-center justify-content-between" style="border-color:#21262d"> <div class="card-header border-secondary d-flex align-items-center justify-content-between" style="border-color:#21262d">
<span class="text-secondary small text-uppercase fw-normal">File &amp; Geometry</span> <span class="text-secondary small text-uppercase fw-normal">File &amp; Geometry</span>
{% if job.file_name %} {% if job.file_name and not file_missing %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="/files/{{ job.file_name }}" class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size:.75rem" download> <a href="/files/{{ job.file_name }}" class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size:.75rem" download>
<i class="bi bi-download me-1"></i>Download <i class="bi bi-download me-1"></i>Download
@@ -122,21 +122,91 @@
{% if job.file_name %} {% if job.file_name %}
<div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div> <div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div>
{% endif %} {% endif %}
{% if stl %} {% if file_missing %}
<table class="table table-dark table-sm mb-0" style="font-size:.875rem"> <div class="alert alert-danger py-2 px-3 mb-0" style="font-size:.875rem">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
File <code>{{ job.file_name }}</code> not found on disk.
</div>
{% elif analysis_failed %}
<div class="alert alert-warning py-2 px-3 mb-2" style="font-size:.875rem">
<i class="bi bi-exclamation-triangle me-2"></i>
Analysis failed or timed out — enter values from your slicer below.
</div>
<form method="POST" action="/jobs/{{ job.id }}/geometry" class="d-flex flex-column gap-2" style="font-size:.8rem">
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">X mm</label>
<input type="number" step="0.1" name="cube_x" value="" required
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Y mm</label>
<input type="number" step="0.1" name="cube_y" value="" required
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Z mm</label>
<input type="number" step="0.1" name="cube_z" value="" required
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Vol cm³</label>
<input type="number" step="0.001" name="volume_ccm" value="" required
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div><button type="submit" class="btn btn-sm btn-outline-secondary py-0 px-2">Save</button></div>
</form>
{% elif computing %}
<div class="text-secondary small d-flex align-items-center gap-2" id="computing-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
Computing geometry…
</div>
<script>setTimeout(() => location.reload(), 3000);</script>
{% elif stl %}
<table class="table table-dark table-sm mb-2" style="font-size:.875rem">
<tbody> <tbody>
<tr><td class="text-secondary border-0">Bounding box</td> <tr><td class="text-secondary border-0">Bounding box</td>
<td class="border-0"> <td class="border-0">
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm {{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
</td></tr> </td></tr>
<tr><td class="text-secondary">Volume</td> <tr><td class="text-secondary">Volume</td>
<td>{{ stl.volume_ccm }} cm³</td></tr> <td>
{{ stl.volume_ccm }} cm³
{% if stl.manually_set %}<span class="badge bg-secondary ms-1" style="font-size:.65rem">manual</span>{% endif %}
</td></tr>
</tbody> </tbody>
</table> </table>
<details class="mt-1" style="font-size:.8rem">
<summary class="text-secondary" style="cursor:pointer">Override values</summary>
<form method="POST" action="/jobs/{{ job.id }}/geometry" class="mt-2 d-flex flex-column gap-2">
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">X mm</label>
<input type="number" step="0.1" name="cube_x" value="{{ stl.cube_x|round(2) }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Y mm</label>
<input type="number" step="0.1" name="cube_y" value="{{ stl.cube_y|round(2) }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Z mm</label>
<input type="number" step="0.1" name="cube_z" value="{{ stl.cube_z|round(2) }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Vol cm³</label>
<input type="number" step="0.001" name="volume_ccm" value="{{ stl.volume_ccm }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div>
<button type="submit" class="btn btn-sm btn-outline-secondary py-0 px-2">Save</button>
</div>
</form>
</details>
{% elif job.file_name and job.file_name.endswith('.gcode') %} {% elif job.file_name and job.file_name.endswith('.gcode') %}
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p> <p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
{% elif job.file_name %} {% elif job.file_name %}
<p class="text-secondary small mb-0">Not yet analysed.</p> <p class="text-secondary small mb-0">Analysis failed — check file format.</p>
{% else %} {% else %}
<p class="text-secondary small mb-0">No file attached.</p> <p class="text-secondary small mb-0">No file attached.</p>
{% endif %} {% endif %}

View File

@@ -50,6 +50,7 @@
<th class="fw-normal">Material</th> <th class="fw-normal">Material</th>
<th class="fw-normal">Status</th> <th class="fw-normal">Status</th>
<th class="fw-normal text-end">Created</th> <th class="fw-normal text-end">Created</th>
<th class="fw-normal"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -87,6 +88,13 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td> <td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td class="text-end" style="width:32px">
<form method="POST" action="/jobs/{{ j.id }}/duplicate" class="d-inline">
<button type="submit" class="btn btn-sm btn-link text-secondary p-0" title="Duplicate job">
<i class="bi bi-files"></i>
</button>
</form>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -16,6 +16,23 @@
{% endif %} {% endif %}
</div> </div>
{# ── Currently printing ── #}
{% if current_job %}
<a href="/jobs/{{ current_job.id }}" class="text-decoration-none">
<div class="card mb-4 border-primary">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-printer text-primary fs-4"></i>
<div>
<div class="text-secondary small text-uppercase">Currently printing</div>
<div class="text-white fw-semibold">{{ current_job.file_name or current_job.name or '—' }}</div>
{% if current_job.customer %}<div class="text-secondary small">{{ current_job.customer }}</div>{% endif %}
</div>
<i class="bi bi-chevron-right text-secondary ms-auto"></i>
</div>
</div>
</a>
{% endif %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
{# ── Info card ── #} {# ── Info card ── #}

View File

@@ -7,4 +7,6 @@ cryptography==44.0.3
jinja2==3.1.4 jinja2==3.1.4
python-multipart==0.0.20 python-multipart==0.0.20
trimesh==4.5.3 trimesh==4.5.3
networkx
lxml
httpx==0.28.1 httpx==0.28.1