Auto thumbnail - #165
Merged
Merged
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.