Skip to content

Auto thumbnail - #165

Merged
ckrew merged 9 commits into
mainfrom
auto-thumbnail
Sep 2, 2026
Merged

Auto thumbnail#165
ckrew merged 9 commits into
mainfrom
auto-thumbnail

Conversation

@msouff

@msouff msouff commented Aug 11, 2026

Copy link
Copy Markdown
Contributor
  • Fixed auto thumbnail functionality (replaced html2canvas with snapdom)
  • Added a toggle for auto thumbnail on save vs manually uploading a thumbnail
  • Removed default icon
  • Unrestricted cells on by default
  • Fill viewport consistency fix (now it looks the same in edit mode)

msouff and others added 3 commits August 10, 2026 18:04
Restores automatic thumbnail capture, disabled long ago because the image
rode along in the layout save request and blew Django's 2.5 MB
DATA_UPLOAD_MAX_MEMORY_SIZE, failing the save itself and costing the user
their work.

Capture uses snapdom rather than html2canvas (1.4.1, unreleased since
2022). The difference is load-bearing rather than cosmetic: OpenLayers
renders base map tiles into a WebGL canvas, and snapdom retries canvas
readback across animation frames where html2canvas does not, so maps now
appear in thumbnails at all.

The capture:
- clips to the viewport and crops off the fixed header's band, by a
  fraction of the height rather than a pixel scale inferred from a width
  ratio, which a vertical scrollbar throws off
- picks the visible grid rather than the first mounted one, which belongs
  to tab one regardless of which tab is active
- resets scroll position, so the image does not depend on where the user
  happened to be scrolled
- is sent as its own request after the layout save has committed, and
  returns null instead of throwing, so it cannot cost a save
- targets 640px wide, 2x the landing page card

Fill Viewport now applies while editing too. It is worth having on its own
- the creator sees the result when the cell is saved instead of having to
leave edit mode - and it fixes the thumbnail: the item and the map canvas
inside it now hold their final size continuously, rather than being
resized by the exit from edit mode and captured mid-resize.

On the map side, two changes make layers survive capture at all. A canvas
holding images fetched without CORS is tainted and unreadable by any
library, and OpenLayers draws consecutive layers into one shared canvas
when their className matches (useContainer in renderer/canvas/Layer.js),
so a single such layer blanked every layer sharing the canvas. Each
Canvas2D raster layer now gets its own className, and CORS support is
detected per origin with a cors-mode HEAD, setting crossOrigin only where
the server allows it - requesting it elsewhere would stop the images
loading entirely. The layer editor exposes crossOrigin as an override on
the three ol/source/Image* types, which unlike ol/source/ImageTile do not
already default to "anonymous".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unrestricted Grid Item Placement is now on unless a request says otherwise.
This is the only creation path - add_new_dashboard has a single caller, and
the New Dashboard modal does not send the field, so the default governs.

Existing dashboards keep whatever they were created with, and copies still
inherit from their original.

Note that the test updated here pins the default but could not be run:
test_controllers.py does not import under this environment's Python 3.10,
at HEAD as much as with this change. It nests same-type quotes inside an
f-string (PEP 701), which needs 3.12+.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fill-viewport item is position:fixed over the whole content area, which
left the tiles around it in three broken states.

Paint order among the grid's tiles was decided by tree order alone: every
tile is positioned with z-index:auto, so DOM order chose what sat on top.
That holds on screen but is not reproduced when a DOM-to-image library
captures the dashboard, since position:fixed is meaningless in the
detached clone it renders from - the fill item gets repositioned and
painted over tiles that belong above it, so they were missing from
thumbnails. Tiles ordered after the fill item now say so with a z-index.
It goes on the grid item itself, not on anything inside it:
react-grid-layout renders its resize handles as siblings of this
component's output, so lifting only the content painted over the handles
and made those tiles impossible to resize. It is also applied only on a
tab that has a fill item, since a z-index creates a stacking context that
would otherwise trap dropdowns and popovers inside a cell.

The item's own context menu was a sibling of the cell rather than a child,
so it was positioned against the react-grid-layout wrapper: it stayed at
the old grid position when the cell moved to cover the content area, and
would not have followed a lifted cell either. It now lives inside the
cell. Positioning is unchanged for ordinary cells, which fill their
wrapper.

Finally, the fill item is no longer draggable or resizable. It takes its
position and size from the viewport, so both gestures did nothing visible,
and turning off resizing is also what hides the handle - react-grid-layout
marks the item react-resizable-hide and its stylesheet hides the handle
within. The flags are derived from the item's metadata each render, so
clearing the setting restores dragging and the handle immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@msouff
msouff requested a review from ckrew August 11, 2026 04:06
msouff and others added 6 commits August 12, 2026 11:35
…efault

The two failure-path tests assert the positional arguments passed to
add_new_dashboard, and its seventh is unrestricted_placement - now True.

These went unnoticed in 96b2082 because test_controllers.py could not be
collected under that environment's Python 3.10: it uses PEP 701 f-string
syntax, so the whole module failed to import and every test in it was
skipped rather than reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test suite uses PEP 701 f-string syntax, which does not parse before
3.12, and CI has pinned 3.12 for that reason. Nothing declared the floor,
so an older interpreter installed happily and then dropped whole test
modules on a collection error - which is how test_controllers.py came to be
skipped locally while passing in CI.

pip now refuses the install instead:
  ERROR: Package 'tethysdash' requires a different Python: 3.10.12 not in '>=3.12'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Images from a host that sends no Access-Control-Allow-Origin came out as a
grey placeholder in dashboard thumbnails, though they displayed fine on
screen. Capturing serialises the page, which re-fetches every <img> from
JavaScript, and that fetch is subject to CORS. Nothing in the browser can
work around it, so the image is fetched server-side instead and handed to
the capture already embedded.

The fetch is made by captureThumbnail rather than through snapdom's own
useProxy option: snapdom picks its credentials from the original image URL
rather than the proxied one it requests, so a cross-origin image resolves
to "omit" and its request to our proxy would arrive with no session. Doing
it here keeps the proxy behind authentication. Each <img> is swapped for a
data URL only for the duration of the capture, and restored afterwards.

The endpoint requires a login, which costs nothing: a capture only runs on
save, and viewing a dashboard never touches it.

An endpoint that fetches whatever URL it is handed is a server-side request
forgery primitive, so:

- Redirects are followed by hand, re-checking every hop. Left to requests,
  a permitted public URL could bounce the server onto an internal one,
  bypassing the address checks entirely - verified against a live
  redirector before and after.
- The address actually connected to is checked at the socket. The hostname
  check and requests each resolve the name separately, and a hostile DNS
  server may answer those differently. Redirect hops keep only the hostname
  check, since urllib3 has already released the connection by then.
- Responses carry nosniff, a null CSP and Content-Disposition: attachment.
  image/svg+xml passes an image/* check and can carry script, which would
  otherwise run as this origin when opened directly.
- Private, loopback, link-local, reserved, multicast and unspecified
  addresses are refused, unresolvable names fail closed, and the body is
  read with a cap that bounds the decompressed size.

No Access-Control-Allow-Origin: the capture is same-origin and needs none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mage

Uploading a thumbnail by hand was pointless while auto-capture was on: the
next save overwrote it. The Update Dashboard Thumbnail modal now carries an
"Update thumbnail on save" switch above the file picker, reflecting the
dashboard's saved setting, and choosing a file clears it - visibly, rather
than silently on save. The switch can be changed on its own, so auto-capture
can be turned back on without uploading anything, and a save skips the
capture entirely while it is off.

Persisted as dashboards.auto_thumbnail, defaulting to true so nothing
changes for existing dashboards. Added nullable and backfilled before being
pinned NOT NULL, since SQLite cannot add a NOT NULL column to a populated
table in one step. Copies inherit it, matching unrestricted_placement.

The default thumbnail is removed with it. Now that a thumbnail is either
captured on save or uploaded, a placeholder shipped in the repo and copied
into media on every dashboard creation earns nothing: creation writes no
file, the serializer returns null rather than falling back to a static
image, and the card omits the image element entirely - rendering it with no
src would show a broken-image icon, which reads worse than an empty card.
Copy and delete already guarded on the file existing, so neither needed
changing.

Existing dashboards keep whatever image file they already have, including
any that only ever held the copied default, until they are saved again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tity

Reordering cells and saving crashed the dashboard with "setTarget is not a
function" and left thumbnails showing placeholder boxes.

Grid items were keyed on i, which the backend rewrites positionally on every
save (model.py: db_grid_item.i = str(grid_item_i)). Reordering two cells
therefore returned them with their i values swapped, so React matched key
"1" to whichever cell now held it and reused one cell's component instance
for another. A map cell became an image cell, and since Base.js shares a
single ref across visualization types - and React attaches new refs before
running old cleanups - the map teardown called setTarget on an <img>.

uuid is set once when a row is created and never reassigned, so it is the
identity i only looks like. Keying on it stops the reuse: each cell keeps
its instance, its ref, and its type across a reorder.

This also fixes the thumbnails, which were the same churn seen from another
angle: cells were being torn down and rebuilt while the capture ran, so a
map lost its layers and an image arrived too late to be inlined. Nothing
needs to wait for content that is no longer being destroyed.

react-grid-layout pairs layout[n].i with the child's React key, so the
layout memo, the child key, updateLayout's lookup and handleResize's lookup
all move together. Each falls back to i, so an item without a uuid still
works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ckrew
ckrew merged commit 7c19f5c into main Sep 2, 2026
2 checks passed
@ckrew
ckrew deleted the auto-thumbnail branch September 2, 2026 17:24
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.

2 participants