news-digest: per-item × button + cross-device hidden tray

Adds a small × on each item that hides it from the page. State is
server-side at /output/hidden.json so the same hidden set follows
the user across devices (home, ipad, laptop, work). A "Hidden (N)"
tray at the bottom shows what's hidden on the current page with a
restore button per row; older hidden ids that aren't on this page
sit silently and continue to filter future editions that include
the same article.

Architecture change: news-digest-web swaps from nginx:alpine to a
FastAPI app on uvicorn, built from the same Dockerfile as the
worker. Same image, different command (`uvicorn web:app` overrides
the worker's cron entrypoint via compose). Drops one image dependency,
adds /api/{hidden,hide,restore}.

Item ids are stable 12-char sha1 prefixes (`reddit:<post_id>` /
`miniflux:<entry_id>`) computed in digest.py at render time and
emitted as `data-id` on each .item. The frontend reads /api/hidden
once on load, applies `is-hidden` to matching items, and POSTs
hide/restore on user interaction (optimistic, with rollback on
network error).

Storage: single JSON array at /output/hidden.json, atomic writes
via tempfile + rename, threading.Lock around the read-modify-write
inside the single uvicorn worker. No auth — the digest itself is
unauthenticated on LAN; same trust boundary applies.

Playbook also drops the DOCKER_BUILDKIT=0 fallback now that
ana-docker is on docker-ce 29, and adds three verify steps
(/api/hidden returns a JSON array, app.js is reachable, full
hide/restore round-trip with a synthetic id).
This commit is contained in:
vh
2026-04-26 15:05:25 -07:00
parent c1427adc0f
commit f692b7ec7a
9 changed files with 596 additions and 45 deletions
+57 -16
View File
@@ -46,6 +46,12 @@ steps:
dest: "{{ compose_dir }}/digest.py"
mode: "0644"
- name: Upload web.py (FastAPI for the web container)
upload:
src: stacks/news-digest/web.py
dest: "{{ compose_dir }}/web.py"
mode: "0644"
- name: Upload entrypoint.sh
upload:
src: stacks/news-digest/entrypoint.sh
@@ -86,6 +92,12 @@ steps:
dest: "{{ compose_dir }}/templates/favicon.svg"
mode: "0644"
- name: Upload templates/app.js (× button + hidden tray client)
upload:
src: stacks/news-digest/templates/app.js
dest: "{{ compose_dir }}/templates/app.js"
mode: "0644"
- name: Seed .env from template (only if absent)
upload:
src: stacks/news-digest/.env.example
@@ -100,29 +112,29 @@ steps:
# /output, so the worker writes a copy of style.css into /output too.
- name: docker compose build (~2-3 min first time)
# DOCKER_BUILDKIT=0 forces the legacy build path. ana-docker is on
# docker 20.10 (Debian package) which doesn't carry the buildx
# driver versions our newer client expects → "client version 1.52
# is too new" without this fallback. Strip the noisy per-step
# download lines for log readability.
# ana-docker is now on docker-ce 29 (post 2026-04-24 fleet upgrade)
# so BuildKit works natively — the old DOCKER_BUILDKIT=0 fallback is
# no longer needed. Strip BuildKit's progress UI lines for cleaner
# elway output.
shell: |
set -o pipefail
cd {{ compose_dir }} && DOCKER_BUILDKIT=0 docker compose build 2>&1 \
| grep -vE '^Step [0-9]+/[0-9]+ : (RUN|COPY)|^Removing intermediate|^ ---> |^ ---> Running|Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
cd {{ compose_dir }} && docker compose build 2>&1 \
| grep -vE '^#[0-9]+ |^ => |^=> |Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
- name: Pre-stage style.css + favicon.svg into /output for nginx
# The worker writes HTML that references "style.css" and
# "favicon.svg" (relative). Neither is generated dynamically;
# nginx serves whichever copy lands at /usr/share/nginx/html/.
# Copy both from the templates dir at deploy-time.
- name: Pre-stage style.css + favicon.svg + app.js into /output
# The worker writes HTML that references "style.css", "favicon.svg",
# and "app.js" relative. None are generated dynamically; the web
# container serves whichever copy lands in /output. Copy all three
# from the templates dir at deploy-time.
shell: |
cp -f {{ compose_dir }}/templates/style.css {{ output_dir }}/style.css
cp -f {{ compose_dir }}/templates/favicon.svg {{ output_dir }}/favicon.svg
cp -f {{ compose_dir }}/templates/app.js {{ output_dir }}/app.js
- name: docker compose up -d
shell: cd {{ compose_dir }} && docker compose up -d
- name: docker compose up -d (rebuild + recreate so the new web image lands)
shell: cd {{ compose_dir }} && docker compose up -d --build
- name: Wait for nginx to serve /
- name: Wait for the web container to serve /
shell: |
for i in $(seq 1 30); do
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/ && exit 0
@@ -132,7 +144,7 @@ steps:
changed_when: "false"
verify:
- name: nginx returns 200 on /
- name: web returns 200 on /
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/
changed_when: "false"
@@ -143,3 +155,32 @@ verify:
- name: news-digest-web on traefik-net (homepage discovery)
shell: docker inspect news-digest-web --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net
changed_when: "false"
- name: /api/hidden returns a JSON array
shell: |
curl -sf --max-time 5 http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; d = json.load(sys.stdin); assert isinstance(d, list)"
changed_when: "false"
- name: app.js is reachable
shell: curl -sf -o /dev/null --max-time 5 http://localhost:{{ host_port }}/app.js
changed_when: "false"
- name: hide → /api/hidden contains it → restore → /api/hidden no longer contains it
# End-to-end smoke of the hide/restore round-trip without touching
# any real item id. Uses a synthetic id so we don't pollute state if
# the deploy runs against a live install.
shell: |
set -e
tid="smoke-$(date +%s)-$$"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"id\":\"${tid}\"}" \
http://localhost:{{ host_port }}/api/hide >/dev/null
curl -sf http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; assert '${tid}' in json.load(sys.stdin)"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"id\":\"${tid}\"}" \
http://localhost:{{ host_port }}/api/restore >/dev/null
curl -sf http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; assert '${tid}' not in json.load(sys.stdin)"
changed_when: "false"