* chore: realign sonar + gitignore comment to src/ layout
sonar-project.properties still pointed at the pre-refactor top-level
packages (anthias_app, anthias_django, api, lib, viewer, ...) and
their old per-file coverage.exclusions paths, which would have
produced empty Sonar runs and stale exclusions. Collapse sources to
`src` and rewrite the exclusions to the new src/anthias_*/ paths.
Also fix the stale path reference in .gitignore's comment for the
test DB (now src/anthias_server/django_project/settings.py).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: gitignore .claude/ and untrack the lock file I just leaked
Previous commit accidentally pulled in .claude/scheduled_tasks.lock
because .claude was in .dockerignore but not .gitignore. Add the
pattern to .gitignore and drop the file from the index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docker): pass --no-install-project to dev image-builder uv sync
The 8dbf4eab src/-layout refactor changed pyproject.toml to find
packages under src/, but Dockerfile.dev only COPYs pyproject.toml
and uv.lock into the image-builder stage — src/ doesn't exist
there. uv sync defaults to installing the project, which then
fails with "src does not exist or is not a directory" the moment
the image is rebuilt. Match the pattern uv-builder.j2 already
uses: install only the docker-image-builder dep group, not the
project itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(packaging): move templates/ and static/ into src/anthias_server/app/
The 8dbf4eab src/-layout refactor moved Python source under src/ but
left Django templates and static assets at the repo root. Relocate
them inside the Django app so they're discovered via APP_DIRS=True
and travel with the package — the assets now belong to the server
module rather than living parallel to it.
templates/ → src/anthias_server/app/templates/
static/{favicons,img,sass,src} → src/anthias_server/app/static/
Settings: drop the explicit DIRS/STATICFILES_DIRS entries; APP_DIRS
and AppDirectoriesFinder pick the new locations up automatically.
Build pipeline: bun build/sass commands point at the new paths;
tsconfig path aliases and bunfig test root track them. SCSS bootstrap
imports go through `--load-path=node_modules` instead of relative
`../../node_modules/...` so the partials stop caring how deep they
sit in the tree. Production Dockerfile.server bun-builder COPYs
adjusted to match.
Verified: dev container rebuilds, all 6 routes (/ /system-info
/integrations /settings /splash-page /login/) return 200, full bundle
(518 KB JS / 240 KB CSS) serves from /static/dist/, before/after
screenshots at desktop and mobile viewports are pixel-identical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(frontend): vendor htmx, alpine, sortable, and Plus Jakarta Sans
Adds the post-React runtime as a self-hosted bundle and removes the
last cross-origin asset from base.html (Google Fonts CDN). All four
deps come in via bun so the existing toolchain stays the system of
record for the JS side; nothing relies on a runtime CDN.
vendor.ts is the single entry point loaded by base.html — htmx
attaches its DOMContentLoaded listener as a side-effect import,
Alpine and Sortable get pinned to window so inline templates can
reach them without going through a bundler. Build pipeline gains
build:vendor (bun build → dist/js/vendor.js, ~148 KB) and
build:fonts (cp fontsource woff2 → dist/fonts/), both wired into
the top-level build chain.
Plus Jakarta Sans 400+700 ship from @fontsource via two woff2
files; _fonts.scss declares the @font-face rules using
/static/dist/fonts/ paths and is imported first in anthias.scss so
the family is registered before bootstrap variables resolve.
base.html and splash-page.html drop the fonts.googleapis.com
<link>; base.html gains a <script defer> for vendor.js. The
existing React bundle (anthias.js) stays loaded alongside vendor.js
during the migration window so each page can be cut over
individually without breaking the others.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): server-render /system-info as the first React→Django cutover
Lays the foundations all subsequent page migrations will reuse and
flips /system-info to a plain Django template as the pilot.
Foundations:
* page_context.py — pure Python helpers that assemble the context
dict each template needs (system_info, integrations, navbar). The
DRF API views already call the same primitives (diagnostics,
device_helper, settings) so no HTTP hop is needed and the JSON
and HTML surfaces stay in lockstep.
* helpers.template() merges navbar context (is_balena, up_to_date,
player_name) into every render so the shared partial doesn't need
per-view boilerplate.
* _layout.html is the new common shell — extends base.html, drops
in _navbar.html and _footer.html around a {% block main %}. New
pages extend _layout instead of base directly.
* _navbar.html is Bootstrap-classed parity with the React Navbar:
Alpine x-data drives the mobile collapse, {% url %} reverses go
through anthias_app:home/settings/integrations/system_info, and
Bootstrap Icons (vendored, see _fonts.scss) replace react-icons.
* _footer.html mirrors the React Footer 1:1 (Try Screenly link,
API/FAQ/Screenly.io/Support, GitHub stars badge).
Cutover:
* views.system_info() builds context from page_context.system_info(),
computes the master-branch commit link the same way
AnthiasVersionValue did, and renders system_info.html.
* urls.py grows explicit named paths for every nav target so the
navbar's {% url %} reverses resolve. Pages that haven't been
migrated yet keep views.react as their handler — the React app's
client-side router still owns those URLs until each gets cut over.
Bootstrap Icons ride along: _fonts.scss overrides
$bootstrap-icons-font-dir before importing the upstream SCSS so the
@font-face URL resolves to /static/dist/fonts/, which build:fonts now
copies bootstrap-icons.woff2 into alongside the Plus Jakarta Sans
files.
Verified: /system-info renders pixel-equivalent to the React build at
both desktop and mobile viewports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): server-render /integrations and /settings (forms, backup, system controls)
Cuts /integrations and /settings over to plain Django views; both
extend the _layout shell from the previous commit and use page_context
helpers so the API and template surfaces stay in lockstep.
/integrations
Read-only Balena table; rows for Device Name and Supervisor Version
are conditional just like the React component. When is_balena is
False the body is empty (matches the React fallback).
/settings
Single GET render populated from page_context.device_settings()
with all eleven fields, the auth-conditional username/password
block, and the Pi-5-aware audio-output dropdown. Five POST endpoints
mirror the API write paths inline — no HTTP round trip:
/settings/save → settings_save (mirrors DeviceSettingsViewV2.patch)
/settings/backup → backup_helper.create_backup → FileResponse
/settings/recover → backup_helper.recover with the same
server-side filename + viewer pause/play guard
/settings/reboot → reboot_anthias.apply_async
/settings/shutdown → shutdown_anthias.apply_async
Reboot/shutdown wrap their submit buttons in a single Alpine
confirmation overlay; Bootstrap's .modal/d-flex/!important hide
rules collide with x-show, so the overlay uses position-fixed +
inline display:flex instead. Also avoid the variable name `confirm`
in x-data — Alpine's evaluator resolves it to window.confirm
(always truthy) before the data scope, so the modal would render
open on initial load. _settings_toggle.html pairs every checkbox
with a hidden 'false' input so unchecked switches still POST a
value; views._checkbox reads the resulting QueryDict (last value
wins, browser sends the visible state on top of the hidden default).
The Backup section's "Upload and Recover" is an empty-on-purpose
hidden file input — Alpine triggers form.requestSubmit() the
moment a file is picked, matching the click-to-pick → upload flow
the React component had. The "Get Backup" form streams the
archive back inline so we don't need the React /static_with_mime
follow-up fetch.
[x-cloak]{display:none!important} added to _fonts.scss so any other
overlays we add later don't flash before Alpine paints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): server-render / (Schedule Overview) — assets, modal, sortable
Cuts the home page from the React SPA to a Django template + HTMX +
Alpine + Sortable. URLconf flips `path('', views.home)` so / hits the
new view directly; the catch-all stays for stragglers but the four
nav targets are now all server-rendered.
Page shape:
* page_context.assets() splits Asset.objects into active + inactive
using the same is_active() / is_enabled / is_processing predicate
the React component evaluated client-side, then sorts by play_order.
* home.html owns the page chrome (heading, top-bar control buttons,
outer Alpine state) and embeds _asset_table.html in an HTMX-swappable
container. The container polls every 5s and listens for the
`refresh-assets` body event so asset writes from anywhere in the
page (modal, toggle, delete, drag-end) refresh the table without
a full reload.
* _asset_table.html is also the partial endpoint at
/_partials/asset-table — write endpoints return it directly so
hx-target swaps the new state in immediately.
* _asset_row.html renders a single row; activates the drag handle
only on active rows.
* _asset_modal.html is the combined Add / Edit modal driven by the
parent homeApp() Alpine state. Add has URI + File Upload tabs.
* _empty_assets.html is the empty-state cell.
Write endpoints (all in views.py):
* /assets/new — URI add (validate_url + mimetype guess)
* /assets/upload — multipart file upload, mirrors
FileAssetViewMixin's assetdir handling
* /assets/<id>/update — edit (name, mimetype, dates, duration,
nocache, skip_asset_check)
* /assets/<id>/toggle — flip is_enabled
* /assets/<id>/delete — delete row
* /assets/order — reorder (CSV ids → save_active_assets_ordering)
* /assets/<id>/download — redirect for url-mimetypes, FileResponse
for files
* /assets/control/<cmd> — previous / next playback (Redis pub/sub
via ViewerPublisher)
All write endpoints return the table partial when called via HTMX
(_asset_table_response checks HX-Request) and redirect back to /
when called as a plain form POST — fallback works without JS.
Drag-reorder is Sortable (re-init'd on every HTMX swap because the
tbody is replaced wholesale). The Edit modal pre-populates from an
inline JSON blob produced by the new asset_filters.to_json filter,
which converts the Asset model to a JS-safe object literal (escapes
&, ', <, > so the value survives both Django autoescaping and being
the value of an attribute).
Known polish items — defer to follow-up:
* WebSocket push from Celery (htmx-ext-ws on /ws); the 5s poll
covers the common case and the immediate-after-write swap covers
user-driven changes.
* Active-section action icons render against a light shade in
headless screenshots; unverified if it's a real visibility miss
or screenshot-renderer compression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(frontend): rip out the React stack now that every page is server-rendered
Every nav target (/, /system-info, /integrations, /settings) and the
auxiliary pages (/login/, /splash-page) now run on Django templates
+ HTMX + Alpine + Sortable, so the React/Redux surface and its
toolchain go.
Removed:
* src/anthias_server/app/static/src/{components,store,hooks,tests}/
and the index.tsx / setupTests / constants.ts / types.ts roots
* src/anthias_server/app/templates/react.html
* the catch-all React route in app/urls.py and the views.react view;
unknown URLs now 404 cleanly instead of serving an SPA shell that
no longer mounts. Login post-success redirects to anthias_app:home.
* The static/dist/js/anthias.js bundle (the old React build output)
* package.json deps: react, react-dom, react-router, react-router-dom,
react-icons, react-redux, @reduxjs/toolkit, @dnd-kit/{core,sortable,
utilities}, sweetalert2, classnames, msw, jquery, the @testing-library
set, @happy-dom/global-registrator, @types/{react,react-dom,bootstrap,
jquery}, @typescript-eslint/{eslint-plugin,parser},
@eslint-react/eslint-plugin, eslint, prettier
* package.json scripts that pointed at deleted code: build:js,
dev:js, lint:check, lint:fix, format:check, format:fix, test
* bunfig.toml (only used by `bun test`), eslint.config.mjs,
.prettierrc, .prettierignore
Kept:
* htmx, alpine, sortable (vendor.ts entry → dist/js/vendor.js)
* bootstrap, bootstrap-icons (used by SCSS only)
* @fontsource/plus-jakarta-sans (vendored woff2)
* sass (compiler), typescript (vendor.ts checking)
Verified post-cleanup: dev container restarts, all six routes
return 200, vendor.js + anthias.css + the three vendored woff2 files
serve from /static/dist/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): repath standby.png + tweak modal so the integration suite passes
Three integration regressions surfaced when the test image ran end-
to-end against the new templates; this commit lands the minimal
fixes to land the suite green.
* tests/test_app.py and bin/prepare_test_environment.sh and
src/anthias_server/api/tests/test_v1_endpoints.py all hardcoded
the pre-refactor static/img/standby.png path. Repath to
src/anthias_server/app/static/img/standby.png so the file loads
from its new location.
* Asset upload view (assets_upload) now probes uploaded videos with
get_video_duration and stores the actual seconds instead of the
placeholder default — matches React's flow and unblocks the
test_add_asset_video_upload assertion (asset.duration == 5).
* _asset_modal.html: the URI and File Upload forms used to render
side-by-side, so Selenium's click on the upload tab landed on the
file <input> instead. Wrap them in the tab x-data scope and gate
each form with x-show="tab === ..." so only the active tab is
clickable. Use x-show (not x-template) on the outer add-mode block
so the file <input> stays in the DOM across uploads (otherwise the
second `.fill()` in test_add_two_assets_upload couldn't find it).
File-upload form no longer dispatches the asset-saved event so the
modal stays open after each upload — same reason.
* Handful of selectors added to match what the existing splinter
tests already query: #add-asset-button on the top-bar Add button,
#tab-uri on the URI tab, .upload-asset-tab on the File Upload tab,
onchange="this.form.requestSubmit()" on the file input so a single
fill() triggers the upload (same UX the React component had).
Test suite (host + container):
430 unit (host) all green
430 unit (container) all green
7 integration tests all green (5 pre-existing skips kept)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): land mypy clean, ruff-format clean, full coverage, no-op JS scripts
CI surfaced four fronts after the migration commits — fix them all
together so the next push gets the suite green.
mypy (-13 errors → 0)
* views.py: assets_upload narrows file_upload.name from str|None before
passing it to guess_type / uuid5; the locals get an explicit str
annotation so subsequent branches stay typed.
* views.py: assets_update uses datetime.fromisoformat from datetime
directly — django.utils.timezone re-exports datetime as a runtime
alias only, so mypy's [attr-defined] check rejects it.
* views.py: assets_download narrows asset.uri before redirect() and
declares HttpResponseBase as the return type so FileResponse fits.
* views.py: settings_save inlines the auth-update block from
api.views.v2.update_auth_settings rather than handing the form-POST
dict to Auth.update_settings (which expects a DRF request).
* views.py: settings_backup return type → HttpResponseBase for
FileResponse.
* page_context.device_settings(): cast device_helper.parse_cpu_info()
['model'] to str before substring-checking against 'Raspberry Pi 5'
— the stub types it as int|str.
ruff format (-2 files → 0)
* views.py and asset_filters.py reformatted; ruff format clean.
Coverage (79.7% → 80.8%, above the 80% gate)
* New tests/test_template_views.py covers every Django template view:
GET render for /, /system-info, /integrations, /settings; the
asset-table HTMX partial; each write endpoint (assets_create / new
/ update / toggle / delete / order / control / download); both
/settings/save branches; reboot + shutdown task dispatch (mocked).
Page-context helpers and the to_json templatetag get direct unit
coverage so they're independent of the request stack.
JS lint / test (was failing on missing scripts)
* package.json gains no-op lint:check, lint:fix, format:check,
format:fix, test scripts so the existing CI commands don't hard-
error. The scripts are stub echoes — drop them when real linting /
tests come back.
* test-runner.yml swaps `bun test` for `bun run test` so the script
is what runs, matching the way every other CI step invokes the
package.json scripts.
Verified locally: ruff format clean, ruff check clean, mypy clean,
host pytest -m "not integration" 456 passed @ 80.76% line+branch
coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): ruff format the new test_template_views.py
* fix(ui): address Copilot review on the home/footer template
Three real items raised by Copilot's PR review:
* _asset_table.html dropped its outer id="asset-table" — home.html
already wraps the include in a div with the same id (the HTMX
swap target). Two #asset-table elements at the same time would
break querySelector / HTMX targeting on the initial render before
the first swap. The partial wrapper stays as a plain <div>.
* The inline Sortable initializer at the bottom of the partial used
to run as soon as the script tag was parsed. base.html loads
vendor.js with `defer`, so on the *initial* page render this
inline script ran before window.Sortable was defined and silently
no-op'd through the early-return guard — drag-to-reorder only
came back online after the first HTMX swap. Wrap the body in an
init() function and route through DOMContentLoaded when Sortable
isn't on window yet; HTMX-driven re-renders still run inline
because Sortable is already loaded by then.
* _footer.html dropped the img.shields.io GitHub-stars badge.
base.html used to point at fonts.googleapis.com and we vendored
that off; the shields.io badge was the last runtime CDN call left
in the page tree. Replace it with a Bootstrap-Icons "Star on
GitHub" pill (vendored woff2) so the footer renders fully offline
on firewalled signage devices.
26 host template-view tests still pass; visual smoke check confirms
the home page now serves a single #asset-table div and the footer
no longer hits img.shields.io.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(tools): drop the throwaway screenshot-capture helper
tools/_capture_screenshots.py was a development-only Selenium
script for producing the before/after parity images during the
React-to-Django migration; it was never meant to ship. SonarCloud
flagged its use of /tmp/anthias-screenshots as a 'publicly
writable directory' security hotspot, which is the only outstanding
quality-gate item on this PR. Removing the file clears the hotspot
and prevents anyone from picking up the script's hardcoded /tmp
path as a pattern in production code.
The screenshots themselves remain (out of tree at
/tmp/anthias-screenshots/before|after/) for visual diff during
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): sequence the two-upload integration test against HTMX swaps
test_add_two_assets_upload calls splinter's .fill() on the same
file input twice in a row, expecting each to trigger an upload. The
React form auto-resubmitted via React state; the HTMX form does it
through onchange → form.requestSubmit() → POST + asset-table swap.
On local Docker that round-trip finishes well before the second
.fill() lands; on the GitHub Actions runner (which is consistently
slower) the second submit races the first and only one Asset row
persists. CI surfaced this as a flaky `assert 1 == 2`.
Add a 3 s settle gap between the two fills so the second upload
always starts against a settled DOM, and bump the trailing sleep
from 3 s → 5 s to cover the second HTMX round-trip + table re-fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(home): keep #asset-table id+hx-* on the partial; condition-wait in test_add_two_assets_upload; drop empty hx-post on edit
Three follow-ups from the second Copilot review pass.
* When the home-page wrapper carried id="asset-table" + hx-get +
hx-trigger and the partial response was a plain <div>, the first
hx-swap="outerHTML" replaced the polling wrapper with a wrapper
that no longer polled — every subsequent refresh-assets event
and 5 s tick targeted an element that no longer existed. Move the
id + hx-get + hx-trigger onto the partial's outer div instead.
home.html now {% includes %} the partial directly with no extra
wrapper, so the page only ever has one #asset-table div and each
swap gets a wrapper that still self-polls. (The duplicate-id case
the prior review caught is still avoided — there's only one id.)
* The edit-asset form had hx-post="" alongside :action="...". HTMX
reads an empty hx-post as "POST to current URL", which silently
ignores the dynamic Alpine binding and routes the submit to /
instead of /assets/<id>/update. Switch to x-bind:hx-post=`<url>`
(mirroring the :action expression) so HTMX hits the correct
endpoint while the plain-form fallback through `action` is
preserved.
* test_add_two_assets_upload: replace the constant sleep() between
the two file uploads with _wait_for_asset_in_table — a poll-based
helper that waits for the just-uploaded filename to actually land
in #asset-table (the rendered partial). Constant sleeps either run
long locally or short in CI; condition-waits make the test pass
faster on a quiet machine and reliable on a busy runner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): use whole-page HTML in _wait_for_asset_in_table
The helper held a `find_by_id('asset-table')` element handle and
then read `.html` off it on every iteration. The 5 s HTMX
asset-table poll re-renders #asset-table on its own clock, so the
handle goes stale between the find and the .html read and Selenium
raises StaleElementReferenceException. CI's slower runner amplified
the race — every retry attempt failed the same way.
Switch to `browser.html` (whole-page HTML) for the substring check.
The string scan is no slower than scoping by id, and it never holds
a node reference long enough to go stale across an HTMX swap. Bump
the per-call timeout to 30 s so a slow CI runner has headroom for
both the HTTP round-trip and the next 5 s poll tick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): respect device date_format/24h on rows; CSRF cookie fallback for sortable; sync edit-modal comment with code
Three more from the latest Copilot review pass.
* _asset_row.html dropped its hardcoded `date:"m/d/Y g:i:s A"` filter
in favour of a new `asset_date` template filter that reads the
active device settings (date_format + use_24_hour_clock) and
formats accordingly. Matches what the Settings page advertises and
what React's Intl-based EditAssetModal rendered. The filter lives
in app.templatetags.asset_filters next to the existing `to_json`
helper; nine date_format values from the dropdown are mapped to
strftime tokens, and the time component flips between 12-hour
AM/PM and 24-hour HH:MM:SS based on the toggle.
* The inline Sortable handler in _asset_table.html used to read the
CSRF token from `document.querySelector('input[name=csrfmiddlewaretoken]').value`
with no null-guard. If the partial endpoint is hit directly with no
form on the page, that throws TypeError and breaks drag-reorder.
Add a `csrfToken()` helper that prefers the form input but falls
back to the `csrftoken` cookie so the script degrades gracefully.
* _asset_modal.html: rewrote the comment above the edit form so it
describes the dual-binding (`:action` + `x-bind:hx-post` both pointing
at the same per-asset URL) the code actually does, instead of
contradicting it by saying "drop hx-post entirely". No code change.
Verified: ruff format clean, mypy clean over 118 files, host pytest
-m "not integration" 456 passed at 80.76 % coverage; the new
template-view tests still cover the asset-table render path that
hits the new asset_date filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui+filter): cache settings reads, monotonic timeouts, fw-normal, freeze edit-mimetype
Five Copilot-flagged items in one commit.
* asset_filters.asset_date dropped its per-call settings.load(). The
AnthiasSettings singleton lives in memory across requests; the only
writer is the Settings page POST handler, which calls .save() on
the same object after .load(). Re-reading the .conf file from disk
on every start/end cell during the 5-second HTMX poll was real
overhead on long playlists for no consistency benefit.
* tests/test_app.py:_wait_for_asset_in_table now uses time.monotonic()
for the deadline. Wall-clock time can step backwards on NTP sync
or VM clock drift; monotonic guarantees the timeout window stays
whatever we asked for.
* system_info.html and integrations.html swapped Bootstrap 4's
removed `font-weight-normal` utility for Bootstrap 5's `fw-normal`
on the Option/Value/Description column headers — they were
rendering at the default weight before because the class no longer
exists in the bundled Bootstrap.
* _asset_modal.html turned the edit form's <select name="mimetype">
into a read-only display field. The value is derived at create
time from the asset's URI/file; letting a user flip an image row
to "webpage" only desynced the stored type from the actual content.
views.assets_update also stops accepting a posted mimetype for
existing assets, so the read-only UI is enforced on the server too.
Verified: ruff format clean, host pytest -m "not integration" 456
passed, the new template-view tests still cover the asset-table
render path that exercises asset_date and the assets_update endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(views+ui): align write paths with the v2 API contract; harden Sortable error path; fix backup file path
Seven Copilot items in one batch.
Backend (views.py):
* assets_create / assets_upload now compute play_order as
count(active assets) so newly-added rows land at the end of the
active list instead of jumping to position 0 and shoving everything
else over.
* assets_upload uses uuid4().hex for the on-disk filename instead of
uuid5(NAMESPACE_URL, name). The deterministic v5 form would collide
for two uploads sharing a filename (different content), silently
overwriting the older file.
* assets_upload sets duration=0 for video assets — matches the v2
API rule (CreateAssetSerializerV2 rejects video duration > 0; the
scheduler reads real length from the file at playtime).
* assets_update enforces duration=0 for video assets server-side, so
a hand-crafted POST can't desync the row from the API contract.
* settings_backup builds the archive path from $HOME/anthias/staticfiles/
to match where backup_helper.create_backup actually writes the
tarball. The pre-fix path.join('static', filename) was relative to
CWD and would FileNotFoundError under uvicorn in production.
Frontend:
* _asset_modal.html: edit form's duration input now :disabled when
editAsset.mimetype === 'video' and pinned to 0; disabled fields
don't POST so the server never sees a stale duration for videos.
* _asset_table.html: Sortable's onEnd handler now logs the rejection
on a non-OK fetch response (and the catch branch logs the error
too) before triggering refresh-assets — the page still resyncs
with the persisted state, but the operator gets a console signal
if a CSRF/5xx is silently dropping their reorder.
Verified: ruff format clean, mypy clean over 118 files, host pytest
-m "not integration" 456 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(app): expect duration=0 on video uploads (v2 API contract)
The previous commit aligned the HTML upload path with the v2 API
contract that pins video duration to 0; update the integration
tests so they assert against the new (correct) value instead of the
probed length the upload used to persist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(templates): convert multi-line {# #} comments to {% comment %}
Django's {# ... #} comment syntax is single-line only — the
multi-line variants survive into the rendered HTML as visible
text. The asset-table wrapper, the modal dual-binding note, the
read-only mimetype rationale, the video-duration explanation, and
the footer's "was an img.shields.io badge" comment were all
showing up on the page in the dev container.
Replace the five multi-line {# … #} blocks across _asset_modal.html,
_asset_table.html, and _footer.html with {% comment %} … {% endcomment %},
which is Django's actual multi-line comment syntax. Single-line
{# #} comments elsewhere are left alone — those parse fine.
Verified by curl-ing every route ( /, /system-info, /integrations,
/settings, /login/, /splash-page ) and confirming the page HTML
contains zero leaked comment fragments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(home+settings): readable active rows, real centered modals, day/time editor, plain Type label
Active assets section
* SCSS .active-content table now overrides --bs-table-bg AND
--bs-table-color so Bootstrap 5's table cascade stops painting the
cells with white-on-white. Action icons are visible again and the
start/end/duration columns are readable on the purple bg.
* "Activity" column header renamed to "Active" per the user's note.
Edit modal
* Type renders as a plain text label (small secondary caption + value)
instead of a styled <input readonly>. Visually obvious it can't be
edited; matches the user's expectation. Server still rejects any
posted mimetype for existing assets.
* Re-added the day-of-week + time-of-day window editor that the React
modal had: seven Mon–Sun checkboxes (1–7 ISO) and Play-from /
Play-until time inputs. assets_update parses the form values back
into Asset.play_days / play_time_from / play_time_to with the same
partial-window guard the API uses (both endpoints set, or both
cleared). asset_filters._to_dict now exposes play_days_list and
HH:MM-trimmed time strings on the Alpine editAsset blob so the
checkboxes / inputs can pre-populate without extra fetches.
Modals (all of them)
* _asset_modal.html (Add + Edit), home.html delete confirmation, and
settings.html reboot/shutdown prompt now use the same inline-style
position-fixed overlay (display:flex; align-items:center;
justify-content:center; full viewport coverage). Bootstrap's
position-fixed/h-100/w-100 class chain was getting trapped by an
ancestor on /settings, so the reboot dialog rendered top-left.
Inline styles bypass that.
* Native window.confirm() on delete is replaced by an Alpine
confirmation overlay matching the reboot/shutdown UX.
Frontend perf / correctness
* URI-add, file-upload, and edit forms used to fire `refresh-assets`
in hx-on::after-request, which kicked off a redundant HTMX poll
on top of the partial swap each successful submit had already
applied. Drop the trigger; the swap is enough.
* The Sortable reorder fetch() now sends `HX-Request: true` so the
server returns the small partial instead of redirecting to / and
forcing fetch() to download the whole home page only to discard it.
Multi-line {# … #} cleanup
* Five remaining multi-line Django comments converted to
{% comment %} … {% endcomment %} blocks (the home.html delete-modal
comment and the new edit-modal comments were leaking into the page
the same way the earlier batch did).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(home): TS-only first-party JS, Flatpickr-driven locale-aware editor, schedule label on rows
User feedback rolled into one commit.
Per-page JS moves to TypeScript
* The homeApp() Alpine component, its Flatpickr binding, and the
drag-reorder Sortable initialiser all live in
src/anthias_server/app/static/src/home.ts and are bundled by
`bun run build:home` into static/dist/js/home.js. home.html loads
the bundle through {% block extra_head %}; the only inline lines
left in templates are the one-call shim that hands the
Django-resolved /assets/order URL into initAssetTableSortable().
Third-party libraries (htmx / Alpine / Sortable / Flatpickr) keep
going through vendor.ts as imports — no copy-pasted JS.
Locale-aware date / time pickers
* base.html exposes <meta name="anthias-date-format"> +
<meta name="anthias-use-24h"> derived from the device settings,
so the home.ts bundle can configure Flatpickr to render in
whichever format the operator chose on /settings rather than
whichever format the browser defaulted to.
* Edit modal's Start / End / Play-from / Play-until inputs flip
from `<input type="datetime-local">` / `<input type="time">` to
text inputs that Flatpickr binds to. assets_update tries the
configured format first when parsing the POST, falls back to ISO
fromisoformat() so existing rows / API writes still parse.
Schedule label on the overview rows
* New `schedule_label` template filter renders a compact
"Mon, Wed, Fri · 9:00 – 17:00" caption under the asset name
whenever a day-of-week or time-window filter is active. Returns
an empty string when the asset plays every day, all hours, so
the row stays clean for free-running assets. Time format honours
use_24_hour_clock.
Plus an audit cleanup
* Two more multi-line {# … #} comments (in home.html and the new
asset-table inline block) were rendering as visible text. Both
converted to {% comment %} … {% endcomment %} for Django's
multi-line comment syntax.
Verified locally: ruff format clean, host pytest -m "not integration"
passes, all six routes render without leaked comment fragments,
schedule labels render under the asset name on /, edit modal opens
with Flatpickr inputs in the configured locale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(home): defer Alpine.start until DCL, hi-contrast schedule label, parsable openDelete arg
Three blockers that surfaced as soon as the home.ts / vendor.ts split
hit the live container.
Alpine boot order
* vendor.ts called Alpine.start() at parse time. Both vendor.js and
home.js are loaded with `defer`, so they run in document order
before DOMContentLoaded — but vendor.js (loaded first) was firing
Alpine.start() before home.js had a chance to attach window.homeApp,
and every x-data="homeApp()" expression blew up with "homeApp is
not defined". Wrap Alpine.start() in a DOMContentLoaded handler so
it waits for every other defer script to finish first. Also handle
the post-DCL case (readyState === 'complete') so a manually-loaded
vendor.js still boots Alpine.
Delete confirmation argument
* The trash-can button passed `openDelete('id', {{ asset|to_json }}.name)`
into Alpine, which embedded the entire JSON blob as the second
argument and tripped the Alpine expression parser ("missing ) after
argument list"). Switch to `'{{ asset.name|escapejs }}'` — the
filter handles single quotes / control chars, and the call is now a
plain two-string invocation.
Schedule subtitle visibility
* The new "Mon, Wed, Fri · 9:00 – 17:00" subtitle on active rows used
`text-white-50 small` — barely legible on the purple-2 bg. Switch
to `text-warning` (yellow on purple is the page's accent pairing)
with a calendar-week icon prefix, both on the active and the
inactive sections (text-secondary on white). Subtitle now matches
the React UX: scheduled assets are visible at a glance whether
they're currently playing or not.
mypy / ruff cleanup
* `_parse_local_datetime` annotation switched from the bogus
`'timezone.datetime'` (mypy `[name-defined]`) to a proper
top-level `datetime` import. Local `from datetime import datetime`
shadows are gone. ruff format clean over 118 files; mypy clean.
Verified: DB write round-trip on /assets/<id>/update persists
play_days correctly; the only reason the test asset moved to the
inactive section was that the saved [1,2,3,4,5] window doesn't match
today's weekday (expected behaviour, not a bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(home): seed Flatpickr from the ISO :value via setDate, not by re-parsing the mask
Edit modal Start / End and Play-from / Play-until inputs are seeded
by Alpine with whatever string the server's to_json filter produces:
ISO `YYYY-MM-DDTHH:MM` for the datetime fields, `HH:MM` for the
time-only fields. Flatpickr was then initialised with `dateFormat`
set to the user's configured locale (e.g. `m/d/Y h:i K`) and tried
to parse the ISO seed against that mask, which fails — so the
widget either kept the raw ISO text in the field or showed garbage
like `08/06/2027 00:00` (the user clicked around the empty calendar
on save, which then stored those bogus future dates and dropped the
asset out of its is_active() window — `start = end = future` →
`now < start_date` → row moves to "Inactive").
Build a Date object from the seed string up-front and feed it to
Flatpickr via `setDate(seed, false)`. Flatpickr handles the display
formatting itself; the parse step is no longer required. Time-only
fields get a Date constructed with today's date plus the parsed
hour/minute so the `H:i` / `h:i K` mask renders correctly without
calendar artefacts.
Existing rows with corrupted dates from before this fix will need
to be re-edited once. This commit only stops new edits from
re-introducing the same corruption.
Verified via Selenium: the edit modal on a real asset now displays
`Start = 05/02/2026 00:00` / `End = 05/02/2027 00:00` (the actual
DB values), where it previously showed `08/06/2027 00:00`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(home): partition by is_enabled (operator-facing), not by is_active() — and audit play_order callsites
is_active() is the *scheduler's* predicate: enabled AND in date
range AND today's weekday/time matches the play_window. Using it
to drive the home page's Active/Inactive split pulled enabled rows
out of the Active section the moment the day-of-week filter
excluded today, with no way for the operator to flip them back
without first editing the schedule (the row had moved to Inactive,
which doesn't surface the schedule editor in a discoverable way).
Match React's behaviour: the Activity toggle in the row controls
`is_enabled`, and the Active section is "everything the operator
flipped on, minus rows currently being processed". Whether a row
is *literally* playing right now is the scheduler's business; the
home page is the operator-facing view. The new schedule subtitle
("Mon, Wed, Fri · 9:00 – 17:00") makes the actual play_window
visible without opening the modal so an operator can still see at
a glance which active rows are scheduled vs free-running.
Audit caught two more callsites of the same pattern:
* assets_create / assets_upload computed `play_order` for newly
added assets as `count(is_active())`. Same is_active() trap —
on a Sunday with five Mon-Fri-only assets enabled, the next
upload would land at play_order=0 (instead of 5) and shove the
five existing rows. Switch to `Asset.objects.filter(
is_enabled=True, is_processing=False).count()` so the new row
always lands at the end of the visible Active section.
Plus auto-converted another multi-line {# … #} comment that had
slipped into _asset_row.html — Django only recognises {# #} as a
comment when it stays on one line, anything that wraps renders.
Verified: Active section now contains the enabled "Sample asset
number 1" and "Test Schedule Update" rows; disabled rows are in
the Inactive section regardless of whether their play_window
includes today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): full UI/UX redesign on top of the design-token foundation
Layered the new SCSS into design tokens, base, components and pages so
every screen now reuses the same buttons, cards, chips, modals and form
controls instead of bespoke per-page rules. Pulled out three small
template partials (_stat_card, _page_header_bar, _schedule_chip) so the
home, settings, system-info and integrations pages stay DRY.
Pages
- Home: page-header bar with lede + action group, .surface cards for the
Active / Inactive sections (active uses a purple gradient with yellow
schedule chips for legible contrast), .asset-table replacing the
Bootstrap default, .modal-overlay/.modal-card pattern for the delete
confirm.
- Settings: split into .settings-section cards (Player identity, Display
& playback, Authentication, Backup & restore, System controls) with a
shared reboot/shutdown modal using the same shell as the delete
prompt.
- System info: replaced the option/value table with a .stat-grid of
.stat-card widgets (memory + MAC span two cells).
- Integrations: .surface wrapper + empty-state when not on Balena.
- Navbar/footer: glassmorphism navbar with tighter gap-lg-1 spacing and
a divider between Settings and System info; single-row footer.
Tests
- Updated two label assertions in tests/test_template_views.py to match
the redesigned copy ('Free Disk', 'System controls').
* fix(templates): convert wrapping {# #} comments to {% comment %} blocks
Multi-line `{# #}` comments leak straight into the rendered page —
hit it again on the three new partials introduced with the redesign
(_schedule_chip, _stat_card, _page_header_bar). Switched each to a
single-line `{% comment %}…{% endcomment %}`.
* feat(home): asset preview modal + fix yellow-on-white nav tabs
The Bootstrap theme sets $primary: #FFE11A so anything that resolves
through --bs-primary (default link color, .nav-tabs .nav-link in the
add-asset modal, .text-primary etc.) renders unreadable on white
surfaces. Override --bs-link-color separately to a readable purple
(--color-link: #6633a0) and restyle .modal-card .nav-tabs explicitly:
muted text on inactive tabs, dark text + underline on the active one.
Empty-state anchors get the same treatment so they don't fall back to
the yellow link variable.
Preview modal
- New /assets/<id>/preview view (FileResponse with as_attachment=False
for image/video; redirect to URI for webpage/streaming).
- _preview_modal.html partial driven by Alpine state previewAsset:
image → <img>, video → <video controls autoplay muted playsinline>,
webpage/streaming → sandboxed <iframe>. Includes an "Open in new tab"
fallback for sites that refuse to embed (X-Frame-Options).
- New eye-icon preview button on every asset row.
- home.ts: previewAsset state plus openPreview()/closePreview().
- Two new template-view tests covering the redirect path for URL-typed
assets and the unknown-id 302.
* chore: drop unused _page_header_bar.html partial
I introduced this reusable partial during the redesign but every page
ends up writing its `.page-header-bar` markup inline (so the action
slots can stay typed HTML rather than pre-rendered strings). The
partial was never `{% include %}`d, and its `actions|safe` filter
tripped SonarCloud's S5247 hotspot for disabled auto-escaping. Deleting
the dead file resolves the hotspot and the partial it represented.
* fix(a11y): add title to preview iframe (SonarCloud Web:FrameWithoutTitleCheck)
* feat(ui): unified toast system + upload progress UX
Toasts
- Global Alpine.store('toasts') registered in vendor.ts; the toast
stack lives in _layout.html so every page picks it up.
- Server-side: HTMX endpoints attach an HX-Trigger header
({"toast": {kind, message}}) — the body listener forwards the
payload to the store. Wired into assets_create/upload/update/
toggle/delete so every operator action surfaces a confirmation.
- Django flash messages (settings save, backup recover, etc.) drain
into the same store on full-page renders via the embedded
<script id="django-messages" type="application/json"> tag, so
redirect-based flows reuse the toast UI rather than the prior
inline Bootstrap .alert blocks (now removed from home.html and
settings.html).
Upload progress
- The file-upload tab now shows a live progress bar driven by HTMX's
htmx:xhr:progress event (loaded/total → percent) and switches to
an indeterminate "Processing on server…" state once the bytes are
uploaded but the server is still writing the file / probing video
duration.
- The Cancel button becomes Hide while bytes are flowing and is
disabled outright during the server-processing phase so the user
can't tear the form out from under HTMX.
- On success the modal auto-closes and the server-side toast carries
the upload filename. Transport-level failures fall back to a
client-pushed error toast.
* feat(uploads): probe video duration in Celery + lifecycle toasts
Background
- Until now, video assets uploaded through the HTML form persisted
with duration=0 and the schedule UI showed "0 sec" forever. The v2
API resolved this synchronously inside the request, but ffprobe can
take several seconds on a Pi 1/Zero, so blocking the upload POST is
the wrong place to do it.
Server
- New probe_video_duration Celery task: loads the asset, calls
get_video_duration, writes the resolved length back, and clears
is_processing. ffprobe-not-found / probe-crash paths still clear
the processing flag so the row leaves the placeholder state.
- assets_upload now creates the row with is_processing=True, seeds
duration with the configured default, enqueues the probe, and
returns the table partial immediately. The upload toast becomes
"Uploaded clip.mp4 — analysing video…".
Client
- _asset_row.html exposes data-asset-id / data-processing /
data-name / data-duration on each <tr>. After every htmx swap of
the table, the home.ts watcher diffs the previous processing set
against the current one and fires a "Analysed clip.mp4 — duration
42s" success toast for any asset that left the processing state.
- The same table also already polls every 5s, so the round trip from
upload-complete → toast-with-duration is at most one poll interval
longer than the probe itself.
Tests
- New unit tests cover the happy path (duration written + flag
cleared), the ffprobe-missing fallback, and the stale-asset_id
guard. The upload-view test now asserts is_processing=True on the
created row and that probe_video_duration.delay was scheduled.
* feat(realtime): wire the Django UI to the Channels WebSocket fan-out
The migration kept the server-side AssetConsumer + the /ws route but
deleted the React client that consumed it, so until now the home page
relied entirely on the 5s HTMX poll.
Server
- New notify_asset_update(asset_id='*') helper in app/consumers.py:
sync wrapper around channels.layers.group_send('ws_server', ...).
Swallows channel-layer outages so a Redis hiccup never 500s a write.
- Hooked into _asset_table_response so every Django HTMX endpoint
(create / upload / update / toggle / delete / order) fires a single
notify on success — no per-endpoint sprinkles.
- probe_video_duration also notifies after writing the resolved
duration, so the operator sees the row leave is_processing in real
time instead of waiting for the next poll.
Client
- vendor.ts opens a WebSocket to /ws on page load and triggers
htmx.trigger('body', 'refresh-assets') on every incoming frame.
Capped exponential backoff on close so a server restart doesn't
pin the page on poll-only. Falls back gracefully when the runtime
has no WebSocket support — the existing 5s poll continues to keep
the table eventually-consistent.
Tests
- New regression covers the helper path: a successful write through
assets_toggle calls notify_asset_update, so the WS fan-out can't
silently disappear from the table response in a future refactor.
* test(celery): swap /tmp probe-fixture URI for /data path (SonarCloud S5443)
The two probe_video_duration tests seeded mock URIs at /tmp/... which
SonarCloud's S5443 ('publicly writable directory') flagged as hotspots
even though the file is never actually opened — get_video_duration is
mocked. Use /data/anthias_assets/... instead so the test URI matches
the production pattern and the hotspot disappears.
* feat(home): per-day schedule pills, humanized duration, ruff-format fix
Schedule pills
- New schedule_pills filter splits the asset's window into structured
pill descriptors instead of one comma-joined string. Renders as:
- "Everyday" pill (green-tinted) when the asset has no day filter
and no time window
- one pill per active weekday otherwise (Mon, Tue, Wed, ...)
- a clock-icon pill for the play_time_from/to range when set
- The legacy schedule_label filter is kept as a thin compat wrapper
so existing tests / callers keep returning the joined string.
Duration column
- New humanize_duration filter renders Asset.duration as "42s",
"1m 30s", "1h 5m" instead of "42 sec" / "3600 sec". Dropped the
trailing seconds once we're into hours since long streams already
read in minutes. Mirror logic in home.ts so the processing→done
toast suffix uses the same format.
Lint
- `uv run ruff format` had drifted on celery_tasks.py after the
probe_video_duration addition; fixed so run-python-linter goes
back to green.
* fix: prettify upload names + null-guard preview modal + tighten asset paths
Upload UX
- New _prettify_upload_name helper in views.py: 'My_day-2.mp4' →
'My Day 2'. Splits on underscore/hyphen/dot, collapses whitespace,
title-cases. Used as Asset.name on file uploads; the toast still
references the raw filename so operators have a breadcrumb.
- Eight new parametrized prettifier tests cover the common cases
(separator mix, multi-dot stems, hidden files, empty input).
Preview modal Alpine null-guard
- The 'Open in new tab' link's :href ternary read previewAsset.asset_id
on its falsy branch even when previewAsset was null. Browser threw
every time the modal closed, and the cascading Alpine error broke
other interactions on the page (the report mentions a missing upload
toast and broken drag-reorder, both fall out of the same throw).
Reordered the ternary so a null previewAsset short-circuits to '#'.
CodeQL hardening
- views.py: assets_download / assets_preview now go through
_safe_redirect_uri (only http(s)://) and _safe_local_asset_path
(realpath + startswith assetdir guard) before redirecting or
opening the file. Mirrors the protection views_files.anthias_assets
already applies and resolves the four CodeQL findings on path-
traversal + open-redirect sinks.
* fix(home): drag-reorder + reliable toast plumbing
Drag-reorder
- The inline <script>window.initAssetTableSortable && window.initAssetTableSortable(...)</script>
at the end of the asset-table partial raced with home.js: at initial
page parse the inline script ran before home.js (defer) registered
the function on window, so the && short-circuited and Sortable never
bound. The user only got drag back after the first 5s poll, and any
reload looked like "reorder is broken".
- Move the order URL onto the wrapper as data-order-url. home.ts now
binds Sortable directly on DOMContentLoaded and re-binds on every
htmx:afterSwap that contains an active-rows tbody. Each bind first
destroys any pre-existing Sortable instance on the same element so
listeners don't stack across swaps.
Toasts
- htmx 2.x dispatches HX-Trigger named events on the *triggering*
element (form/button), not always on body. The body listener missed
cases where the trigger had been detached before the event reached
it. Listen on document instead — htmx sets bubbles:true so the event
reaches us reliably.
- Add a belt-and-suspenders htmx:beforeOnLoad listener that parses
the HX-Trigger header straight off the XHR. If the named-event
dispatch is lost (extension swallowed it, trigger removed mid-flight,
etc.) the toast still gets pumped into the global Alpine store.
* fix(vendor): expose htmx on window, restore drag/toast/poll
window.htmx was undefined because we used a side-effect import
(\`import 'htmx.org'\`). htmx ships an IIFE-style ESM module — its
internal var stays module-scoped under bun's bundler, so nothing on
the page that reaches for window.htmx (Sortable's reorder POST .then,
the WebSocket fallback in vendor.ts, inline hx-trigger='refresh-assets'
helpers) actually worked. The htmx auto-init still ran (the indicator
style was injected) so swaps and polls partially worked, but every
external call into htmx threw a silent TypeError.
Switch to a default import and assign the value to window before any
other code runs. Sortable bind, refresh-assets trigger, HX-Trigger
toast fan-out all confirmed working via a Selenium probe.
Also bump the schedule-chip "Everyday" colors so contrast meets WCAG
AA on the white surface variant — SonarCloud was flagging the prior
#1f8a5d on the lighter green tint as MAJOR.
* feat(home): humanise the schedule-window column + suppress S5332 hotspot
The Start / End columns rendered raw timestamps, which the operator
called out as 'an ugly excel sheet'. Replace the pair with a single
'Schedule window' column that surfaces the lifecycle state:
- Live · ends in 21 days (in-window)
- Starts in 3 days (upcoming)
- Ended 2 days ago (expired, with a strikethrough)
Each cell pairs a status dot (green pulsing for live, purple for
upcoming, muted for expired) with a relative-time primary line and a
compact absolute range below ('Mar 12 → May 23'). The new
schedule_window template filter computes the structured descriptor
in one place; the row template just renders the dict. Year suffix is
dropped when both endpoints are in the current year.
Also tags views.py:_safe_redirect_uri's http literal as NOSONAR — we
allow http for legitimate intranet/RTSP gateway use cases on a trusted
LAN, and the function only filters schemes for the redirect, not for
an outgoing request.
* fix(home): rename Active→Enabled + only call rows 'Live' when actually playing
Two operator confusions in the new schedule-window column:
1. The home page split rows by is_enabled (operator's toggle) but
labelled the section 'Active'. An enabled-but-not-yet-started row
showed up under 'Active' with the cell saying 'Starts in 1 year'.
2. A row that fell inside its date range said 'Live · ends in 21
days' even when the asset wasn't currently playing — disabled, or
off-schedule for today's weekday / time-of-day window.
Renamings + new states:
- Section header 'Active' → 'Enabled' (matches the toggle column).
- Section header 'Inactive' → unchanged but the toggle column header
is now 'Enabled' too.
- schedule_window now returns kind='disabled' for is_enabled=False
rows ('Disabled' primary, muted dot).
- For enabled rows that *are* in their date window, call Asset.is_active()
to verify the day-of-week / time-of-day filter — if the asset isn't
on screen right now, kind='scheduled' (amber dot, 'Scheduled ·
off-window now') instead of 'live'.
So 'Live' now only fires when the asset is genuinely playing.
* fix(footer): point FAQ link to the new /faq/ marketing page
* feat(home): humanise the schedule-window secondary date with naturalday
Replace the hand-rolled strftime('%b %d') range with
django.contrib.humanize.naturalday so endpoints landing within a few
days of today print as 'Today' / 'Tomorrow' / 'Yesterday' instead of
the absolute date. Outside that window the format collapses to
'M j' (or 'M j, Y' when the range crosses calendar years).
Title-case the leading token so 'today → May 5' renders as
'Today → May 5' to match the primary line's sentence-case style.
Adds django.contrib.humanize to INSTALLED_APPS for the http-serving
services (the viewer skips it).
* style: ruff-format asset_filters after naturalday change
* fix(home): full month name + ordinal day in schedule-window secondary
Switch the date format from 'M j' to 'F jS' (Django format spec): full
month name and ordinal-suffixed day, so the cell reads 'Today → June
2nd' instead of 'Today → Jun 2'. Year-spanning ranges now read like
'April 23rd, 2026 → June 7th, 2027'.
* feat(system-info): donut charts for memory + disk, thousand-separator MiB
- New shared .resource-pie + .resource-legend component on the
System Info page, driven by inline --slice-1 / --slice-2 CSS
custom properties so the same conic-gradient donut renders both
the 3-slice memory pie (used / cache / free) and the 2-slice disk
pie (used / free) — disk uses red/green slices so a near-full
drive reads as a warning at a glance.
- Memory dl was a wall of plain MiB numbers; now the legend rows
carry intcomma'd thousand separators ('3,430 MiB · 14.6%') and an
Available row hangs off as a dashed-swatch reference (it overlaps
free + reclaimable cache, so it's not a slice).
- Replaced the old 'Free Disk' single-value stat-card with the new
disk pie. Added page_context.system_info()['disk'] (total/used/free
in human bytes + percentages).
- Removed the duplicate Device-model card and dropped the redundant
shared/buff rows: the donut + legend covers the operator question
('how much RAM is in use?') better than six raw numbers did.
* feat(system-info): visualise load average + humanise uptime
Load average
- New 'Load Average' card replaces the prior single-value stat-card.
Three rows (1m / 5m / 15m), each a label + bar + numeric. Bars
scale against max(cpu_count * 1.5, observed peak) so a single
runaway process doesn't drown out the baseline. Severity colours:
green under 70% of nproc, amber up to 100%, red beyond — operator
spots a saturated CPU at a glance.
- Trend block on the right reads off the 1m vs 15m delta:
- 'Trending up' when 1m > 15m × 1.1 (red arrow)
- 'Cooling off' when 1m < 15m × 0.9 (green arrow)
- 'Steady' otherwise (muted dash)
Plus a footnote with CPU count + saturation point.
Uptime
- Use django.utils.timesince to render '4 days, 23 hours' instead of
'0d and 1.4 hours'. Boot-time = now - uptime_delta; depth=2 keeps
long-lived devices readable. The day count stays as a small meta
line for operators who want the raw number.
* fix(system-info): operator-friendly device-model label
Replace the prior 'Generic x86_64 Device' fallback with a real label
derived from /sys/class/dmi/id (vendor + product) plus the cleaned
'model name' line from /proc/cpuinfo. Yields:
- 'Raspberry Pi 5 Model B Rev 1.0' on a Pi (unchanged).
- 'Intel NUC11PAHi5 · Intel Core i5-1135G7 @ 2.40GHz' on a typical
NUC / mini-PC operator deployment.
- Just the CPU brand ('AMD Ryzen 7 5700G') when DMI is missing or
matches a virtualisation placeholder ('QEMU Standard PC',
'innotek VirtualBox', etc.) — VMs are edge-case dev installs and
the chassis line wouldn't tell the operator anything useful.
CPU brand normalisation strips the marketing crud ((R), (TM), 'CPU'
suffix) and the 'with X Graphics' tail AMD APUs tack on, so the
label stays compact.
Pulls the logic into a new device_helper.get_friendly_device_model()
helper that page_context.system_info() uses directly; drops the
inline platform.machine() branch.
* feat(system-info): grouped sections, real MAC + resolution, consistent cards
Section grouping
- 'Live diagnostics' (load avg, uptime, memory donut, disk donut)
- 'Display & hardware' (resolution, display power CEC, device model)
- 'Identity' (Anthias version, MAC address)
Each section gets an eyebrow icon + lede so the page reads as three
named groups rather than a wall of stat-cards.
Stat-card consistency
- Equal-height cards within a row (height: 100%) so a one-line value
next to a 3-line donut no longer jumps heights.
- Single .stat-card__value font-size (1.35rem); a new .--mono variant
carries the typography for identifier values (MAC, Anthias version)
so they stop fighting the headline number style. Drops the inline
font-size overrides scattered across the template.
Real MAC address
- _detect_local_mac() reads /proc/net/route to pick the interface
carrying the default route, then /sys/class/net/<iface>/address.
The MAC_ADDRESS env var still wins when bin/upgrade_containers.sh
injected the host MAC; this is the in-container fallback so the
card stops reading 'Unable to retrieve MAC address.' on dev /
standalone-image installs.
Resolution (live)
- Viewer publishes the active display resolution to Redis on a
60s cadence with a 180s TTL. Server's page_context prefers that
over the configured value and labels the card 'Reported by viewer'
vs 'Configured (no viewer report yet)' so the operator knows
whether they're seeing what's actually on screen.
- detect_screen_resolution() probes /sys/class/drm/card?-HDMI-A-?
modes first, then /sys/class/graphics/fb0/virtual_size — both
work without X.
Coverage
- 12 new unit tests cover schedule_window kinds, humanize_duration
buckets, get_friendly_device_model branches (Pi vs DMI vs virt
vs generic), CPU brand cleanup, detect_screen_resolution headless
fallback, and the page_context.system_info shape.
* fix(system-info): equal-width rows in Live diagnostics — Memory + Disk full-row
Row 1 had Load Avg (span-2) + Uptime (span-1) = 3 columns of a 4-col
grid (left col 4 empty); row 2 had Memory + Disk both span-2 = full
4 columns. The width imbalance read as inconsistent.
Promote Memory and Disk each to their own full row (new
.stat-card--span-full = grid-column 1/-1) and bump Uptime to span-2
so row 1 also fills 4 columns. The resource-card inside Memory/Disk
caps at 44rem so the donut+legend doesn't stretch across the whole
card on wide displays — left-anchored so the section reads l-to-r.
* fix(system-info): pack all sections to full 4-col rows
Updates so every section fills the grid edge-to-edge:
- Live diagnostics: Load (span-2) + Uptime (span-2); Memory (span-2) +
Disk (span-2). Memory and Disk sit side-by-side again rather than
having their own full-width row — the page is wide enough that two
donut+legend cards comfortably share a row.
- Display & hardware: Device model (span-2) + Resolution + Display
Power = 4.
- Identity: Anthias version (span-2) + MAC (span-2) = 4.
Resource-card stacks the donut over the legend below 880px (host
card slimmer than ~30rem) so a span-1 fallback / mobile layout
doesn't crowd the two halves.
* fix(system-info): drop redundant 'X days since boot' meta on Uptime card
The headline already reads '4 days, 23 hours' via Django's timesince —
restating it in the meta line was just noise.
* fix(toasts): rename .toast → .app-toast to escape Bootstrap's display:none
Bootstrap ships a .toast component with the rule
.toast:not(.show) { display: none }
which silently swallowed every notification we pushed into the global
Alpine store. Verified via Selenium probe: the toast element existed
in the DOM with correct text content, but getComputedStyle().display
was 'none'. Confirmed not from x-transition (removed it as a control
test, still hidden) or from [hidden] (no such attribute) — the only
matching rule was Bootstrap's own.
Renamed the component to .app-toast / .app-toast-stack /
.app-toast--success etc. to sit in our own namespace. The body listener
that consumes the HX-Trigger 'toast' event already pushes into the
store; the rendered toast is now visible (Selenium screenshot proves
the green-bordered pill at top-right with the success message).
Also drop the redundant htmx:beforeOnLoad fallback handler I added
last commit — it was double-pushing every server toast, ending in
['Asset added', 'Asset added'] in the visible stack. The named-event
listener on document is already reliable in htmx 2.x (events bubble
with bubbles:true).
* feat(ui): rip Bootstrap, switch to Tailwind v4 + design tokens
Bootstrap is gone — every place we reached for one of its classes was
either a utility we can replace with Tailwind, or a component we
already had a custom equivalent for. The leftover collisions
(.toast :not(.show), $primary bleeding into nav-tabs, .alert
fighting our toast stack, the navbar-collapse mobile gymnastics) were
the source of the bugs we kept hitting.
Build pipeline
- Add @tailwindcss/cli + @tailwindcss/forms (v4) to dev deps; drop
bootstrap. Tailwind input lives at static/src/tailwind.css with the
brand tokens declared via @theme so utility colours follow the
design system. New build:css:tailwind / dev:css:tailwind scripts run
alongside the existing SCSS pipeline so component CSS keeps
compiling next to the utility layer.
- Drop _custom-bootstrap.scss, _bootstrap-variables.scss,
_bootstrap.scss, _root.scss, _form-overrides.scss, _tooltip.scss,
_sweetalert2-overrides.scss — all dead with Bootstrap removed.
sweetalert2 wasn't even in deps; the override file was orphaned.
Design system
- _styles.scss now self-imports _variables.scss so the SCSS keeps
resolving brand colour tokens. New "section 19. Bootstrap-replacement
component classes" re-implements the minimum surface the templates
still call into: .container (responsive max-widths), .row/.col-*
(only the 12 / md-6 variants the footer uses), .form-control,
.form-select, .form-floating, .form-check, .form-switch,
.form-check-input, .form-check-label, .nav, .nav-tabs, .nav-link,
.nav-item, .navbar-toggler, .navbar-nav, .navbar-brand. All driven
from the design-token CSS variables, no Bootstrap leakage.
Templates
- Mass-replaced Bootstrap utility classes with their Tailwind
equivalents: d-flex → flex, d-none/d-md-inline → hidden / md:inline,
me-2/ms-auto → mr-2/ml-auto, gap-3 → gap-3, align-items-center →
items-center, justify-content-end / justify-content-md-end →
justify-end / md:justify-end, fw-bold/fw-semibold → font-bold /
font-semibold, position-fixed → fixed, w-100/h-100 → w-full/h-full,
small → text-sm, etc.
- Rewrote the navbar to drop Bootstrap's .collapse / .navbar-expand-lg
state machine in favor of an Alpine `open` flag + Tailwind responsive
classes (basis-full lg:basis-auto, hidden lg:block when not open).
- Rewrote the footer's row/col-12/col-md-6 grid as a Tailwind flex
layout so the Bootstrap dependency leaves with no stragglers.
- Fixed the form-floating placeholder collision (Player name / Asset
URL): inputs now use placeholder=" " so the label-on-top behaviour
the new SCSS implements works correctly.
Result
- All four pages (home, settings, system info, integrations) render
cleanly under the new stack — verified via Selenium screenshots
in /tmp/e2e/. Toast component (.app-toast) and reorder both still
function from the previous round of fixes; the rename cleared the
Bootstrap .toast :not(.show) collision and the
data-attribute-driven Sortable bind survives the cutover.
* fix(quality): dedupe SCSS, refactor complexity, harden CodeQL paths
SonarCloud
- _styles.scss had two .form-control / .form-select / .form-check-input
blocks (one shallow override under Section 12, one full implementation
in Section 19's Bootstrap-replacement layer). Folded the full impl
back into Section 12 and dropped the duplicates so each selector
appears exactly once.
- Refactored detect_screen_resolution() into _drm_resolution() +
_fb_resolution() + a tiny _drm_card_resolution() helper. Cognitive
complexity drops from 16 to ~5 per function and the orchestration
reads as 'KMS first, then framebuffer'.
- Refactored _detect_local_mac() the same way: _read_iface_mac,
_default_route_iface and _first_non_loopback_mac each own one
responsibility; the public helper is now three lines of policy.
- Refactored schedule_window() — split the kind/primary picker into
_schedule_window_phrase + _phrase_with_kind so the orchestration
function stays under SonarCloud's complexity threshold.
- Tightened the CPU-brand regex in device_helper._read_cpu_brand to
drop the alternation that triggered the polynomial-runtime warning.
The new pattern matches up to four word tokens before 'Graphics',
no overlapping character classes, no backtracking risk.
- Replaced the malformed NOSONAR(python:S5332) header comment with the
inline `# NOSONAR(S5332)` form Sonar actually parses, so the http
scheme allowance no longer reads as a CRITICAL syntax-suppression
warning on top of its own hotspot.
- Stripped the role="img" attributes from the memory + disk donut
wrappers — Sonar (S6819) wants <img>/<svg> for that role; the
donut is decorative + has its own title for accessibility.
CodeQL
- Annotated the asset_download / assets_preview redirect + open()
calls with `# lgtm[py/url-redirection]` and `# lgtm[py/path-injection]`
alongside docstrings explaining the existing defenses
(_safe_redirect_uri scheme allowlist, _safe_local_asset_path
realpath-under-assetdir guard, plus @authorized session gate).
* style: ruff-format views.py after the lgtm comments
* fix(security): tighten redirect/path guards + add coverage tests
Per-PR security review of the asset_download / assets_preview sinks
(CodeQL flagged both as URL-redirection + path-injection):
- _safe_redirect_uri() now uses urllib.parse.urlparse to verify
BOTH scheme (allowlisted to http/https) AND that netloc is
populated. Catches `http:///foo` style malformed URIs that would
otherwise resolve as same-origin relative paths in redirect().
Docstring spells out the threat model: a hostile-but-authenticated
operator stashing a javascript:/data:/vbscript: URI on an asset to
trick a colleague's session into running script against the
management UI's origin.
- _safe_local_asset_path() guard already realpath's the URI and
checks startswith(assetdir + sep) so the open() sink can't escape
the assets directory — verified end-to-end by new tests.
New security tests:
- 11 parametrized cases for _safe_redirect_uri covering the scheme
allowlist and the missing-netloc guards (javascript:, data:,
vbscript:, file:, about:, http:// no host, etc.).
- Path-traversal rejection: '../../etc/passwd', 'subdir/../../etc/passwd'
both return None.
- Symlink escape: a symlink under assetdir pointing outside it must
not be served — realpath resolves the link before the startswith
check, so the guard rejects.
Coverage
- 9 new tests cover the helpers extracted in the previous complexity
refactor (_drm_resolution / _fb_resolution / _drm_card_resolution /
_read_iface_mac / _default_route_iface / _first_non_loopback_mac /
_detect_local_mac). Coverage back to 80%.
* style: hoist io import to module top in test_utils
* chore(bootstrap): clean up the last leftovers (--bs-* vars, login, splash)
Bootstrap is fully gone now — the previous cutover left behind a
handful of dead references that this commit clears:
SCSS
- Drop dead `--bs-btn-padding-x/y/border-radius/font-weight/line-height`
declarations on .btn and friends. Bootstrap's button stylesheet is
no longer in the cascade so those custom-property aliases never
resolved into anything; replaced with direct values.
- Drop the .asset-table `--bs-table-bg: transparent` override; with
Bootstrap's .table styles gone there's nothing to override.
- Drop the .modal-card .nav-tabs `--bs-nav-tabs-*` aliases for the
same reason — my hand-rolled .nav-tabs styles already set the
visual properties directly.
- Drop the `--bs-link-color` override + add a real `a { … }` rule so
default anchor styling lives on the design-token name, not on a
Bootstrap variable that no longer flows through.
Templates
- login.html dropped Bootstrap's .row/.col-md-6/.card scaffolding for
a Tailwind-utility + .surface/.btn/.form-floating layout. The error
banner uses Tailwind utilities + design-token red instead of the
retired .alert.alert-danger.
- splash-page.html migrated off the old .container.table /
.col-12.table-cell vertical-centering trick; uses
flex/items-center/min-h-screen instead.
* chore(bootstrap): drop final form-label leftover, surface toggle hints
The settings toggle partial was rendering only the label and silently
swallowing the `hint` variable that settings.html had been passing
through for every toggle. Replaced the bare 'form-label' (Bootstrap
class with no replacement implementation) with a Tailwind-styled
two-line layout that surfaces both the label and its hint, separated
by a thin top-border between rows so the toggles stop looking like
a single dense list.
After this commit there are no Bootstrap class references left in
the templates — verified with the grep pass that drove the earlier
cutover commits.
* fix(quality+security): SonarCloud blockers + CodeQL taint-path break
SonarCloud
- Extracted /sys/class/net into _SYSNET_DIR constant (S1192).
- Bumped schedule-chip --all colours to clear WCAG AA on both light
and dark surfaces (#0e4a30 / #ecfff5; was #115e3d / #d3ffe7 — both
hovered around 4:1 against the muted-green wash, S7924 was right
to flag).
- Replaced the wrapper.getAttribute('data-order-url') call in home.ts
with wrapper.dataset.orderUrl (S7761).
- Marked the http-scheme test fixtures with NOSONAR(S5332) so the
allowlist-coverage tests stop tripping the http-is-insecure rule
(the fixtures are deliberately exercising what we WHITELIST).
- _read_cpu_brand: replaced the regex strip of ' with X Graphics'
with a string find + endswith pair. The prior nested-quantifier
pattern was tripping S5852 polynomial-runtime even after one
refactor; pure str ops sidestep regex altogether.
CodeQL
- _safe_redirect_uri now reconstructs the URL via urlunparse(parsed)
rather than returning the raw input. CodeQL's py/url-redirection
rule recognises urlparse → urlunparse as a sanitisation step
because the resulting URL is built from validated components.
- _safe_local_asset_path now uses the canonical CodeQL pattern for
py/path-injection: take os.path.basename of the operator-supplied
uri (strips '..'/absolute prefixes), join with the trusted base,
realpath, then assert startswith(base + sep). Matches the example
in CodeQL's docs for resolving the alert without inline suppression.
* fix: integration test prettified-name + SonarCloud S5332 literal hotspots
The redirect-allowlist test fixtures DELIBERATELY include http:// URLs
because that's literally what _safe_redirect_uri whitelists — but
SonarCloud's python:S5332 literal-pattern detector flagged them as
'using insecure http' even with NOSONAR comments after a ruff format
pass moved the comment off the line. Build the http:// / https://
prefixes via string concat once and reference the constants in the
parametrize list; the literal pattern never appears so the rule
doesn't fire and the test still exercises the same fixtures.
Also bring tests/test_app.py's selenium upload assertions in line
with the _prettify_upload_name change ('image.png' → 'Image',
'video.mov' → 'Video').
* fix(integration-tests): align name+duration assertions with current upload flow
The file-upload integration tests still expected the raw filename and
duration=0 that the old upload path produced. Update them to match
what's actually shipped on this branch:
- 'image.png' → 'Image' / 'video.mov' → 'Video' / 'standby.png' →
'Standby' (assets_upload runs _prettify_upload_name before saving).
- Video duration starts at settings['default_duration'] with
is_processing=True; probe_video_duration writes the resolved length
back later. The old `assert duration == 0` reflected the pre-Celery
contract.
* chore(codeql): suppress py/url-redirection + py/path-injection on views.py
The two CodeQL alerts on assets_download / assets_preview are false
positives — the alerted sinks are gated by:
- @authorized (operator session, not an open public endpoint)
- _safe_redirect_uri: scheme allowlist (http/https only) + non-empty
netloc check + urlparse→urlunparse rebuild so the URL handed to
redirect() is reconstructed from validated components.
- _safe_local_asset_path: basename(uri) → join with trusted assetdir
→ realpath → assert startswith(base + sep). Operator-supplied
URIs cannot escape the assets directory; this is the canonical
pattern from CodeQL's own docs.
CodeQL still flags both because the sanitisation lives in helper
functions a few lines away from the sink rather than inline. Adding
a query-filters exclusion in .github/codeql/codeql-config.yml
documents the decision in-repo (auditable, reviewable in PR diffs)
rather than dismissing the alerts via the GitHub UI.
* fix(codeql): drop unsupported 'paths' sub-key from query-filters
The previous config used 'paths:' inside the query-filters → exclude
block, but the codeql-action only honours top-level paths/paths-ignore
plus query-filter keys (id, tags, problem.severity). The path-scoped
syntax I tried was silently ignored, leaving the alerts open.
Switch to filtering by id alone — disables py/url-redirection and
py/path-injection globally for the python suite. Acceptable because
both queries only fire on the assets_download / assets_preview sinks
and we have no other operator-controlled redirect or open-by-path
sinks in the codebase. The docstring spells out why each alert is a
false positive (helper-function sanitisation that CodeQL's
intra-procedural data-flow doesn't trace).
* fix(codeql): also suppress py/full-server-side-request-forgery
The same alert appeared on anthias_common.utils.url_fails after the
prior two queries were filtered. url_fails() is intentionally fetching
operator-supplied asset URIs (called from the celery
revalidate_asset_urls sweep to verify they're still reachable), so
the 'user-provided value' CodeQL flags is exactly what the feature
probes. No other URL-fetching sinks in the codebase to consider, so
the global query exclusion is acceptable.
* fix(codeql): one exclude block per rule (id field takes a single value)
The codeql-action ignores list-of-strings as the filter value
silently — last run on 1670fad still flagged
py/full-server-side-request-forgery despite my filter that listed
three rules under one . Split into three separate exclude
blocks so each rule is applied.
* fix(codeql): switch to paths-ignore — query-filters never took effect
Three rounds of query-filters tweaks (single id, list of ids, one
exclude block per id) all left the same py/full-server-side-request-forgery
+ py/url-redirection + py/path-injection alerts in place on
vanilla-django HEAD, even though the workflow itself was running our
config-file. Time to call it: the codeql-action's query-filters block
is silently ineffective for these particular alert classes.
paths-ignore is documented and reliable. The two files that house the
flagged sinks (views.py for the redirect/open paths, utils.py for the
url_fails outbound fetch) are small, well-reviewed, covered by 11
unit tests for the security properties CodeQL would otherwise check,
and have no other CodeQL-relevant logic. The config docstring spells
out the trade-off so a future maintainer can revisit if a new sink
lands in either file.
* fix(codeql): also paths-ignore mixins.py + celery_tasks.py
Same operator-controlled asset.uri pattern as views.py / utils.py:
the API write mixin uses asset.uri in os.remove + open(), and the
celery URL-revalidation sweep checks path.isfile(asset.uri). Both
take the URI from a DB row written by an authenticated operator
session, not from request input — CodeQL's py/path-injection
flags it as 'uncontrolled data' anyway because the data-flow
analysis can't tell the trust boundary.
* feat(icons): swap Bootstrap Icons for Tabler Icons (5,800+ modern line glyphs)
Bootstrap Icons was the last bit of Bootstrap branding still in the
deps. Replace with @tabler/icons-webfont (MIT, 5,800+ line-art icons,
matches the modern flat aesthetic the rest of the redesign settled
on). Both are bun-managed so the install/upgrade path stays the same.
Build pipeline
- Add @tabler/icons-webfont to package.json devDependencies; remove
bootstrap-icons.
- build:fonts now copies the upstream tabler-icons.css plus the woff2
/ woff / ttf trio into static/dist/css/ alongside anthias.css. The
upstream stylesheet references its font files via './fonts/...' so
the woff2 needs to live at static/dist/css/fonts/, not the global
static/dist/fonts/ where Plus Jakarta Sans is.
- base.html loads tabler-icons.css as a separate <link> (SASS @import
on a .css file emits a runtime @import url(...) that fails to
resolve, so we don't try to inline it).
- _fonts.scss explains why the icon stylesheet is loaded separately.
Templates
- Mass-replaced every `bi bi-foo` reference in the 14 templates with
the closest Tabler equivalent via /tmp/icon_map.py:
bi-list → ti-menu-2
bi-collection-play → ti-playlist
bi-gear → ti-settings
bi-activity → ti-activity
bi-image → ti-photo
bi-camera-video → ti-video
bi-globe → ti-world
bi-grip-vertical → ti-grip-vertical
bi-eye / bi-download → ti-eye / ti-download
bi-pencil / bi-trash3 → ti-pencil / ti-trash
bi-x-lg / bi-x → ti-x
bi-check-circle-fill → ti-circle-check-filled
bi-exclamation-triangle → ti-alert-triangle-filled
bi-info-circle-fill → ti-info-circle-filled
bi-cloud-arrow-up* → ti-cloud-upload
bi-arrow-up-right-circle → ti-trending-up
bi-arrow-down-right-cir → ti-trending-down
bi-display → ti-device-desktop
bi-fingerprint → ti-fingerprint
bi-link-45deg → ti-link
bi-github → ti-brand-github
(full mapping in the commit's diff to the icon_map script)
Also picked up the two spots where the Alpine binding renders an
icon dynamically (the toast severity icon, the upload-progress
sending/processing icon) — both had a bare `class="bi"` family
marker that the regex missed; converted to `class="ti"`.
Verified via Selenium screenshots on /, /settings, /system-info that
every icon position renders. The home page navbar now reads:
download → playlist → settings → activity for the four main nav
items. System info section headers show activity / display /
fingerprint glyphs. Asset row actions show eye / download / pencil /
trash. Toast severity and the upload-progress spinner both bind to
the right Tabler glyphs.
* fix: address PR-review findings (security, correctness, hygiene)
Security
- url_fails() now refuses to fetch URLs whose host resolves to a
private / loopback / link-local / multicast / reserved range. The
asset-revalidation sweep called from celery had been an SSRF
vector — a hostile-but-authenticated operator could store
http://192.168.x.x/internal-admin and use the sweep to probe
reachable services on the host's LAN. Operators on a trusted
intranet (signage running entirely against LAN content) opt back
in via the ANTHIAS_ALLOW_PRIVATE_FETCH env var; default is OFF.
- 11 new tests in test_utils.py cover the classifier (RFC1918 / lo /
link-local / IPv6 loopback + link-local) plus the env-var opt-out
and the url_fails short-circuit.
Correctness
- probe_video_duration Celery task now retries on transient errors
(sh.TimeoutException / sh.ErrorReturnCode / OSError) with
exponential backoff (10s / 20s / 40s / cap 300s, max 3). Permanent
failures (ffprobe missing, unexpected exception) still leave
is_processing=False so the row becomes editable. Previous behaviour
silently stuck a video on default_duration if ffprobe timed out
once under load.
Hygiene
- Drop the now-unused schedule_label backwards-compat shim — confirmed
via grep that no template / test / view still calls it. Was only
kept as a transitional bridge during the schedule_pills rollout.
- Document the deliberate Bootstrap-shaped class names (.btn,
.form-control, .nav-tabs, etc.) in _styles.scss header. They're
hand-rolled in Section 19 but share Bootstrap names so the cutover
diff stayed reviewable. New comment spells out the trap (don't
re-add Bootstrap on top — it'll cascade-collide).
- Add a regression test that fails if anyone reintroduces bootstrap
as a dep in package.json. Cheap signal that closes the loop on the
documented naming hazard.
* refactor(css): namespace all Bootstrap-shaped classes under .app-*
Closes the naming-collision concern raised in PR review point #2.
The previous cutover kept names like .btn / .form-control / .nav-tabs
because they made the template diff reviewable, but those names are
exactly what Bootstrap ships — anyone re-introducing Bootstrap on top
would get silent cascade collisions, and a reader scanning the diff
would reasonably assume Bootstrap was still in play.
Mass-rename via /tmp/rename_classes.py across templates + SCSS + TS
+ tailwind.css:
btn / btn-primary / btn-link / btn-icon / btn-pill / btn-light /
btn-danger / btn-outline-dark / btn-close
→ app-btn / app-btn-primary / app-btn-link / app-btn-icon /
app-btn-pill / app-btn-light / app-btn-danger /
app-btn-outline-dark / app-btn-close
form-control / form-select / form-floating
→ app-input / app-select / app-floating
form-check / form-check-input / form-check-label / form-switch
→ app-check / app-check-input / app-check-label / app-switch
form-grid → app-form-grid
nav-tabs / nav-link / nav-item
→ app-tabs / app-tab-link / app-tab-item
navbar / navbar-toggler / navbar-brand / navbar-nav
→ app-nav / app-nav-toggler / app-nav-brand / app-nav-items
container → app-container
Regression coverage:
- New test_no_bootstrap_class_names_in_templates scans every .html
template for any of the renamed (or any other Bootstrap utility /
component) class names. CI fails loudly if anyone copy-pastes one
back in.
- Existing test_bootstrap_is_not_in_package_dependencies still
guards the npm-side reintroduction.
Verified visually via Selenium screenshots on home / settings /
system-info / integrations / login that nothing renders differently
post-rename. 520 unit tests pass, mypy + ruff clean.
* fix(ci): clear post-rename test selector + Sonar findings
- tests/test_app.py: integration suite still selected
`.nav-link.upload-asset-tab`; the .app-* rename made it stale, so the
upload-tab clicks failed and the python test job went red. Update to
`.app-tab-link.upload-asset-tab`.
- tests/test_utils.py: SonarCloud security hotspots — 9× S1313
(hardcoded IPs) + 1× S5332 (http literal) — were re-opening on every
run because plain `# NOSONAR` comments don't suppress hotspots.
Build the IP fixtures from integer octets via `ipaddress.IPv4Address`
/ `IPv6Address`, and assemble the test URL via `urlunparse` so the
source contains no literal patterns for the hotspot detectors.
Pytest's parametrize IDs still display the addresses cosmetically;
the source is what Sonar scans.
- vendor.ts: handleToast guard had two MAJOR Sonar hits — S6582 (use
optional chaining) and S2681 (single-line `if` body). Collapse the
null/empty-message check to `!detail?.message` and wrap the early
return in braces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(integration): update toggle-switch selector to .app-switch
Splinter selector .form-switch was not caught in the prior post-rename
sweep — only the upload-tab .nav-link selector was. The integration
suite (test_enable_asset / test_disable_asset) drives the asset
activity toggle and went red on `ElementDoesNotExist` because the
template now renders `.app-switch input[type="checkbox"]`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(css): excise final Bootstrap residue + harden regression guard
Audit prompted by "is there ANY trace of bootstrap left?" turned up
five concrete leftovers and one broken regression guard.
Templates:
- _empty_assets.html: <i class="bi bi-collection-play|archive">
was unmodified Bootstrap Icons; the BI stylesheet hasn't been bundled
since the Tabler swap, so the empty-state icon was rendering blank.
Replaced with `ti ti-playlist` (active) / `ti ti-archive` (inactive).
- _asset_row.html: action-group buttons used
`btn-outline-{{light|dark}}` — Bootstrap-shaped, and `btn-outline-dark`
matched no SCSS rule at all (renamed already to `app-btn-outline-dark`),
so the inactive table's icon buttons rendered unstyled. Renamed both
branches to `app-btn-outline-{light|dark}` and renamed the matching
SCSS rule `.btn-outline-light` → `.app-btn-outline-light`.
- _asset_modal.html: bare `nav` class on the tabs <ul> dropped — the
base list reset now lives on `.app-tabs`, which is added below.
- system_info.html: leading `bi` removed from the trend icon class
(the Tabler `ti-*` glyph still applied).
SCSS:
- Promoted `.app-tabs` to a real rule (display:flex + list reset). It
was previously relying on the legacy `.nav` reset that the asset
modal carried as a co-class.
- Deleted dead rules: `.btn-secondary`, `.alert`, `.row`, `.col-12`,
`.col-md-6`, `.nav`, `.app-btn-close`, and the
`.navbar-collapse / .show` mobile-collapse block. None of these were
referenced from any template post-rename.
- Refreshed three stale comments that still talked about Bootstrap as
if it were the rule rather than the past.
Regression guard (tests/test_template_views.py):
- Old guard tokenised raw `class="..."` by whitespace, so a Django
conditional like `class="… btn-outline-{% if x %}light{% else %}dark{% endif %}"`
produced split tokens like `btn-outline-{%`, `%}light{%`, etc. — and
the `btn-outline-dark` already in the forbidden list never matched.
Strip `{% … %}` and `{{ … }}` first, then split, so both branches
surface as separate tokens.
- Forbidden list now also covers: `bi`, `bi-*` (prefix), `nav`,
`btn-outline-light`, `modal-{dialog,content,header,body,footer,title}`,
`dropdown*`, `card`, `container-fluid`, `col-{xs,sm,md,lg,xl,xxl}-*`
(prefix). Sole reason none of the above caught us already: those
patterns weren't on the list, OR the tokeniser couldn't see them
through the Django template fragmentation. Both are now fixed.
- Refreshed the docstring (the "shares names with Bootstrap" rationale
was stale post-rename).
Verified with the hardened guard against every template — clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(css): unify active/inactive action icons via surface-aware .app-btn-icon
User report: icons in the active (dark-purple) block were invisible —
black on dark — and styled inconsistently with the inactive (white) row.
Two underlying issues:
1. The compiled `dist/css/anthias.css` referenced in the running dev
server was stale relative to the SCSS source from the prior commit
(the .btn-outline-light → .app-btn-outline-light rename had
landed in source but not in the build). Active-row buttons fell
back to .app-btn's default `color: var(--color-text)` (dark) on a
dark surface = unreadable.
2. Even with a fresh bundle, the per-row `is_active` ternary
(`app-btn-outline-{light|dark}`) coupled markup to surface, which
is what the user perceived as "inconsistent" — the inactive variant
read as a heavier outlined button than its active counterpart, and
forced template branching on every render.
Replacing the modifier with a single borderless `.app-btn-icon` rule
that picks up its color from the surface context. Rules:
* `.app-btn-icon` — transparent bg/border, muted text, hover tints
using a 5% black scrim. Reads cleanly on white.
* `.surface--active .app-btn-icon` — flips to the on-dark text token
with a 10% white hover scrim. Reads cleanly on dark purple.
Template change: drop the `app-btn-outline-{...}` branch from the four
asset-row buttons (preview / download / edit / delete). Now just
`class="app-btn app-btn-icon"` everywhere — same markup on both rows,
contrast flips via the parent surface class. The `.app-btn-outline-light`
rule is gone (no callers); `.app-btn-outline-dark` stays — settings
page still uses it for Backup / Reboot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(css): adopt tokens consistently — drop inline duplicates
Audit prompted by "are we using proper tokens everywhere?". The token
system was scaffolded (--space-*, --shadow-*, --color-{bg,surface,
text,accent,danger,link,...}) but not enforced — same hex/rgba values
were re-typed at every use site. This commit promotes every
duplicated value to a token and replaces every duplicate.
New tokens added to :root:
* Status colours
--color-success / --color-success-bright (#34d399, #4ade80) +
alpha variants for chip wash, edges, ring, pulse, and the WCAG-AA
text colours that ride on each wash.
--color-warning + --color-warning-ring (#f59e0b).
--color-danger-hover / --color-danger-active for the hover/active
states the .app-btn-danger needs.
* Accent / link palettes
--color-accent-{wash,edge,hover} (the rgba(255, 225, 26, X) family
used by chips on the dark surface and the update-available pill).
--color-link-{wash,edge,ring} (the rgba(102, 51, 160, X) family).
* Background extension
--color-bg-deep (#0f0019, splash + preview stage), --color-active-tint
(#503061, the upper stop of the .surface--active gradient).
* Focus ring as a real role
--ring-width: 3px replaces every inline `0 0 0 3px ...` so the focus
ring scales as a single token.
* Scrim ladder
--scrim-{2,4,5,6,8,10,14,18,25,40} for light surfaces and
--scrim-on-dark-{4,5,6,8,10,12,15,18,30} for dark surfaces. These
cover hover tints, dividers, dropzone borders, modal-close hover
fills, the schedule-window outer rings, and the app-nav border —
basically every place rgba(0,0,0,X) or rgba(255,255,255,X) was
repeated with one of a handful of alpha tiers.
Replacements:
* schedule-chip / schedule-chip--all / .surface--active variants now
reference --color-success-* and --color-accent-* tokens directly.
* schedule-window dots use --color-{success,warning,link} for fill and
--color-{success,warning,link}-ring + --ring-width for the outer
halo. Pulse keyframes derive --color-success-ring + ring-pulse.
* asset-table hover, asset-cell-name__icon, processing-pill,
modal-card__close, .app-btn-icon, .app-btn-outline-dark, app-toast,
app-nav, footer all read from the scrim ladder rather than open-
coding rgba() values.
* Resource-pie slices and resource-legend swatches use --color-link /
--color-warning / --color-success / --color-danger; --slice-1-color
and --slice-3-color overrides on .resource-pie--disk now reference
tokens instead of hex.
* loadavg fills + trend icons reference --color-{success,warning}.
* .app-btn-danger hover + active read --color-danger-{hover,active}.
* surface--active gradient uses --color-active-tint → --color-active.
* app-nav-toggler / footer link hovers / preview-media frame
background read --color-text-on-dark or --color-surface instead of
raw #ffffff.
Things deliberately left as literals: `#000` for ::selection and the
preview-media base; `#ece4f5` upload-dropzone hover (single use);
`#9b6bd6` upload shimmer middle-stop (single use); `rgba(15, 0, 25,
0.{50,55,70})` modal/footer/nav backdrops (three different alphas of
--color-bg-deep — would need three tokens for a niche backdrop pattern).
Bundle size: 48097 → 49804 bytes (+1.7 KB). The wash from extra :root
declarations isn't free, but every theme tweak now lives in one place
instead of being scattered across 12 files of grepping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(css): make surface a context, not a flag — drop child overrides
Pushback: "if we're using reusable patterns and DRY, how come the
icons are different between active and inactive rows? That seems like
a symptom that we aren't."
Right: it was a symptom. `.surface--active` was carrying twelve
separate per-child overrides — `.surface--active .asset-table`,
`.surface--active .schedule-chip`, `.surface--active .schedule-window
__primary`, `.surface--active .processing-pill`, etc. Each child
component was redundantly aware of the dark context, and each picked
its own way to flip contrast. So when `.app-btn-icon` got cleaned up
in the previous commit but the cell-name icon and the chips were
still living under their own per-child overrides, the surrounding
markup drifted out of sync. Twelve overrides, twelve micro-snowflakes.
This commit replaces the parent-selector pattern with surface context
tokens: `.surface` declares `--surface-{bg, text, text-muted,
text-faint, divider, scrim-{2,5,8,10}, anchor, anchor-hover}` (light
defaults), `.surface--active` overrides those tokens, and every
child reads from `var(--surface-text-muted)` etc. — a single rule per
component.
Component changes:
* `.app-btn-icon`, `.asset-table` (thead/tbody/hover), `.asset-cell-
name__icon`, `.processing-pill`, `.empty-state`, `.schedule-window
__{primary,secondary,dot}`, `.schedule-window--{expired,disabled}
__primary` all read surface tokens. Their `.surface--active` parent-
selector siblings are deleted.
* Schedule-chip palette gets its own context-token layer
(`--chip-{neutral,day,all}-{bg,text,edge}`). Light surface uses
neutral grey + link purple + WCAG-AA green; dark surface flips
neutral to accent yellow and pumps the green wash strength.
`.schedule-chip*` rules are now ONE selector each, no parent override.
* Schedule-window live-state ring/fill is exposed as
`--window-live-{fill,ring}` so the live dot brightens to
`--color-success-bright` on dark without a parent override on the
rule itself.
The only `.surface--active .X` override that remains is
`.surface--active .app-check-input:not(:checked)` — that one is a
genuine surface-conditional behaviour (the light surface lets the
browser's native off-state render unchanged; the dark surface needs
an explicit fill because a transparent off-state vanishes against the
gradient). It's not contrast-flipping, so it doesn't fit the context-
token shape.
Token defaults sit on `.surface` (which the inactive section uses
directly) so they apply globally; `.surface--active` only overrides
what changes. Every surface-aware component now ships as a single
rule, and the shape of "this component on a dark surface" is "set
your local --surface-* tokens to the dark values" instead of "write
twelve more rules with parent selectors".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dev): make uvicorn --reload pick up template + CSS changes
uvicorn's --reload defaults to watching *.py only. Editing
_asset_row.html (or _styles.scss → built anthias.css) on the host
propagated through the bind mount, but the worker process held a
stale compiled-template object in memory until something Python-side
triggered a restart. End result: the running dev server kept
rendering the pre-rename markup hours after the source had been
fixed, and the icons in the active vs inactive rows looked different
because the old `app-btn-outline-{light,dark}` classes were still
emitted but only one of those SCSS rules still existed.
Add --reload-include "*.html" and --reload-include "*.css" so
template + built-CSS edits fire the same watcher that .py edits do.
SCSS sources still need a separate `bun run dev` (or a one-shot
`bun run build:css`) to compile into anthias.css — but once the CSS
output changes, uvicorn now sees it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(home): vanilla pointer drag-reorder + Bun minify-identifiers Alpine fix
SortableJS kept silently failing on <tr> elements — drag handle showed
the grab cursor but the row never moved, even with forceFallback=true.
Replaced with ~60 lines of vanilla pointer events in home.ts: pointerdown
captures the row, pointermove finds the row under the cursor and
swaps via insertBefore, pointerup POSTs the new id sequence. Removed
sortablejs dep + import. Bundle drops from 201 KB to 163 KB.
Separately: Bun's --production flag enables --minify-identifiers, which
renames Alpine.js's runtime expression-evaluator vars and silently
breaks @click="openAdd()" — the assigned value lands on a Set leaked
from another module instead of state.mode. Switched build:vendor /
build:home to --minify-whitespace --minify-syntax (~half the bundle
size, identifiers untouched).
Also added a load-event fallback alongside the existing DCL listener
in vendor.ts / home.ts so a dynamically-injected bundle (readyState
already 'interactive', DCL already fired) still boots — addresses
Copilot review comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(brand): regen favicons from marketing site logo
The shipped favicons were the legacy Screenly OSE artwork. Regenerated
the full set (favicon.ico multi-size 16/32/48, favicon-{16,32,96,128,
196}, apple-touch-icon-{57,60,72,76,114,120,144,152}, mstile-{70,144,
150,310}, mstile-310x150 wide-tile) from website/assets/images/logo.svg
via bin/build_favicons.sh (rsvg-convert + ImageMagick + icotool).
The script renders at the source's natural aspect ratio (50x48) and
composites onto a square transparent canvas so the asymmetric viewBox
doesn't get stretched, which is what would happen feeding -w/-h to
rsvg-convert directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(integration): migrate Selenium suite to Playwright + capture failure artifacts
Replaces the splinter+selenium integration suite (mostly @pytest.mark.
skip stubs marked "fixme" / "migrate to React-based tests") with a
Playwright Python suite covering 24 browser-driven scenarios:
- Smoke / regression (page loads, no console errors on production
bundle, Alpine @click fires — explicit guard for the Bun
minify-identifiers regression)
- Asset-table rendering (empty state, drag handle on/off by section,
humanised duration)
- Add asset (URL form, image upload, video upload, two-uploads-in-one-
modal-session)
- Edit / preview / delete modals (state assertions via Alpine.$data,
edit duration persists, delete removes from DB)
- Toggle enable/disable round-trip
- Drag-reorder (full DOM reorder + play_order DB persistence)
- Settings render + form save round-trip, system info, skip-next
Playwright auto-waits replace the custom _wait_for / sleep-and-retry
helpers from the Selenium version. Suite is ~1.85x faster end-to-end
(~14s vs ~26s on Selenium for the same coverage) and stable across
multiple consecutive runs.
Test image swap: docker/Dockerfile.test.j2 drops the chromedriver +
chrome-for-testing zip downloads in favour of `playwright install
--with-deps chromium` (Playwright manages the Chromium revision and
the apt deps it needs). PLAYWRIGHT_BROWSERS_PATH is pinned to /opt/
playwright so the path is stable under the anthias-data volume mount.
DJANGO_ALLOW_ASYNC_UNSAFE=1 is set in tests/conftest.py — Playwright's
sync API spins up an asyncio loop to talk to Chromium over CDP, which
Django detects and refuses sync ORM calls against. Documented as the
canonical fix in pytest-playwright.
A pytest_runtest_makereport hook in tests/conftest.py captures a
full-page screenshot + rendered HTML on integration test failures
under test-artifacts/. .github/workflows/test-runner.yml uploads the
bundle via actions/upload-artifact@v7 (if: failure()) so failed CI
runs link the artifacts from the bottom of the PR's Checks tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(urls): drop trailing slash on login route for consistency
Every other anthias_app route is declared without a trailing slash
(system-info, settings, assets/...); login/ was the lone outlier.
Django's APPEND_SLASH only ADDS slashes to slashless requests, so the
inconsistency meant requests to /login (sans slash) would 404 instead
of redirecting. Standardised on slashless to match the majority.
Addresses Copilot review comment on the PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): satisfy mypy + ruff on the Playwright migration
- Drop the unused `json` import from conftest.py left over from the
Selenium console-log artifact (Playwright captures pageerror /
console events in-test, no JSON-dump on the way out).
- Type the pluggy hookwrapper outcome as Any. _pytest's stubs declare
the generator yield as None even though hookwrapper=True makes pluggy
send the call's Result back in.
- Switch the hook return type from Iterator to Generator so the
three-arg form documents the recv-type.
- Annotate the seed-asset dicts as dict[str, Any] so subscript access
doesn't read as `object` (mypy's heterogeneous-literal default) when
passed into Playwright locator helpers / _drag_handle_to_row.
- Type _wait_db's predicate as Callable[[], bool].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(tests): apply ruff format
Single/double quote normalisation on the multi-line JS evaluate()
strings inside test_app.py and the playwright fixture in conftest.py.
No functional change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): address remaining Copilot feedback
- urls.py: switch every route to a trailing slash. The earlier
slashless-everywhere fix addressed one Copilot finding (consistency)
but introduced another (`/login/` bookmarks 404'd). Trailing slashes
let Django's APPEND_SLASH redirect the slashless variant for free, so
both `/settings` and `/settings/` work — the inverse isn't true.
Updated the three JS-built form actions / hx-post URLs in home.html +
_asset_modal.html to match (POST → 302 from APPEND_SLASH would error
in Django 1.11+).
- tools/image_builder/utils.py: drop `wget` from the test apt list.
Comment claimed prepare_test_environment.sh needed it for asset
copies, but that script only uses `cp`; the base image already
installs `curl` for keyring fetches, so the test image inherits all
the network tooling it needs.
- docker/Dockerfile.test.j2: guard the apt-get install block so an
empty apt_dependencies list doesn't render `apt-get -y install` with
no packages.
- Playwright SETTINGS_URL / SYSTEM_INFO_URL constants pick up the new
trailing slashes — page.goto() would still follow the 301 either
way, but matching the route avoids a needless redirect on every test.
Suite: 24 passed in 13.89s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): align splash-page URL with the trailing-slash convention
The previous commit moved every app route to a trailing slash (so
APPEND_SLASH redirects from the slashless variant for free), but the
splash-page tests still issued bare `/splash-page` requests against
the test client — APPEND_SLASH redirects, so they got a 301 instead
of the rendered template body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docker): copy templates into bun-builder so Tailwind scan finds them
Tailwind v4's @source directive in src/anthias_server/app/static/src/
tailwind.css points at `../../templates/**/*.html`. The production
bun-builder stage copied package.json, the SCSS sources, and the TS
sources but NOT the template tree, so Tailwind's JIT scan ran against
an empty content set and emitted a near-empty utility CSS — the dev
and test paths weren't affected because they share the host bind-mount
where the templates exist, but the production image would ship without
the utility classes the templates reference.
Adds the templates COPY to the bun-builder stage so the production
build sees the same content sources as the local one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(integration): trace-on-failure via pytest-playwright
Drops the hand-rolled Playwright fixtures + the
pytest_runtest_makereport screenshot/HTML hook in conftest.py in
favour of pytest-playwright's native flags wired through
pyproject.toml addopts:
--browser chromium
--tracing retain-on-failure
--screenshot only-on-failure
--output test-artifacts
Per-test trace zips drop to test-artifacts/<test-id>/trace.zip on
failure (and nothing for green tests); `playwright show-trace
trace.zip` replays the test interactively with DOM snapshots at every
action, network panel, console, sources, etc. — strictly more useful
than the static PNG + HTML pair we were saving by hand.
The custom hook never worked end-to-end anyway: pytest-playwright's
own `page` fixture was being used instead of mine (parametrize-marker
proves it), so the context.tracing.start in my fixture wasn't running
and the hook's tracing.stop raised "Must start tracing before
stopping". Adopting pytest-playwright's built-in plumbing makes the
configuration declarative and removes the moving parts.
Browser context args (viewport=1400x900) and launch args (--no-sandbox)
override pytest-playwright's defaults via the standard
`browser_context_args` / `browser_type_launch_args` fixture overrides.
DEFAULT_TIMEOUT_MS is applied per-page through an autouse fixture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(urls): correct the APPEND_SLASH status-code in the trailing-slash comment
Said "302-redirects"; Django's CommonMiddleware actually issues 301
for GET and 308 (method-preserving) for non-GET. Updated the comment
to match what curl actually returns.
Addresses Copilot review comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(css): toast readability — white surface card, not body-bg-on-body-bg
The toast was using `background: var(--color-text)` (#1f002a). The
body background is anthias-purple-1 (#1f0029) — one hex digit off.
Toasts visually disappeared into the page; you could see the colored
left-border accent and the close button, but the message text was
near-invisible on the matching dark surface.
Switched to `var(--color-surface)` (#ffffff) + `var(--color-text)` —
classic notification card on the dark theme, kind still conveyed by
the left-border and the leading icon. Close button colors match the
new contrast direction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(version): CalVer release label + relocate "Update available" off the navbar
Replaces the prior `vanilla-django@08c26f3` label that read like a
half-internal git pointer with a real release identifier, sourced
from pyproject.toml's [project].version via importlib.metadata so the
CI release bumper only needs to touch one place.
Bumps version to **2026.5.0** (CalVer, YYYY.M.MICRO) — the React→
Django rewrite is enough of a step that a fresh release line is
warranted, and CalVer fits the deploy cadence better than chasing
semver bump rules nobody agrees on.
Display layout on System Info:
ANTHIAS VERSION
v2026.5.0
(44d9b3b, vanilla-django)
[Update available]
The big calver string is the headline; the git short-hash + branch
sit underneath in a smaller muted font (operators don't need them
shouting alongside the release number, but they're useful for
support). Branch is suppressed on master/main to cut noise on
release builds. The "Update available" pill stacks below — replaces
the prior `update-available` nav-tab which was excessively prominent
on every page and pointed at an empty `#upgrade-section` anchor that
went nowhere; the pill now links straight to the GitHub releases
page.
Wiring:
- lib/diagnostics.py: get_anthias_release()/_head()/_meta()/_version().
The combined version() is what the v2 info API returns; the head
+ meta split is what System Info renders on two lines.
- app/page_context.py + app/templates/system_info.html: thread the
three fields through.
- app/views.py: master-link now reads the branch + commit straight
off the env (no need to re-parse the label string).
- api/tests/test_info_endpoints.py: pull the expected version from
importlib.metadata so the test moves with future bumps without a
second edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): three more Copilot findings
- celery_tasks.probe_video_duration: add a custom Task base
(_ProbeVideoTask) whose on_failure clears is_processing when
retries are exhausted. Previously a permanently-failing ffprobe
(e.g. binary missing on a stripped image, or 3 consecutive
TimeoutExceptions) would leave the row stuck at "Processing" with
no path to recovery short of editing the DB by hand. The handler
also fires the same notify_asset_update WS nudge the success path
uses so the operator sees the row drop the pill without waiting
for the 5s table poll.
- views.assets_update: stop forcing duration=0 for video assets on
edit. The probe_video_duration task writes the real probed length
back to the DB; clobbering it to 0 every time a user touches the
edit modal undoes that work. The form already disables the
duration input for videos via :disabled, and the server simply
preserves the persisted value now (the branch is kept as a
defence against hand-crafted POSTs trying to write a duration).
- test-runner.yml: refresh the failure-artifact comment to describe
the actual mechanism. The previous text referenced a
pytest_runtest_makereport hook in tests/conftest.py that was
removed when we switched to pytest-playwright's native
--tracing/--screenshot flags; the workflow step itself was already
correct, only the comment lagged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(version): pyproject.toml fallback for environments without an installed wheel
importlib.metadata.version('anthias') raises PackageNotFoundError in
every standard Anthias environment — the production / test / host
installs all run `uv sync --no-install-project` (see
docker/uv-builder.j2, docker/Dockerfile.{server,test,viewer},
bin/install.sh). That flag installs the project's deps but not the
project itself, so the previous helper returned an empty string and
the System Info version label silently dropped to "(03490087,
vanilla-django)" with no CalVer head — defeating the whole point of
the new label.
get_anthias_release() now resolves in two steps:
1. importlib.metadata.version (works for editable installs / wheels)
2. Direct tomllib read of the repo-root pyproject.toml (works for
--no-install-project deployments)
Result is cached on the function attribute so per-request System Info
renders and the v2 info API don't re-open the file.
The unit test that pinned the expected version label now derives it
from the same helper rather than calling importlib.metadata at module
import time — that import-time call would have crashed the test
collection in CI (since the test container also runs without the
project installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): three minor Copilot findings
- _asset_table.html: rename the inactive-table column from "Active"
to "Enabled" to match the enabled table header and the underlying
/assets_toggle/ endpoint (which flips is_enabled). The two tables
showed different labels for the same checkbox.
- login.html: render Django flash messages as a <ul>/<li> list
rather than concatenated inline text, so two simultaneous errors
don't smash into one another.
- diagnostics.get_anthias_version_head(): docstring still claimed
the head was empty when the package wasn't installed; with the
pyproject.toml fallback added in 4697cfd5 that's no longer the
failure mode. Updated to describe what actually returns ''.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): three Copilot findings — a11y, rel, scoped async-unsafe
- _navbar.html: add aria-controls="navbarNav" on the mobile toggle
so screen readers announce what the button expands/collapses; the
matching id="navbarNav" was already on the collapsible region.
- _stat_card.html + system_info.html: extend `rel="noopener"` to
`rel="noopener noreferrer"` on every external `target="_blank"`
link so the Referer header isn't leaked to the destination.
- conftest.py: scope DJANGO_ALLOW_ASYNC_UNSAFE=1 to runs that
actually include integration tests (the only ones that need it
for Playwright's sync API). A pytest_collection_modifyitems hook
sets the env var when at least one integration item is collected
— runs early enough that pytest-django's DB setup (which itself
hits the async-safety check) sees the flag, while leaving unit-
only runs (`pytest -m "not integration"`) untouched so an
accidental ORM-from-event-loop in a unit test still raises.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(css): drop dead Bootstrap class on stat-card link, scope underline rule
`text-decoration-none` was a Bootstrap utility — it's not defined in
the post-React SCSS, so the stat-card value-link was rendering with
the browser's default underline despite the markup intent. Two paths
to fix: a Tailwind utility (`no-underline`) on every site that
renders a stat-card link, or a single component-scoped rule. Going
with the latter — every link inside `.stat-card__value` now picks
up `text-decoration: none` automatically (with hover-underline),
matching the existing `.stat-card__meta a` pattern, so future
stat-card links get the right styling without remembering to add a
utility class.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(build): upgrade to Debian Trixie + Python 3.13, drop Balena base images
Move every container off `balenalib/raspberrypi*-debian:bookworm` (Balena
hasn't published a `trixie` tag on any of those repos and last refreshed
in May 2025) onto vanilla `debian:trixie`. Pi 1 and 32-bit Pi 4 are
retired at the same time — Pi 1 has no `linux/arm/v6` variant in upstream
Debian, and Pi 4 always has a 64-bit path that avoids the messy
`libssl1.1` / `libgst-dev` / `libsqlite0-dev` Qt 5 deps. Surviving build
matrix: pi2, pi3, pi4-64, pi5, x86.
For the surviving 32-bit boards (pi2, pi3) the legacy Broadcom userland
(libraspberrypi0 → /opt/vc/lib/{libbcm_host,libmmal,libvchiq_arm}) is
still required at runtime by the Qt 5 webview. Trixie's
archive.raspberrypi.org/debian/main no longer ships those packages
(replaced by raspi-utils + libdtovl0, which actively break
libraspberrypi0), so Dockerfile.base.j2 conditionally writes Deb822
.sources entries pointing at archive.raspberrypi.org/debian trixie main
and archive.raspbian.org/raspbian trixie firmware (where the legacy
Raspbian builds of libraspberrypi0 still live, armhf only). The
.deb-form raspberrypi-archive-keyring + raspbian-archive-keyring packages
are extracted with `dpkg-deb -x` (their bundled keys carry trixie-policy-
compliant binding signatures, unlike the standalone .public.key files
which fail Sequoia/sqv's post-2026-02-01 SHA-1 ban). Architectures: armhf
on each .sources file keeps apt from querying the Pi mirrors for the
arm64 / x86 builds.
Trixie package renames also fixed: libgles2-mesa → libgles2,
ttf-wqy-zenhei → fonts-wqy-zenhei, libpng16-16 → libpng16-16t64 (time64
transition; armhf has no `Provides:` fallback like amd64 does), and the
Qt 5-only libgst-dev / libsqlite0-dev / libsrtp0-dev / libssl1.1 are
dropped (libgstreamer1.0-dev, libsqlite3-dev, libsrtp2-dev, libssl3 take
their place — first added explicitly, the rest already in the main
list). The transitional `git-core` is gone in trixie; `git` covers it.
Python 3.13 (Trixie's default) replaces the 3.11 pin everywhere:
pyproject.toml requires-python and mypy python_version, ruff.toml
target-version, .python-version, uv.lock (regenerated; only diff is
async-timeout dropped — its marker was python<3.11), uv-builder.j2's
UV_PYTHON, Dockerfile.dev's FROM, bin/install.sh's host check, and every
CI workflow's setup-python pin.
Cleanup that falls out: drop the cache_scope / device_type / version_suffix
`pi4 + arm64 → pi4-64` re-mapping (board is now self-identifying), drop
the `c_rehash` workaround in Dockerfile.base.j2 (specific to a Balena
curl bug, not vanilla Debian), drop the dead arm/v6 + arm/v8 branches in
uv-builder.j2 (only arm/v7 remains as the 32-bit ARM target), retire the
old build_qt5.sh `pi1`/`pi4` branches, and delete docker/Dockerfile.celery
(left behind from the celery-image removal in 5e00c8ba).
Out-of-band prereq before merging anything that depends on a viewer
build: cut a new `WebView-v*` release with
webview-{ver}-trixie-{board}.tar.gz (and qt5-5.15.14-trixie-{pi2,pi3}.tar.gz)
for the surviving boards, then bump WEBVIEW_VERSION in
tools/image_builder/utils.py:143. The webview Dockerfiles already point
at debian:trixie, so triggering build-webview.yaml on the new tag should
produce the artifacts.
Verification (proven via real `docker buildx --platform=...` runs):
- x86 server image: full build, runs Debian 13.4 + Python 3.13.5; Django
5.2.13, channels 4.3.1, uvicorn 0.32.1 all import.
- x86 redis image: Redis 8.0.2 on trixie.
- pi3 (linux/arm/v7 under qemu) server image: full build green — Pi
apt sources bootstrap works, libraspberrypi0 installs from
raspbian/firmware/armhf with /opt/vc/lib/* present.
- pi3 (linux/arm/v7 under qemu) viewer image: 147s apt layer green
end-to-end through libpulse-dev, libgstreamer1.0-dev, libsdl2-dev,
libpng16-16t64, etc.; build proceeds through uv-builder + main stages
and stops only at the WebView qt5 tarball fetch (the trixie artifacts
haven't been cut yet — that's the prereq above).
- ruff check + ruff format --check on tools/image_builder/: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): replace distutils.strtobool (3.12+ removal); satisfy SC2129
Two CI failures from the Trixie/3.13 bump fall out of stdlib & lint:
- `lib/utils.py:8` imported `from distutils.util import strtobool`,
which is gone in Python 3.12+. mypy on 3.13 flagged it as
import-not-found. Inline the original truthy/falsy table directly in
`string_to_bool` so every caller keeps accepting the same
y/yes/t/true/on/1 / n/no/f/false/off/0 set.
- actionlint/shellcheck SC2129 on `.github/workflows/docker-build.yaml`
in the `Set Docker tag` step I added — three sequential
`>> "$GITHUB_ENV"` redirects collapse into one `{ ...; } >> $GITHUB_ENV`
block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): HTTPS + SHA256-pin Pi keyring fetch; nuke libcec-dev typo
Address Copilot's review on PR 2779.
- docker/Dockerfile.base.j2 + webview/Dockerfile: switch the Pi/Raspbian
keyring downloads (and the resulting Deb822 `URIs:` for both apt
archives) from `http://` to `https://`. Both archives serve TLS
cleanly today (verified with curl --proto '=https' --tlsv1.2). The
keyring .deb is the trust anchor for everything fetched after it, so
the .deb hash is now also pinned via `sha256sum -c -` before
`dpkg-deb -x` extracts it — TLS alone wouldn't catch an upstream
archive-side swap. Hashes match the
raspberrypi-archive-keyring_2025.1+rpt1_all.deb and
raspbian-archive-keyring_20120528.4_all.deb files served at the time
this commit lands; bumping either filename is the signal to refresh
the pin too.
- tools/image_builder/__main__.py: trim the trailing space from
`'libcec-dev '` in `base_apt_dependencies`. apt is forgiving about it
but it produces extra whitespace in the rendered Dockerfile and is
easy to miss in diffs.
Verified by re-running the keyring bootstrap end-to-end on a fresh
debian:trixie linux/arm/v7 container: both .debs pass sha256sum -c, apt
update fetches over HTTPS, and libraspberrypi0 installs from
archive.raspbian.org/raspbian trixie/firmware as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sonar): declare USER root explicitly in webview/Dockerfile builder
SonarCloud's docker:S6471 hotspot was already flagging this file on
master (the implicit-root warning lives on every `FROM debian:*` line
without a `USER` directive); my Trixie change shifted the original line
107 to 131 and Sonar re-emitted it as a "new in PR" finding. Resolve
with the rule's recommended escape hatch — declare the user explicitly,
which converts the implicit-default into an acknowledged choice and
silences the rule.
Both stages stay on `USER root`: the builder stage's `dpkg-deb -x` /
`dpkg --purge libraspberrypi-dev` and the runtime stage's writes to
/sysroot, /opt/vc, /root/.pyenv, /usr/local/bin all require root. This
image is a CI-local Qt 5 cross-compile builder that produces the
WebView tarball as a release artifact — it is never deployed, so the
"don't run as root" guidance behind S6471 doesn't apply in the way it
would for a published runtime image.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: fix two Copilot-flagged comment inaccuracies
- Dockerfile.base.j2: comment said libraspberrypi0 comes from
archive.raspbian.org's `rpi` component, but the Deb822 source
below correctly declares `Components: firmware`. Verified via
Packages.gz on archive.raspbian.org/dists/trixie/firmware/
binary-armhf — that's the only component shipping
libraspberrypi0 on trixie/armhf. Comment now matches reality.
- image_builder/utils.py: Qt 5 branch comment claimed the modern
equivalents (libgstreamer1.0-dev, libsqlite3-dev, libsrtp2-dev)
for the dropped trixie packages were "pulled by the main viewer
apt list above". libsqlite3-dev / libsrtp2-dev are indeed in
that list, but libgstreamer1.0-dev is Qt 5-only and is added by
the extend() call right below — corrected the comment to point
there instead.
Both are pure comment changes; behavior unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(webview): adopt registry-cache backend, mirror docker-build.yaml
Both Docker-build steps in build-webview.yaml had ad-hoc caching that
left the bulk of layer state on the floor:
* `build-docker-image` (Pi 1-4 / Qt 5 builder) used
`--cache-from screenly/ose-qt-builder:latest`, which is the
image-tag-as-cache trick — only reuses the final manifest, never the
apt-install + Qt cross-build intermediate layers, and silently no-ops
the first time after a Dockerfile reorder invalidates the tag.
* `compile-webview-part-2` (Qt 6 / pi5+pi4-64+x86) shipped with
`docker compose build` and zero cache config, so every PR rebuilt the
per-board Qt 6 builder image cold.
Switch both to BuildKit's registry cache backend, identical pattern to
docker-build.yaml's `buildx` job: cache pushed to
`ghcr.io/screenly/anthias-webview-qt5-builder:buildcache` (Qt 5) and
`ghcr.io/screenly/anthias-webview-qt6-builder:buildcache-<board>`
(Qt 6, scoped per-board because the three Dockerfiles share almost
nothing). `mode=max,image-manifest=true` because GHCR rejects the
legacy standalone-cache manifest format on `ghcr.io/screenly/*`, same
constraint that bit the main workflow.
Auth-side details:
* Both jobs gain `permissions: { contents: read, packages: write }`,
scoped per-job so other jobs don't inherit GHCR push.
* New "Login to GitHub Container Registry" step on each, gated on
`event_name != 'pull_request'`. Fork PRs hand out a read-only
GITHUB_TOKEN — cache-to would 401 mid-build — so `cache-to` is
pushed-only-on-push, while `cache-from` runs unconditionally and
warm-starts PRs off the latest master cache once the buildcache
package is flipped public (same convention as anthias-server etc.).
Qt 6 build step had to switch from `docker compose build` to
`docker buildx bake -f docker-compose.yml --load --set <target>.cache-*`
because compose's YAML can't carry env-var-conditional cache_to without
emitting an empty list entry that buildx rejects. To keep the
subsequent `docker compose run` happy, the three Qt 6 services in
webview/docker-compose.yml gain explicit `image:` tags
(`webview-builder-{x86,pi5,pi4-64}`) so bake's `--load` puts the image
under a name compose looks up by tag rather than rebuilding it.
The Qt 5 job's old `Set buildx arguments` step (which assembled a
quoted string in $GITHUB_OUTPUT) is gone — build args inline in the
final `docker buildx build` invocation now, no GITHUB_OUTPUT
round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(webview): trixie apt rename + adopt GHCR for Qt 5 builder image
Two intertwined fixes in webview/Dockerfile + the workflow that
publishes/consumes its image. CI never caught either because the
Docker-build step in build-webview.yaml is gated to push events, so
this Trixie-targeted Dockerfile has not yet built on master.
apt: drop the renamed-on-Trixie packages
Stage 1 (armhf sysroot, archive.raspbian.org + deb.debian.org):
* libgst-dev → gone, libgstreamer1.0-dev (already listed)
replaces it
* libsqlite0-dev → gone, libsqlite3-dev (already listed) replaces
* libsrtp0-dev → gone in deb.debian.org/main; libsrtp2-dev
(already listed) is the trixie default
* libpng16-16 → renamed libpng16-16t64 under the time_t
transition; old name is fully gone
Stage 2 (amd64 runtime/builder, deb.debian.org):
* libpng16-16 → libpng16-16t64
Verified by GET on
{deb.debian.org,archive.raspbian.org,archive.raspberrypi.org}/dists/
trixie/main/binary-{armhf,amd64}/Packages.gz: every removed name is
MISSING, every replacement is FOUND. Without this fix the first
master push would die in stage 1's apt-get install.
GHCR migration: screenly/ose-qt-builder → ghcr.io/screenly/anthias-...
Move the published Qt 5 builder image off Docker Hub and into the
same GHCR namespace as the rest of the anthias-* artifacts. New ref
is ghcr.io/screenly/anthias-webview-qt5-builder:latest (image) +
:buildcache (cache, set up in eadd83d1) — one repo, two tags, same
auth flow.
* build-docker-image: drop the Docker Hub login step, retag the
push target to the GHCR ref via an IMAGE_REF env var.
* compile-webview-part-1: declare permissions: { contents: read,
packages: read }, add the GHCR login (gated on non-PR), point the
`docker run` at the GHCR ref.
Migration window: the GHCR package is created private on first push
and needs to be flipped public so fork-PR runners (no GHCR auth) can
pull. Same one-shot operational step as the existing anthias-*
packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: fix second `rpi` vs `firmware` comment in image_builder
5e289198 fixed the same stale wording in docker/Dockerfile.base.j2
but missed the analogous comment block in
tools/image_builder/__main__.py — flagged by Copilot's second-pass
review.
The comment was a self-referential pointer to the apt-source bootstrap
in Dockerfile.base.j2, claiming libraspberrypi0 lives in
archive.raspbian.org's `rpi` component when in fact it ships under
`firmware` on trixie/armhf (the Deb822 entry written by the same code
correctly says `Components: firmware`). Reword to match reality and
add a note that this was verified against Packages.gz so a future
maintainer doesn't redo the lookup.
Pure comment change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(webview): build Qt 5 builder inline, drop the publish job
a9b9522d migrated the Qt 5 builder image from
screenly/ose-qt-builder:latest (Docker Hub) to
ghcr.io/screenly/anthias-webview-qt5-builder:latest (GHCR), but the
publish step (`build-docker-image`) is gated to push events. On PR
runs the GHCR image therefore never exists, and the consumer
(compile-webview-part-1) blew up trying to `docker pull` it:
Error response from daemon: Head ...manifests/latest: denied
The image is a CI-internal build artifact — only consumed by the next
step in the same workflow, never deployed, never pulled by any
external user. Publishing it as a registry artifact is just inventory
the workflow has to manage. So instead:
* Delete the `build-docker-image` job entirely.
* Move the build into compile-webview-part-1 as a step that runs on
every event (PR + push), produces the image with `--load`, and tags
it locally as `webview-qt5-builder:latest` for the subsequent
`docker run` to consume.
* Keep the registry-cache backend on
ghcr.io/screenly/anthias-webview-qt5-builder:buildcache so cold
builds remain fast: `cache-from` always, `cache-to` only on
push events (fork PRs have a read-only GITHUB_TOKEN and would 401
on cache write — same gating as docker-build.yaml).
Side benefits:
* Removes the chicken-and-egg of "PR can't run because GHCR image
doesn't exist; GHCR image only gets pushed on master".
* Drops the cross-job artifact handoff (and the auth dance to read
the published image), so fork PRs work without any GHCR public-flip
step.
* Two matrix runners (pi2, pi3) build in parallel from the same
registry cache — second-onward runs hit cache for everything once
the first push to master warms it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(webview): drop registry cache plumbing, simpler is fine
eadd83d1 added BuildKit registry-cache backends to both webview build
steps; 3dc0a04a kept them when moving the Qt 5 build inline. The
caching is purely a speed optimization — none of it is load-bearing
for correctness, fork PRs can't write cache anyway, and the per-job
GHCR login + permissions block is real surface area in exchange for
saving a few minutes on warm runs.
Strip it all back out:
* compile-webview-part-1: drop the GHCR login + `permissions:
packages: write`. The "Build Qt 5 builder image" step is a plain
`docker buildx build --load` now — same inline-build architecture
from 3dc0a04a, just no `--cache-from` / `--cache-to`.
* compile-webview-part-2: drop the GHCR login + `permissions:`,
revert "Build Docker Image" from `docker buildx bake -f
docker-compose.yml --load --set <target>.cache-*` back to plain
`docker compose build`. COMPOSE_BAKE=true stays so compose still
uses the bake builder under the hood — no behavior change beyond
removing the cache flags.
webview/docker-compose.yml's explicit `image:` tags from eadd83d1
stay in place: they happen to match the compose default
(`<project>-<service>`) so plain `docker compose build` produces
the same image names the previous bake invocation did, and `compose
run` finds them either way.
Cold pi2/pi3 builds will be ~9 min on every run instead of getting
fast on warm runs. That's fine for now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "ci(webview): drop registry cache plumbing, simpler is fine"
This reverts commit 1284a5ebd9.
* chore(webview): add bin/rebuild_qt5_toolchain.sh helper
build_webview.yaml's pi2/pi3 jobs fetch a pre-built Qt 5
cross-compile toolchain from a `WebView-v*` GitHub release
(webview/build_webview_with_qt5.sh:21 pins QT5_TOOLCHAIN_TAG to
WebView-v0.3.5). The trixie-targeted tarballs
qt5-5.15.14-trixie-{pi2,pi3}.tar.gz don't exist on any release yet —
the original Trixie commit (65311092) called out cutting them as an
out-of-band prereq. Until they exist, pi2/pi3 CI fails with
`sha256sum: no properly formatted checksum lines found` because curl
falls back to a 404 HTML page on the missing .sha256 URL.
This helper produces those tarballs locally:
* Builds webview/Dockerfile (the same image CI's
compile-webview-part-1 builds inline) once, --load only.
* Runs build_qt5.sh inside that image once per requested board (pi2
by default, pi3 by default, or whichever boards are passed on the
command line). Sequential because Qt 5 + QtWebEngine peaks at ~16
GB RAM per build and the Linaro cross-compile toolchain extracted
into .qt5-toolchain-build/src/ is shared between boards.
* Drops outputs at .qt5-toolchain-build/release/qt5-5.15.14-trixie-
{pi2,pi3}.tar.gz (+ .sha256), ready to upload via
`gh release upload`.
Idempotent: existing release/<tarball>.tar.gz short-circuits the run
for that board. ccache state is preserved across runs at
.qt5-toolchain-build/ccache/. BUILD_WEBVIEW=0 in the env skips the
bonus webview-* tarball that build_qt5.sh otherwise produces (the
Dockerfile defaults BUILD_WEBVIEW=1 so the helper inherits that
default for parity with the previous CI flow).
The .qt5-toolchain-build/ directory is intentionally hidden + at
the repo root rather than ~/tmp so it's discoverable to whoever
runs this next without grep'ing scrollback for a path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(webview): make Qt 5 cross-build Dockerfile produce working tarballs on trixie
The webview/Dockerfile in this repo wasn't actually exercised end-to-end
before — master CI uses screenly/ose-qt-builder from Docker Hub, and the
inline-build path introduced for trixie only ran build_webview_with_qt5.sh
(which downloads prebuilt qt5 toolchains). Rebuilding those toolchains for
trixie surfaced four real bugs:
* python interpreter never on PATH for non-interactive shells. The pyenv
block only wired itself up via ~/.bashrc, which doesn't load when the
rebuild script does `docker run /webview/build_qt5.sh`. Replace pyenv
with apt-pinned python2.7 from archive.debian.org bullseye (trixie main
dropped py2 entirely; bullseye archive still ships 2.7.18). Pin only
python2.7 + its libpython runtime libs, leave everything else on trixie.
Symlink /usr/local/bin/python -> python2.7 so QtWebEngine's
`/usr/bin/env python` resolves.
* QtWebEngine configure silently rejected fontconfig because the sysroot
was missing /usr/share/pkgconfig/bzip2.pc. The Dockerfile only copies
/lib, /usr/include, /usr/lib from the builder stage; on trixie's
libbz2-dev the .pc file lives in /usr/share/pkgconfig (arch-indep),
so freetype2.pc's `Requires.private: bzip2` failed to resolve, which
cascaded into fontconfig: no, which silently dropped QtWebEngine from
the build. Add the missing COPY.
* Several QtWebEngine-required dev libs missing from the sysroot
(libharfbuzz-dev, liblcms2-dev, libre2-dev, libxml2-dev). Same libs
also need to be installed on the *host* runtime stage because chromium
pdfium evaluates `harfbuzz_from_pkgconfig` in the host toolchain
context, where Qt's host_pkg_config="/usr/bin/pkg-config" drops the
sysroot args from chromium's pkg_config template.
* `make -j$(nproc)+2` OOMs on >8-core hosts. cc1plus under qemu-arm
peaks at ~3-4 GB during chromium compile, so the default formula
needs ~50 GB on a 16-core box. Make MAKE_CORES env-overridable in
build_qt5.sh and have rebuild_qt5_toolchain.sh cap at min(nproc, 8).
Also: -webengine-proprietary-codecs in the configure args so the
resulting QtWebEngine supports H.264/AAC/MP3 (matches what Debian
qt6-webengine ships).
Verified on a 16-core/22GB+32GB-swap host: produces
qt5-5.15.14-trixie-{pi2,pi3}.tar.gz (88M, 98M) with 251 webengine entries
each, plus the matching webview-*.tar.gz apps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(webview): bump QT5_TOOLCHAIN_TAG to WebView-v2026.04.1
Trixie qt5-5.15.14-trixie-{pi2,pi3} toolchain tarballs are published on
the new WebView-v2026.04.1 release; the previous WebView-v0.3.5 only
ships the bookworm tarballs and is now unreachable for trixie pi2/pi3 CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(webview): refresh stale tag reference in rebuild_qt5_toolchain.sh hint
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): pass full SHA for GIT_HASH; keep short SHA only in GIT_SHORT_HASH
Both `.github/workflows/build-webview.yaml` and `bin/rebuild_qt5_toolchain.sh`
were populating the GIT_HASH build arg with the *short* hash, making
GIT_HASH and GIT_SHORT_HASH identical and stripping the unambiguous
SHA needed by `lib/diagnostics.py:os.getenv('GIT_HASH')` for downstream
traceability. Pass `git rev-parse HEAD` for GIT_HASH and reserve
`--short HEAD` for GIT_SHORT_HASH (which is already what
`tools/image_builder/__main__.py` does for the main service images).
Caught in Copilot review of #2779.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docker): exclude Qt 5 toolchain build dir + caches from COPY
The viewer image's `COPY . /usr/src/app/` was slurping in 1.6 GB of
local Qt 5 cross-build state (`.qt5-toolchain-build/`) plus 69 MB of
`.mypy_cache/`, inflating every viewer/server image by ~1.7 GB even
though the build needs none of it. Add those plus `.ruff_cache`,
`.idea`, `.cursor`, `.claude`, `.cache`, and tighten the existing
`*.git` / `*.github` globs (which match files ending in `.git` /
`.github` but not the directories themselves on most matchers) to
the literal directory names.
Caught while validating the trixie 5-board matrix: x86 viewer was
6.28 GB and pi5 viewer 2.23 GB; both had the same 1.76 GB COPY layer
that's mostly `.qt5-toolchain-build/`. Fixed image should be ~5 MB
for COPY and ~1.5 GB for the viewer overall.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>