Files
POPS/app/templates/job_detail.html
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

443 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Job {{ job.job_uuid[:8] if job.job_uuid else job.id }} — POPS{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ '/brackets/' ~ job.bracket.id if job.bracket else '/jobs' }}" class="text-secondary text-decoration-none">
<i class="bi bi-arrow-left"></i>
</a>
<div class="flex-grow-1 me-3">
<div class="d-flex align-items-center gap-2">
<h5 class="text-white mb-0">{{ job.file_name or job.name }}</h5>
{% set s = job.status.value %}
{% if job.priority %}<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>Priority</span>{% endif %}
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
{% elif s == 'done' %}<span class="badge bg-success">done</span>
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
{% endif %}
</div>
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
</div>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editJobModal">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</div>
{# ── Job actions ── #}
{% set s = job.status.value %}
{% if s not in ('done', 'cancelled') %}
<div class="d-flex gap-2 mb-4">
{% if s == 'queued' %}
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#startModal">
<i class="bi bi-play-fill me-1"></i>Start Job
</button>
{% elif s == 'printing' %}
<form method="POST" action="/jobs/{{ job.id }}/finish">
<button type="submit" class="btn btn-success btn-sm">
<i class="bi bi-check-lg me-1"></i>Finish
</button>
</form>
<form method="POST" action="/jobs/{{ job.id }}/stop">
<button type="submit" class="btn btn-outline-warning btn-sm">
<i class="bi bi-stop-fill me-1"></i>Stop
</button>
</form>
{% endif %}
<form method="POST" action="/jobs/{{ job.id }}/cancel">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-x-lg me-1"></i>Cancel Job
</button>
</form>
</div>
{% endif %}
<div class="row g-3 mb-4">
{# ── Job info ── #}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Job</div>
<div class="card-body">
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0" style="width:40%">Customer</td>
<td class="border-0">{{ job.customer or '—' }}</td></tr>
<tr><td class="text-secondary">Material</td>
<td>{{ job.job_material or '—' }}{% if job.job_color %} · {{ job.job_color }}{% endif %}</td></tr>
<tr><td class="text-secondary">Printer</td>
<td>{{ job.printer.name if job.printer else '—' }}</td></tr>
<tr><td class="text-secondary">Bracket</td>
<td>{% if job.bracket %}<a href="/jobs?bracket_id={{ job.bracket.id }}" class="text-white">{{ job.bracket.name }}</a>{% else %}—{% endif %}</td></tr>
<tr><td class="text-secondary">Created</td>
<td>{{ job.created_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% if job.started_at %}
<tr><td class="text-secondary">Started</td>
<td>{{ job.started_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% endif %}
{% if job.finished_at %}
<tr><td class="text-secondary">Finished</td>
<td>{{ job.finished_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% endif %}
{% if job.duration_minutes %}
<tr><td class="text-secondary">Duration</td>
<td>{{ job.duration_minutes }} min</td></tr>
{% endif %}
{% if job.cost_eur %}
<tr><td class="text-secondary">Cost</td>
<td>€ {{ "%.2f"|format(job.cost_eur) }}</td></tr>
{% endif %}
{% if job.notes %}
<tr><td class="text-secondary">Notes</td>
<td>{{ job.notes }}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{# ── STL geometry ── #}
<div class="col-md-6">
<div class="card h-100">
<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>
{% if job.file_name and not file_missing %}
<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>
<i class="bi bi-download me-1"></i>Download
</a>
{% if not job.file_name.endswith('.gcode') %}
<form method="POST" action="/jobs/{{ job.id }}/analyse">
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2" style="font-size:.75rem">
<i class="bi bi-cpu me-1"></i>Compute now
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
<div class="card-body">
{% if job.file_name %}
<div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div>
{% endif %}
{% if file_missing %}
<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 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>
<tr><td class="text-secondary border-0">Bounding box</td>
<td class="border-0">
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
</td></tr>
<tr><td class="text-secondary">Volume</td>
<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>
</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') %}
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
{% elif job.file_name %}
<p class="text-secondary small mb-0">Analysis failed — check file format.</p>
{% else %}
<p class="text-secondary small mb-0">No file attached.</p>
{% endif %}
</div>
</div>
</div>
</div>
{# ── Estimate ── #}
{% if estimation %}
<div class="card mb-4">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">
Estimate
<span class="text-secondary fw-normal ms-2" style="font-size:.7rem">
{{ (estimation.material_factor * 100) | round | int }}% material factor
· {{ estimation.print_speed | int }} g/h
· {{ estimation.price_per_kg }} €/kg
· {{ estimation.printer_cost_per_hour }} €/h printer
· {{ estimation.vat_rate }}% VAT
</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="text-secondary small mb-2">Material &amp; Time</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Volume used</td>
<td class="border-0">{{ estimation.material_ccm }} cm³</td></tr>
<tr><td class="text-secondary">Weight</td>
<td>{{ estimation.weight_g }} g</td></tr>
<tr><td class="text-secondary">Print time</td>
<td>
{% set h = (estimation.print_minutes // 60) %}
{% set m = (estimation.print_minutes % 60) %}
{% if h %}{{ h }}h {% endif %}{{ m }}min
<span class="text-secondary" style="font-size:.75rem">(est.)</span>
</td></tr>
</tbody>
</table>
</div>
<div class="col-md-4">
<div class="text-secondary small mb-2">Production cost</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Filament</td>
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
<tr><td class="text-secondary">+ 20 % misprint</td>
<td>{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €</td></tr>
<tr><td class="text-secondary">Printer ({{ estimation.printer_cost_per_hour }} €/h)</td>
<td>{{ "%.2f"|format(estimation.printer_cost) }} €</td></tr>
</tbody>
</table>
</div>
<div class="col-md-4">
<div class="text-secondary small mb-2">Suggested price</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Net (ex. VAT)</td>
<td class="border-0 fw-medium text-white">{{ "%.2f"|format(estimation.net_price) }} €</td></tr>
<tr><td class="text-secondary">Gross ({{ estimation.vat_rate }}% VAT)</td>
<td class="fw-medium text-white">{{ "%.2f"|format(estimation.gross_price) }} €</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{# ── Log ── #}
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="text-white mb-0">Log</h6>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addLogModal">
<i class="bi bi-plus-lg me-1"></i>Add entry
</button>
</div>
<div class="card">
{% if job.logs %}
<table class="table table-dark table-hover mb-0 align-middle">
<tbody>
{% for entry in job.logs %}
<tr>
<td class="text-secondary small" style="width:140px;white-space:nowrap">
{{ entry.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No log entries.</div>
{% endif %}
</div>
{# ── Edit Job Modal ── #}
<div class="modal fade" id="editJobModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/jobs/{{ job.id }}/edit">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Edit Job</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Customer</label>
<input type="text" name="customer" value="{{ job.customer or '' }}"
class="form-control bg-dark border-secondary text-white">
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Status</label>
<select name="status" class="form-select bg-dark border-secondary text-white">
{% for s in all_statuses %}
<option value="{{ s }}" {% if job.status.value == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Material</label>
<select name="job_material" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for m in materials %}
<option value="{{ m }}" {% if job.job_material == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Color</label>
<select name="job_color" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for c in colors %}
<option value="{{ c }}" {% if job.job_color == c %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Printer</label>
<select name="printer_id" class="form-select bg-dark border-secondary text-white">
<option value="">— unassigned —</option>
{% for p in printers %}
<option value="{{ p.id }}" {% if job.printer_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Bracket</label>
<select name="bracket_id" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for b in brackets %}
<option value="{{ b.id }}" {% if job.bracket_id == b.id %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="priority" id="editPriorityCheck"
value="1" {% if job.priority %}checked{% endif %}>
<label class="form-check-label text-secondary small" for="editPriorityCheck">
<i class="bi bi-exclamation-circle text-warning me-1"></i>Priority
</label>
</div>
<div>
<label class="form-label text-secondary small">Notes</label>
<textarea name="notes" class="form-control bg-dark border-secondary text-white"
rows="2">{{ job.notes or '' }}</textarea>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
</div>
</div>
</div>
{# ── Add log entry modal ── #}
<div class="modal fade" id="addLogModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/jobs/{{ job.id }}/log">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Add Log Entry</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<textarea name="message" class="form-control bg-dark border-secondary text-white"
rows="3" placeholder="What happened?" required autofocus></textarea>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</div>
</form>
</div>
</div>
</div>
{# ── Start Job Modal ── #}
{% if job.status.value == 'queued' %}
<div class="modal fade" id="startModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/jobs/{{ job.id }}/start">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Start Job on Printer</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{% if job.file_name %}
<div class="mb-3">
<a href="/files/{{ job.file_name }}" class="btn btn-outline-primary btn-sm w-100" download>
<i class="bi bi-download me-1"></i>Download {{ job.file_name }}
</a>
</div>
{% endif %}
{% if available_printers %}
<label class="form-label text-secondary small">Select printer <span class="text-danger">*</span></label>
<select name="printer_id" class="form-select bg-dark border-secondary text-white" required>
<option value="">— select —</option>
{% for p in available_printers %}
<option value="{{ p.id }}">{{ p.name }}{% if p.location %} · {{ p.location }}{% endif %} ({{ p.status.value }})</option>
{% endfor %}
</select>
{% else %}
<p class="text-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>All printers are currently busy.
</p>
{% endif %}
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success btn-sm" {% if not available_printers %}disabled{% endif %}>
<i class="bi bi-play-fill me-1"></i>Start
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}