diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a3ce72a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# The database (and everything else in the mounted volume) must never be +# baked into an image — it's live data, and it made every build context +# larger by the full size of everyone's grading history. +data/ +grades.db +.git/ +nginx/ +README.md +docker-compose.yml +.env* +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile index 6103ac0..296c4b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ FROM python:3.12-slim +# Logs reach `docker logs` the moment they're printed rather than whenever a +# buffer happens to flush — without this, a crash can eat the lines that +# explain it. +ENV PYTHONUNBUFFERED=1 + # Only optional deps (see README) — the app itself is stdlib only. RUN pip install --no-cache-dir anthropic pillow @@ -13,4 +18,14 @@ ENV CARD_GRADER_DB_PATH=/data/grades.db ENV PORT=8778 EXPOSE 8778 +# nobody:users — Unraid's appdata convention. Running as root inside the +# container was needless: the app needs exactly one writable path (/data), +# so give it only that. The host ./data dir must be owned 99:100 to match. +USER 99:100 + +# python (not curl — slim image doesn't ship it) probing the app's own root. +# BASE_PATH doesn't affect this: un-prefixed paths still route normally. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8778/', timeout=4).status == 200 else 1)"] + CMD ["python3", "app.py"] diff --git a/docker-compose.yml b/docker-compose.yml index a1f827f..e441e2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,16 @@ services: build: . container_name: card-grader restart: unless-stopped + # Proper PID-1 signal handling — a clean, immediate stop on + # `docker compose down` instead of the 10s SIGKILL timeout. + init: true + # Without a cap, an always-on container's json log grows unbounded on + # the array — this is a server that never reboots, so it would. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" # Bound to all interfaces (not 127.0.0.1) because Nginx Proxy Manager # runs in its own container on a separate macvlan IP (192.168.86.2), # not in this host's network namespace — it has to reach this over the diff --git a/static/app.js b/static/app.js index 08124f1..e74212e 100644 --- a/static/app.js +++ b/static/app.js @@ -333,9 +333,18 @@ function gradePillClass(grade) { return 'pill-critical'; } +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + function fmtWhen(iso) { if (!iso) return ''; - return String(iso).replace('T', ' ').slice(0, 16); + const s = String(iso); + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/); + if (!m) return s.replace('T', ' ').slice(0, 16); + const nowYear = String(new Date().getFullYear()); + const day = `${MONTHS[Number(m[2]) - 1]} ${Number(m[3])}`; + // Only spend width on the year when it isn't this year's. + return `${m[1] === nowYear ? day : `${day} ${m[1]}`} · ${m[4]}`; } async function loadHistory() { @@ -361,12 +370,14 @@ function renderHistory() {