- 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>
443 lines
20 KiB
HTML
443 lines
20 KiB
HTML
{% 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 & 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 & 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 %}
|