Skip to content

Replace React frontend with Django templates + HTMX/Alpine#2818

Open
vpetersson wants to merge 97 commits intomasterfrom
vanilla-django
Open

Replace React frontend with Django templates + HTMX/Alpine#2818
vpetersson wants to merge 97 commits intomasterfrom
vanilla-django

Conversation

@vpetersson
Copy link
Copy Markdown
Contributor

@vpetersson vpetersson commented May 3, 2026

Summary

  • Rebuilds every page as server-rendered Django templates with HTMX (network-driven swaps) and Alpine.js (modal/collapse state). The React/Redux SPA is gone.
  • Drag-to-reorder is vanilla pointer events in home.ts (~60 lines). SortableJS was ripped out after it kept silently failing on <tr> elements.
  • Moves templates/ and static/ into src/anthias_server/app/ so they ride with the Django app instead of living parallel to it.
  • All four nav routes (/, /system-info/, /integrations/, /settings/) hit Django views directly; the React catch-all and react.html shell are gone.
  • HTMX, Alpine, Flatpickr, Tabler Icons, Plus Jakarta Sans, and Tailwind v4 are vendored via Bun and bundled to static/dist/. Nothing depends on a runtime CDN — base.html no longer references fonts.googleapis.com.
  • /api/v2/* stays intact for third-party automation.
  • Favicons regenerated from the marketing site's canonical website/assets/images/logo.svg via bin/build_favicons.sh — no more Screenly OSE artwork.
  • All app routes carry a trailing slash so Django's APPEND_SLASH=True redirects the slashless variant for free (301 for GET, 308 method-preserving for POST/PUT) — both /login and /login/ work.

Architecture notes

  • page_context.py owns shared per-page context (navbar, system_info, integrations, device_settings, assets). Both the new template views and the existing DRF API views call the same primitives — no HTTP round-trip from server to itself.
  • Settings save / backup / recover / reboot / shutdown are split into Django POST endpoints that mirror the API write paths inline. Plain HTML forms with CSRF tokens; HTMX is opt-in for partial swaps where it improves UX.
  • Asset list refresh is a 5-second HTMX poll plus a refresh-assets body event every write endpoint triggers, and a Channels WebSocket push on /ws (Celery → channel layer → browser) for instant fan-out on async writes.
  • Bundle minification gotcha: Bun's --production flag enables --minify-identifiers, which renames Alpine.js's runtime expression-evaluator vars and silently breaks @click handlers (mode = 'add' mysteriously becomes [object Set] at runtime — the bytes assigned land somewhere else entirely). Build scripts use --minify-whitespace --minify-syntax only; the bundle is still ~half the unminified size, and the production-bundle smoke test below catches any regression.
  • Production Tailwind build: the bun-builder Docker stage now copies templates/ in alongside the SCSS + TS sources so Tailwind v4's @source JIT scan sees them — without that, the production image would ship a near-empty utility CSS even though dev/test (which share the host bind-mount) work fine.

Browser test suite

A Playwright suite (chromium, sync API) covering 24 browser-driven scenarios:

  • Smoke / regression: page loads, no console errors on the production bundle, Alpine @click actually fires (regression guard for the minify-identifiers bug)
  • Asset table rendering: empty state, active row + drag handle, inactive row no-handle, humanised duration formatting (125 → 2m 5s)
  • Add asset: modal opens, URL form, image upload, video upload, two-uploads-in-one-session
  • Edit / preview / delete: modal-state checks, duration edit persists to DB, preview asset populated, delete confirm flow + actual DB removal
  • Toggle: enable + disable round-trip to is_enabled
  • Drag-reorder: full DOM-reorder + play_order DB persistence
  • Other pages: settings render, settings form save round-trip, system info, skip-next button

Failure artifacts are wired through pytest-playwright's native flags (--tracing retain-on-failure --screenshot only-on-failure --output test-artifacts). Each failed test drops a trace.zip and a full-page PNG into test-artifacts/<test-id>/; actions/upload-artifact@v7 attaches the bundle to the workflow run on failure, accessible from the PR's Checks tab. playwright show-trace trace.zip replays the test interactively with DOM snapshots at every action, network panel, console, and source-stack — strictly more useful than a static screenshot when a failure isn't obvious from one frame.

Test plan

  • uv run pytest -m "not integration" — passes (520 unit tests)
  • pytest -m integration --reuse-db (Playwright + Chromium) — 24 passed in ~14 s (was ~26 s on Selenium)
  • Failure-artifact path verified — trace.zip + test-failed-1.png produced on an intentional failure
  • Production-bundle Alpine handlers verified via the smoke regression test
  • ruff format --check ., ruff check ., mypy . — clean
  • All Copilot review threads addressed (or wontfix-with-explanation for the CodeQL paths-ignore scope, where the file-level ignore is a documented, conscious trade-off after query-filters failed in the advanced-security check)
  • Smoke-test on a real Raspberry Pi before merging
  • Manual asset-management QA (URI add, file upload, drag-reorder, toggle, delete) on the dev compose stack

SonarCloud

The quality gate fails only on new_security_hotspots_reviewed: 0 / 100 % — a manual review action surfaced by the file-upload / archive-extract / shell-out lines in views.py and _asset_modal.html. Reliability, security, maintainability, and duplication metrics are all green. Mark the hotspots as reviewed in the SonarCloud UI (or accept them in the merge) — there is no code change that clears that condition.

Follow-ups (intentionally not in this PR)

  • Cross-browser test runs (pytest-playwright auto-parametrises [chromium] [firefox] [webkit] — currently only chromium is installed in the test image).

🤖 Generated with Claude Code

@vpetersson vpetersson requested a review from a team as a code owner May 3, 2026 08:58
@vpetersson vpetersson requested a review from Copilot May 3, 2026 09:00
vpetersson and others added 10 commits May 3, 2026 09:00
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>
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>
The 8dbf4ea 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>
…ver/app/

The 8dbf4ea 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>
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>
…over

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>
…, 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>
…table

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>
…r-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>
… 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the Anthias UI from a React/Redux SPA to server-rendered Django templates enhanced with HTMX (partial swaps/polling) and Alpine.js (light client-side state), while reorganizing the Python package layout under src/ and updating imports/tests/CI to match.

Changes:

  • Replaces the React SPA routes with Django template views for /, /system-info, /integrations, and /settings, including HTMX-driven asset list refresh and Alpine-driven modal/nav state.
  • Moves frontend assets into src/anthias_server/app/static/ and templates into src/anthias_server/app/templates/, bundling vendored JS/CSS/fonts into static/dist/ via Bun.
  • Refactors Python module paths (e.g., anthias_server.*, anthias_viewer.*, anthias_common.*) and updates tests, Docker, and GitHub workflows accordingly.

Reviewed changes

Copilot reviewed 108 out of 147 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/anthias_common/device_helper.py Adds shared device helpers; contains a confirmed parsing bug in parse_cpu_info() (stored comment).
src/anthias_server/lib/backup_helper.py Adds backup/recover with safer tar extraction; calls sys.exit() on missing HOME (stored comment).
src/anthias_server/app/templates/base.html Loads new vendor.js; defer ordering can break inline initializers on first render (stored comment).
src/anthias_server/app/templates/home.html New server-rendered home + HTMX polling; duplicates id="asset-table" with included partial (stored comment).
src/anthias_server/app/templates/_asset_table.html Asset table partial + Sortable init; duplicate ID + init timing issue (stored comments).
src/anthias_server/app/templates/_footer.html New footer template; still pulls badge from img.shields.io (PR-description discrepancy comment).
tools/raspberry_pi_imager/README.md Documents new Pi Imager JSON generator; local command path is outdated (stored comment).
package.json Removes React toolchain scripts and adds Bun build pipeline for vendor bundle + fonts/CSS.
pyproject.toml / sonar-project.properties / Docker + workflows Repoints settings/modules/testpaths to anthias_server.django_project.* and src/ layout.
(many removed files under static/src/**, templates/react.html, eslint/prettier configs) Deletes React/Redux SPA implementation and associated JS tests/configuration.
Comments suppressed due to low confidence (1)

src/anthias_server/app/templates/base.html:9

  • vendor.js is loaded with defer in base.html, but this page renders inline scripts (e.g., Sortable initialization in the asset table partial) during HTML parsing. Deferred scripts execute after parsing completes, so those inline scripts can run before window.Sortable/window.htmx are initialized and silently no-op. To avoid feature loss on first load, either move vendor.js to the end of (after inline scripts), drop defer, or change the inline initializers to run via an HTMX/DOMContentLoaded hook once vendor globals are available.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/templates/_asset_table.html Outdated
Comment thread src/anthias_server/app/templates/_asset_table.html Outdated
Comment thread src/anthias_server/app/templates/_footer.html Outdated
Comment thread src/anthias_server/app/templates/home.html
vpetersson and others added 5 commits May 3, 2026 09:12
…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>
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>
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>
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 109 out of 148 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/templates/_asset_modal.html Outdated
Comment thread tests/test_app.py Outdated
Comment thread src/anthias_server/app/templates/_asset_table.html Outdated
…n 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 109 out of 148 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/templates/_asset_modal.html Outdated
Comment thread src/anthias_server/app/templates/_asset_row.html Outdated
Comment thread src/anthias_server/app/templates/_asset_table.html Outdated
vpetersson and others added 2 commits May 3, 2026 11:44
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>
… 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>
@vpetersson vpetersson requested a review from Copilot May 3, 2026 11:49
vpetersson and others added 2 commits May 4, 2026 10:09
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>
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>
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 138 out of 192 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/urls.py
vpetersson and others added 2 commits May 4, 2026 10:57
…h 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>
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 138 out of 192 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/celery_tasks.py
Comment thread src/anthias_server/app/views.py Outdated
Comment thread .github/workflows/test-runner.yml Outdated
vpetersson and others added 2 commits May 4, 2026 11:16
… 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>
- 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 141 out of 195 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/lib/diagnostics.py
Comment thread src/anthias_server/api/tests/test_info_endpoints.py Outdated
…talled 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 "(0349008,
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 141 out of 195 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/anthias_server/app/templates/splash-page.html:10

  • splash-page.html uses Tailwind utility classes (e.g. flex, items-center, min-h-screen, gap-1) but only includes dist/css/anthias.css in the <head>. If Tailwind is bundled separately (as in base.html), the splash page will render unstyled in production. Consider also linking dist/css/tailwind.css (and any other required vendor CSS) here, or avoid Tailwind-only classes on this standalone template.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/templates/_asset_table.html
Comment thread src/anthias_server/app/templates/login.html Outdated
Comment thread src/anthias_server/lib/diagnostics.py
- _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 4697cfd 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 141 out of 195 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/anthias_server/django_project/settings.py:249

  • This comment still references STATICFILES_DIRS, but that setting is removed in this PR. Updating the wording to reflect the new app-local static layout (served via staticfiles finders / WhiteNoise when DEBUG) will avoid confusion for future maintainers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/templates/_navbar.html
Comment thread src/anthias_server/app/templates/_stat_card.html Outdated
Comment thread tests/conftest.py Outdated
- _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>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 4, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 141 out of 195 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/anthias_server/app/templates/splash-page.html:11

  • splash-page.html now uses Tailwind utility classes on and child elements (e.g. flex, items-center, min-h-screen, px-6), but the template only links dist/css/anthias.css. Since Tailwind is built into a separate dist/css/tailwind.css (and loaded in base.html), these utilities won’t be present here and the splash page layout will render unstyled. Add a <link> to dist/css/tailwind.css (and any other required shared CSS like tabler-icons if used) in this standalone template, or refactor the splash page to extend base.html so it inherits the global asset pipeline.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3 to +6
<p class="stat-card__label">{{ label }}</p>
<div class="stat-card__value">
{% if href %}<a href="{{ href }}" rel="noopener noreferrer" target="_blank" class="text-decoration-none">{{ value }}</a>
{% else %}{{ value }}{% endif %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants