diff --git a/app/main.py b/app/main.py
index 9fc1ce2..b02bce4 100644
--- a/app/main.py
+++ b/app/main.py
@@ -5,6 +5,7 @@ from fastapi import FastAPI
from app.db import check_db, run_migrations
from app.routers import filaments, pricing, print_jobs, printers, todos
+from app.routers import ui
logger = logging.getLogger("pops")
@@ -22,16 +23,15 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="POPS", lifespan=lifespan)
-app.include_router(printers.router)
-app.include_router(filaments.router)
-app.include_router(print_jobs.router)
-app.include_router(todos.router)
-app.include_router(pricing.router)
+# JSON API
+app.include_router(printers.router, prefix="/api")
+app.include_router(filaments.router, prefix="/api")
+app.include_router(print_jobs.router, prefix="/api")
+app.include_router(todos.router, prefix="/api")
+app.include_router(pricing.router, prefix="/api")
-
-@app.get("/")
-def root():
- return {"message": "Hello from POPS"}
+# Web UI (must come after API to avoid route shadowing)
+app.include_router(ui.router)
@app.get("/health")
diff --git a/app/routers/ui.py b/app/routers/ui.py
new file mode 100644
index 0000000..0d56313
--- /dev/null
+++ b/app/routers/ui.py
@@ -0,0 +1,76 @@
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from app.db import get_db
+from app.models.filament import Filament
+from app.models.print_job import JobStatus, PrintJob
+from app.models.printer import Printer, PrinterStatus
+from app.models.pricing import PricingConfig
+from app.models.todo import Todo
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app/templates")
+
+
+@router.get("/", response_class=HTMLResponse)
+def dashboard(request: Request, db: Session = Depends(get_db)):
+ printers = db.query(Printer).order_by(Printer.name).all()
+ return templates.TemplateResponse("dashboard.html", {
+ "request": request,
+ "active": "dashboard",
+ "printers": printers,
+ "total_printers": len(printers),
+ "printing_count": sum(1 for p in printers if p.status == PrinterStatus.printing),
+ "active_jobs": db.query(PrintJob).filter(
+ PrintJob.status.in_([JobStatus.queued, JobStatus.printing])
+ ).count(),
+ "total_filaments": db.query(Filament).count(),
+ "recent_jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).limit(5).all(),
+ })
+
+
+@router.get("/printers", response_class=HTMLResponse)
+def printers_page(request: Request, db: Session = Depends(get_db)):
+ return templates.TemplateResponse("printers.html", {
+ "request": request,
+ "active": "printers",
+ "printers": db.query(Printer).order_by(Printer.name).all(),
+ })
+
+
+@router.get("/jobs", response_class=HTMLResponse)
+def jobs_page(request: Request, db: Session = Depends(get_db)):
+ return templates.TemplateResponse("jobs.html", {
+ "request": request,
+ "active": "jobs",
+ "jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).all(),
+ })
+
+
+@router.get("/filaments", response_class=HTMLResponse)
+def filaments_page(request: Request, db: Session = Depends(get_db)):
+ return templates.TemplateResponse("filaments.html", {
+ "request": request,
+ "active": "filaments",
+ "filaments": db.query(Filament).order_by(Filament.brand, Filament.material).all(),
+ })
+
+
+@router.get("/todos", response_class=HTMLResponse)
+def todos_page(request: Request, db: Session = Depends(get_db)):
+ return templates.TemplateResponse("todos.html", {
+ "request": request,
+ "active": "todos",
+ "todos": db.query(Todo).order_by(Todo.done, Todo.created_at.desc()).all(),
+ })
+
+
+@router.get("/pricing", response_class=HTMLResponse)
+def pricing_page(request: Request, db: Session = Depends(get_db)):
+ return templates.TemplateResponse("pricing.html", {
+ "request": request,
+ "active": "pricing",
+ "configs": db.query(PricingConfig).order_by(PricingConfig.valid_from.desc()).all(),
+ })
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..a259910
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+ {% block title %}POPS{% endblock %}
+
+
+
+
+
+
+
+
+
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
new file mode 100644
index 0000000..7f41896
--- /dev/null
+++ b/app/templates/dashboard.html
@@ -0,0 +1,106 @@
+{% extends "base.html" %}
+{% block title %}Dashboard — POPS{% endblock %}
+
+{% block content %}
+Dashboard
+
+{# ── Stats row ── #}
+
+
+
+
Printers
+
{{ total_printers }}
+
+
+
+
+
Printing Now
+
{{ printing_count }}
+
+
+
+
+
Active Jobs
+
{{ active_jobs }}
+
+
+
+
+
Filament Spools
+
{{ total_filaments }}
+
+
+
+
+
+
+ {# ── Printer overview ── #}
+
+
+
+ {% if printers %}
+
+
+ {% for p in printers %}
+
+ | {{ p.name }} |
+ {{ p.model or '—' }} |
+
+ {% set s = p.status.value %}
+ {% if s == 'printing' %}printing
+ {% elif s == 'idle' %}idle
+ {% elif s == 'error' %}error
+ {% else %}offline
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No printers configured yet.
+ {% endif %}
+
+
+
+ {# ── Recent jobs ── #}
+
+
+
+ {% if recent_jobs %}
+
+
+
+ | Name |
+ Printer |
+ Status |
+ Date |
+
+
+
+ {% for j in recent_jobs %}
+
+ | {{ j.name }} |
+ {{ j.printer.name if j.printer else '—' }} |
+
+ {% set s = j.status.value %}
+ {% if s == 'printing' %}printing
+ {% elif s == 'done' %}done
+ {% elif s == 'failed' %}failed
+ {% elif s == 'cancelled' %}cancelled
+ {% else %}queued
+ {% endif %}
+ |
+ {{ j.created_at.strftime('%d.%m %H:%M') }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No jobs yet.
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/app/templates/filaments.html b/app/templates/filaments.html
new file mode 100644
index 0000000..7fb8f32
--- /dev/null
+++ b/app/templates/filaments.html
@@ -0,0 +1,52 @@
+{% extends "base.html" %}
+{% block title %}Filaments — POPS{% endblock %}
+
+{% block content %}
+Filament Inventory
+
+ {% if filaments %}
+
+
+
+ | Brand |
+ Material |
+ Color |
+ Remaining |
+ Price / kg |
+
+
+
+ {% for f in filaments %}
+ {% set pct = (f.weight_remaining_g / f.weight_total_g * 100) | int if f.weight_total_g else 0 %}
+
+ | {{ f.brand or '—' }} |
+ {{ f.material.value }} |
+
+
+ {% if f.color_hex %}
+
+ {% endif %}
+ {{ f.color or '—' }}
+
+ |
+
+
+
+ {{ f.weight_remaining_g | int }}g / {{ f.weight_total_g | int }}g
+
+ |
+
+ {% if f.price_per_kg_eur %}€ {{ "%.2f"|format(f.price_per_kg_eur) }}{% else %}—{% endif %}
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No filaments in inventory yet.
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/jobs.html b/app/templates/jobs.html
new file mode 100644
index 0000000..3237eb6
--- /dev/null
+++ b/app/templates/jobs.html
@@ -0,0 +1,52 @@
+{% extends "base.html" %}
+{% block title %}Jobs — POPS{% endblock %}
+
+{% block content %}
+Print Jobs
+
+ {% if jobs %}
+
+
+
+ | Name |
+ Printer |
+ Filament |
+ Status |
+ Duration |
+ Cost |
+ Created |
+
+
+
+ {% for j in jobs %}
+
+ | {{ j.name }} |
+ {{ j.printer.name if j.printer else '—' }} |
+
+ {% if j.filament %}{{ j.filament.material.value }} {{ j.filament.color or '' }}{% else %}—{% endif %}
+ |
+
+ {% set s = j.status.value %}
+ {% if s == 'printing' %}printing
+ {% elif s == 'done' %}done
+ {% elif s == 'failed' %}failed
+ {% elif s == 'cancelled' %}cancelled
+ {% else %}queued
+ {% endif %}
+ |
+
+ {% if j.duration_minutes %}{{ j.duration_minutes }} min{% else %}—{% endif %}
+ |
+
+ {% if j.cost_eur %}€ {{ "%.2f"|format(j.cost_eur) }}{% else %}—{% endif %}
+ |
+ {{ j.created_at.strftime('%d.%m %H:%M') }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No jobs yet.
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/pricing.html b/app/templates/pricing.html
new file mode 100644
index 0000000..f5bf74f
--- /dev/null
+++ b/app/templates/pricing.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+{% block title %}Pricing — POPS{% endblock %}
+
+{% block content %}
+Pricing Configuration
+{% if configs %}
+
+{% for c in configs %}
+
+
+
+ {{ c.name }}
+ from {{ c.valid_from.strftime('%d.%m.%Y') }}
+
+
+
+ | Electricity | € {{ "%.4f"|format(c.electricity_kwh_eur) }} / kWh |
+ | Printer draw | {{ c.printer_watt }} W |
+ | Hourly rate | € {{ "%.2f"|format(c.hourly_rate_eur) }} / h |
+ | Markup | {{ c.markup_percent }} % |
+
+
+
+
+{% endfor %}
+
+{% else %}
+
+{% endif %}
+{% endblock %}
diff --git a/app/templates/printers.html b/app/templates/printers.html
new file mode 100644
index 0000000..79da7b6
--- /dev/null
+++ b/app/templates/printers.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+{% block title %}Printers — POPS{% endblock %}
+
+{% block content %}
+Printers
+
+ {% if printers %}
+
+
+
+ | Name |
+ Model |
+ Location |
+ Status |
+ Added |
+
+
+
+ {% for p in printers %}
+
+ | {{ p.name }} |
+ {{ p.model or '—' }} |
+ {{ p.location or '—' }} |
+
+ {% set s = p.status.value %}
+ {% if s == 'printing' %}printing
+ {% elif s == 'idle' %}idle
+ {% elif s == 'error' %}error
+ {% else %}offline
+ {% endif %}
+ |
+ {{ p.created_at.strftime('%d.%m.%Y') }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No printers configured yet.
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/todos.html b/app/templates/todos.html
new file mode 100644
index 0000000..3b1d62e
--- /dev/null
+++ b/app/templates/todos.html
@@ -0,0 +1,40 @@
+{% extends "base.html" %}
+{% block title %}To-Do — POPS{% endblock %}
+
+{% block content %}
+To-Do
+
+ {% if todos %}
+
+
+
+ | Task |
+ Printer |
+ Done |
+ Created |
+
+
+
+ {% for t in todos %}
+
+ |
+ {{ t.title }}
+ |
+ {{ t.printer.name if t.printer else '—' }} |
+
+ {% if t.done %}
+
+ {% else %}
+
+ {% endif %}
+ |
+ {{ t.created_at.strftime('%d.%m.%Y') }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No to-do items yet.
+ {% endif %}
+
+{% endblock %}
diff --git a/requirements.txt b/requirements.txt
index 310ac42..bf30a37 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,5 @@ sqlalchemy==2.0.36
alembic==1.14.0
pymysql==1.1.1
cryptography==44.0.3
+jinja2==3.1.4
+python-multipart==0.0.20