Files
Announcements/docker-compose.yaml
MediaandClaude Opus 5 423056e885 Implement Phase 6A visual management dashboard
Build the desktop management surface at / while leaving the verified
playback engine and display renderer untouched. Opening the dashboard goes
straight to the playlist that is actually active, with slide imagery
dominating rather than an admin table.

Playlist selection and activation are deliberately separate. Choosing a
playlist only changes what is being edited; an inactive playlist offers an
explicit Activate Playlist action. Activation remains the server's
decision: when playback queues the request to the current slide boundary
the header shows STARTING SOON and keeps the old playlist marked active
until authoritative state promotes it, so the UI never fakes activation
timing.

Entries render as large tiles in a responsive grid where position is the
sequence, so no ordinal numbers are stamped on slides. Names drop file
extensions and are uppercased in Roboto for presentation only; stored
display names keep their case. Status rings are green, gray, amber, or red
and are always accompanied by the same status as text, so nothing depends
on colour alone. Clicking a tile toggles that playlist entry only, never
the library asset or its use in other playlists. Tiles reorder by drag
through dnd-kit, persisting through the existing playlist API and snapping
back to the authoritative order if the server rejects the change. React
identity is always the stable PlaylistEntry UUID, never an index.

Uploads accept PNG, JPEG, and WebP from the picker or a drop anywhere on
the page, run sequentially so appended order matches selection order, and
report per-file state without blocking the dashboard. Content duplicates
and assets already in the playlist are surfaced rather than duplicated.
The Library panel browses and reuses existing assets, and permanent assets
are never shown as stale.

The live preview reuses the display's own presentation resolver and scene
stylesheet, which are extracted to a shared module so the miniature and
the signage output cannot drift apart. It takes on none of the kiosk's
authority: no worker, no offline rotation, no heartbeat, no identity.

Management is desktop-only below 768px, a restriction /display never
inherits.

Compose now attaches only the web service to the external tabernet network
as announcements-web, keeping the API and object storage private.

Verified with 200/200 backend, 20 client unit, and 44 Playwright tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:33:04 -05:00

125 lines
3.8 KiB
YAML

name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
services:
web:
build:
context: .
dockerfile: client/Dockerfile
restart: unless-stopped
init: true
ports:
- "${WEB_PORT:-5175}:80"
depends_on:
api:
condition: service_healthy
networks:
app:
# Only the web service is reachable from the shared reverse-proxy
# network. Caddy resolves it as announcements-web:80; the API and object
# storage stay private on the Announcements networks.
tabernet:
aliases:
- announcements-web
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
api:
build:
context: .
dockerfile: server/Dockerfile
restart: unless-stopped
init: true
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: "7532"
DATABASE_PATH: /data/db/announcements.sqlite
APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
TRUST_PROXY: "${TRUST_PROXY:-true}"
S3_ENDPOINT: http://seaweedfs:8333
S3_REGION: "${S3_REGION:-us-east-1}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_FORCE_PATH_STYLE: "true"
S3_CONNECT_TIMEOUT_MS: "${S3_CONNECT_TIMEOUT_MS:-2000}"
S3_REQUEST_TIMEOUT_MS: "${S3_REQUEST_TIMEOUT_MS:-5000}"
S3_MAX_ATTEMPTS: "${S3_MAX_ATTEMPTS:-2}"
S3_HEALTH_PROBE_INTERVAL_MS: "${S3_HEALTH_PROBE_INTERVAL_MS:-30000}"
UPLOAD_TEMP_DIRECTORY: /tmp/announcements-uploads
UPLOAD_MAX_FILE_BYTES: "${UPLOAD_MAX_FILE_BYTES:-26214400}"
UPLOAD_MAX_FILES: "${UPLOAD_MAX_FILES:-20}"
IMAGE_MAX_INPUT_PIXELS: "${IMAGE_MAX_INPUT_PIXELS:-40000000}"
MQTT_URL: "${MQTT_URL:-}"
MQTT_USERNAME: "${MQTT_USERNAME:-}"
MQTT_PASSWORD: "${MQTT_PASSWORD:-}"
MQTT_TOPIC_PREFIX: "${MQTT_TOPIC_PREFIX:-announcements}"
MQTT_RECONNECT_PERIOD_MS: "${MQTT_RECONNECT_PERIOD_MS:-5000}"
MQTT_CONNECT_TIMEOUT_MS: "${MQTT_CONNECT_TIMEOUT_MS:-10000}"
HA_WEBHOOK_URL: "${HA_WEBHOOK_URL:-}"
HA_WEBHOOK_SECRET: "${HA_WEBHOOK_SECRET:-}"
HA_WEBHOOK_TIMEOUT_MS: "${HA_WEBHOOK_TIMEOUT_MS:-10000}"
DISPLAY_OFFLINE_THRESHOLD_MS: "${DISPLAY_OFFLINE_THRESHOLD_MS:-45000}"
volumes:
- app-data:/data
depends_on:
seaweedfs:
condition: service_started
networks:
- app
- storage
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:7532/health/ready').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
stop_grace_period: 15s
seaweedfs:
image: chrislusf/seaweedfs:4.39
restart: unless-stopped
command: ["mini", "-dir=/data", "-ip=seaweedfs"]
environment:
AWS_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
AWS_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
volumes:
- seaweedfs-data:/data
expose:
- "8333"
networks:
- storage
healthcheck:
test:
- CMD-SHELL
- >-
wget -q -O /dev/null http://127.0.0.1:9333/readyz &&
wget -q -O /dev/null http://127.0.0.1:8333/readyz
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
stop_grace_period: 30s
networks:
app:
storage:
internal: true
tabernet:
external: true
volumes:
app-data:
seaweedfs-data: