Add Jinja2 web UI with Bootstrap 5 dark theme

- app/templates/: base layout with sidebar nav + 6 pages
  (dashboard, printers, jobs, filaments, todos, pricing)
- app/routers/ui.py: HTML routes querying the DB live
- API routes moved to /api/* prefix
- requirements: jinja2, python-multipart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 21:26:23 +02:00
parent f4e991e5dc
commit ee0b1b39e9
10 changed files with 473 additions and 9 deletions

65
app/templates/base.html Normal file
View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}POPS{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background: #0d1117; }
#sidebar {
width: 230px;
min-height: 100vh;
background: #010409;
border-right: 1px solid #21262d;
flex-shrink: 0;
}
#sidebar .brand { border-bottom: 1px solid #21262d; }
#sidebar .nav-link {
color: #8b949e;
border-radius: 6px;
padding: .4rem .75rem;
font-size: .9rem;
}
#sidebar .nav-link i { width: 18px; }
#sidebar .nav-link:hover { color: #e6edf3; background: #161b22; }
#sidebar .nav-link.active { color: #e6edf3; background: #21262d; font-weight: 500; }
#content { min-height: 100vh; }
.card { border-color: #21262d !important; background: #161b22 !important; }
.table-dark { --bs-table-bg: transparent; }
.table > :not(caption) > * > * { border-color: #21262d; }
</style>
</head>
<body>
<div class="d-flex">
<nav id="sidebar" class="p-3 d-flex flex-column gap-3">
<a href="/" class="brand text-decoration-none pb-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-printer-fill text-primary fs-5"></i>
<div>
<div class="text-white fw-semibold lh-sm">POPS</div>
<div class="text-secondary lh-sm" style="font-size:.68rem;letter-spacing:.03em">PRINT FARM MANAGER</div>
</div>
</div>
</a>
<ul class="nav flex-column gap-1">
<li><a href="/" class="nav-link {% if active == 'dashboard' %}active{% endif %}"><i class="bi bi-speedometer2 me-2"></i>Dashboard</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="/filaments" class="nav-link {% if active == 'filaments' %}active{% endif %}"><i class="bi bi-record-circle me-2"></i>Filaments</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="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
</ul>
</nav>
<main id="content" class="flex-grow-1 p-4">
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Dashboard — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">Dashboard</h5>
{# ── Stats row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card p-3">
<div class="text-secondary small mb-1">Printers</div>
<div class="fs-2 fw-bold text-white">{{ total_printers }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card p-3">
<div class="text-secondary small mb-1">Printing Now</div>
<div class="fs-2 fw-bold text-primary">{{ printing_count }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card p-3">
<div class="text-secondary small mb-1">Active Jobs</div>
<div class="fs-2 fw-bold text-warning">{{ active_jobs }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card p-3">
<div class="text-secondary small mb-1">Filament Spools</div>
<div class="fs-2 fw-bold text-white">{{ total_filaments }}</div>
</div>
</div>
</div>
<div class="row g-3">
{# ── Printer overview ── #}
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Printers</div>
{% if printers %}
<table class="table table-dark table-hover mb-0 align-middle">
<tbody>
{% for p in printers %}
<tr>
<td>{{ p.name }}</td>
<td class="text-secondary small">{{ p.model or '—' }}</td>
<td class="text-end">
{% set s = p.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% elif s == 'idle' %}<span class="badge bg-success">idle</span>
{% elif s == 'error' %}<span class="badge bg-danger">error</span>
{% else %}<span class="badge bg-secondary">offline</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No printers configured yet.</div>
{% endif %}
</div>
</div>
{# ── Recent jobs ── #}
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Recent Jobs</div>
{% if recent_jobs %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Name</th>
<th class="fw-normal">Printer</th>
<th class="fw-normal">Status</th>
<th class="fw-normal text-end">Date</th>
</tr>
</thead>
<tbody>
{% for j in recent_jobs %}
<tr>
<td>{{ j.name }}</td>
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
<td>
{% set s = j.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">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-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% endif %}
</td>
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No jobs yet.</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Filaments — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">Filament Inventory</h5>
<div class="card">
{% if filaments %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Brand</th>
<th class="fw-normal">Material</th>
<th class="fw-normal">Color</th>
<th class="fw-normal">Remaining</th>
<th class="fw-normal text-end">Price / kg</th>
</tr>
</thead>
<tbody>
{% for f in filaments %}
{% set pct = (f.weight_remaining_g / f.weight_total_g * 100) | int if f.weight_total_g else 0 %}
<tr>
<td class="fw-medium text-white">{{ f.brand or '—' }}</td>
<td><span class="badge bg-secondary">{{ f.material.value }}</span></td>
<td>
<div class="d-flex align-items-center gap-2">
{% if f.color_hex %}
<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:{{ f.color_hex }};border:1px solid #444"></span>
{% endif %}
{{ f.color or '—' }}
</div>
</td>
<td style="min-width:160px">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height:6px">
<div class="progress-bar {% if pct < 20 %}bg-danger{% elif pct < 50 %}bg-warning{% else %}bg-success{% endif %}"
style="width:{{ pct }}%"></div>
</div>
<span class="text-secondary small" style="min-width:70px">{{ f.weight_remaining_g | int }}g / {{ f.weight_total_g | int }}g</span>
</div>
</td>
<td class="text-secondary small text-end">
{% if f.price_per_kg_eur %}€ {{ "%.2f"|format(f.price_per_kg_eur) }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No filaments in inventory yet.</div>
{% endif %}
</div>
{% endblock %}

52
app/templates/jobs.html Normal file
View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Jobs — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">Print Jobs</h5>
<div class="card">
{% if jobs %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Name</th>
<th class="fw-normal">Printer</th>
<th class="fw-normal">Filament</th>
<th class="fw-normal">Status</th>
<th class="fw-normal">Duration</th>
<th class="fw-normal text-end">Cost</th>
<th class="fw-normal text-end">Created</th>
</tr>
</thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="fw-medium text-white">{{ j.name }}</td>
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
<td class="text-secondary small">
{% if j.filament %}{{ j.filament.material.value }} {{ j.filament.color or '' }}{% else %}—{% endif %}
</td>
<td>
{% set s = j.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">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-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% endif %}
</td>
<td class="text-secondary small">
{% if j.duration_minutes %}{{ j.duration_minutes }} min{% else %}—{% endif %}
</td>
<td class="text-secondary small text-end">
{% if j.cost_eur %}€ {{ "%.2f"|format(j.cost_eur) }}{% else %}—{% endif %}
</td>
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No jobs yet.</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Pricing — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">Pricing Configuration</h5>
{% if configs %}
<div class="row g-3">
{% for c in configs %}
<div class="col-md-6 col-xl-4">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<span class="fw-semibold text-white">{{ c.name }}</span>
<span class="text-secondary small">from {{ c.valid_from.strftime('%d.%m.%Y') }}</span>
</div>
<table class="table table-dark table-sm mb-0" style="font-size:.85rem">
<tbody>
<tr><td class="text-secondary border-0">Electricity</td><td class="text-end border-0">€ {{ "%.4f"|format(c.electricity_kwh_eur) }} / kWh</td></tr>
<tr><td class="text-secondary">Printer draw</td><td class="text-end">{{ c.printer_watt }} W</td></tr>
<tr><td class="text-secondary">Hourly rate</td><td class="text-end">€ {{ "%.2f"|format(c.hourly_rate_eur) }} / h</td></tr>
<tr><td class="text-secondary">Markup</td><td class="text-end">{{ c.markup_percent }} %</td></tr>
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card"><div class="card-body text-secondary">No pricing config yet.</div></div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Printers — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">Printers</h5>
<div class="card">
{% if printers %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Name</th>
<th class="fw-normal">Model</th>
<th class="fw-normal">Location</th>
<th class="fw-normal">Status</th>
<th class="fw-normal">Added</th>
</tr>
</thead>
<tbody>
{% for p in printers %}
<tr>
<td class="fw-medium text-white">{{ p.name }}</td>
<td class="text-secondary">{{ p.model or '—' }}</td>
<td class="text-secondary">{{ p.location or '—' }}</td>
<td>
{% set s = p.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% elif s == 'idle' %}<span class="badge bg-success">idle</span>
{% elif s == 'error' %}<span class="badge bg-danger">error</span>
{% else %}<span class="badge bg-secondary">offline</span>
{% endif %}
</td>
<td class="text-secondary small">{{ p.created_at.strftime('%d.%m.%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No printers configured yet.</div>
{% endif %}
</div>
{% endblock %}

40
app/templates/todos.html Normal file
View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}To-Do — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">To-Do</h5>
<div class="card">
{% if todos %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Task</th>
<th class="fw-normal">Printer</th>
<th class="fw-normal text-center">Done</th>
<th class="fw-normal text-end">Created</th>
</tr>
</thead>
<tbody>
{% for t in todos %}
<tr class="{% if t.done %}opacity-50{% endif %}">
<td class="{% if t.done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
{{ t.title }}
</td>
<td class="text-secondary small">{{ t.printer.name if t.printer else '—' }}</td>
<td class="text-center">
{% if t.done %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-circle text-secondary"></i>
{% endif %}
</td>
<td class="text-secondary small text-end">{{ t.created_at.strftime('%d.%m.%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No to-do items yet.</div>
{% endif %}
</div>
{% endblock %}