From 355e8cad4c4bd13babc530376fbe8d3f487fa1dc Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Tue, 10 Mar 2026 08:02:16 +0200 Subject: [PATCH 1/2] Add SPA episode navigation to avoid rerun WASM reload Use rerun viewer's `add_receiver`/`remove_receiver` API to swap RRD data without reloading the iframe. Adds Service Worker to cache WASM assets and `/api/episode/{id}` endpoint for client-side navigation. --- positronic/server/positronic_server.py | 43 +++-- positronic/server/static/sw.js | 31 ++++ positronic/server/templates/base.html | 1 + positronic/server/templates/episode.html | 202 +++++++++++++++++------ 4 files changed, 215 insertions(+), 62 deletions(-) create mode 100644 positronic/server/static/sw.js diff --git a/positronic/server/positronic_server.py b/positronic/server/positronic_server.py index e71d9a77..0e1bf0f6 100644 --- a/positronic/server/positronic_server.py +++ b/positronic/server/positronic_server.py @@ -103,6 +103,17 @@ async def cache_rerun_assets(request: Request, call_next): return response +def _make_serializable(obj): + """Ensure obj is JSON serializable (e.g. convert datetime).""" + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, dict): + return {k: _make_serializable(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_make_serializable(v) for v in obj] + return obj + + def _iter_file_chunks(path: str, *, chunk_size: int = 128 * 1024): with open(path, 'rb') as source: while True: @@ -188,16 +199,6 @@ async def episode_viewer(request: Request, episode_id: int): size_mb = meta.get('size_mb') size_mb_display = f'{size_mb:.2f}' if isinstance(size_mb, int | float) else None - # Ensure static_data is JSON serializable (e.g. handle datetime) - def _make_serializable(obj): - if isinstance(obj, datetime): - return obj.isoformat() - if isinstance(obj, dict): - return {k: _make_serializable(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_make_serializable(v) for v in obj] - return obj - return templates.TemplateResponse( 'episode.html', { @@ -428,6 +429,28 @@ async def api_dataset_status(): } +@app.get('/api/episode/{episode_id}') +@require_dataset +async def api_episode(episode_id: int): + ds = app_state.get('dataset') + try: + episode = ds[episode_id] + except IndexError as e: + raise HTTPException(status_code=404, detail='Episode not found') from e + + meta = episode.meta + size_mb = meta.get('size_mb') + + return { + 'episode_id': episode_id, + 'num_episodes': len(ds), + 'task': episode.static.get('task', None), + 'episode_path': meta.get('path'), + 'episode_size_mb': f'{size_mb:.2f}' if isinstance(size_mb, int | float) else None, + 'static_data': _make_serializable(episode.static), + } + + @app.get('/api/episode_rrd/{episode_id}') @require_dataset async def api_episode_rrd(episode_id: int): diff --git a/positronic/server/static/sw.js b/positronic/server/static/sw.js new file mode 100644 index 00000000..3f204a03 --- /dev/null +++ b/positronic/server/static/sw.js @@ -0,0 +1,31 @@ +// Service Worker to cache rerun WASM and JS assets. +// These files are large (~35MB WASM) and don't change between episodes. + +const CACHE_NAME = 'rerun-assets-v1'; +const RERUN_PATH_PREFIX = '/static/rerun/'; + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (!url.pathname.startsWith(RERUN_PATH_PREFIX)) return; + + event.respondWith( + caches.open(CACHE_NAME).then((cache) => + cache.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (response.ok) cache.put(event.request, response.clone()); + return response; + }); + }) + ) + ); +}); diff --git a/positronic/server/templates/base.html b/positronic/server/templates/base.html index 5995fbb9..a2f3427b 100644 --- a/positronic/server/templates/base.html +++ b/positronic/server/templates/base.html @@ -28,6 +28,7 @@ + {% block scripts %}{% endblock %} diff --git a/positronic/server/templates/episode.html b/positronic/server/templates/episode.html index a642f8e8..60ea7a0e 100644 --- a/positronic/server/templates/episode.html +++ b/positronic/server/templates/episode.html @@ -7,48 +7,159 @@ {% endblock %} {% block scripts %} + + - + {% endblock %} @@ -150,31 +254,25 @@ Ep. -
- / {{ num_episodes }}
- {% if task %} : {{ task }}{% endif %} + {% if task %} : {{ task }}{% endif %}
- {% if episode_path or episode_size_mb %} -
- {% if episode_path %} - Episode path: - {{ episode_path }} - {% endif %} - {% if episode_size_mb %} - ({{ episode_size_mb }} MB) - {% endif %} +
+ Episode path: + {{ episode_path or '' }} + {% if episode_size_mb %}({{ episode_size_mb }} MB){% endif %}
- {% endif %}
From 81d93dc390e9e1942f7d5acd39ec72aa019520b5 Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Fri, 13 Mar 2026 15:52:15 +0200 Subject: [PATCH 2/2] Fix SPA viewer issues: sidebar accumulation, input maxLength, jinja2 dep - Reset `viewerReady` on iframe fallback reload - Use `replaceState` in popstate handler to avoid double history entries - Clear sidebar content before re-rendering on SPA navigation - Restore `input.maxLength` in `updateNavControls()` for both modes - Add `jinja2` to dependencies (required by starlette's Jinja2Templates) --- positronic/server/static/app.js | 1 + positronic/server/templates/episode.html | 12 +++++++++--- pyproject.toml | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/positronic/server/static/app.js b/positronic/server/static/app.js index 8ce011dc..fdad6438 100644 --- a/positronic/server/static/app.js +++ b/positronic/server/static/app.js @@ -615,6 +615,7 @@ function loadSidebarState() { function initializeSidebar(staticData) { const tbody = document.querySelector('.sidebar-content-wrapper tbody'); + tbody.innerHTML = ''; tbody.insertAdjacentHTML('beforeend', renderLevel('', staticData)); document.querySelectorAll('.expand-button').forEach((button) => { diff --git a/positronic/server/templates/episode.html b/positronic/server/templates/episode.html index 60ea7a0e..8f7f4cb2 100644 --- a/positronic/server/templates/episode.html +++ b/positronic/server/templates/episode.html @@ -17,7 +17,7 @@ // SPA navigation: swap RRD data in the running rerun viewer via its JS API. // This keeps the WASM alive — no 35MB re-download/recompile on each episode. - async function navigateToEpisode(episodeId) { + async function navigateToEpisode(episodeId, {replace = false} = {}) { const resp = await fetch(`/api/episode/${episodeId}`); if (!resp.ok) return; const data = await resp.json(); @@ -46,6 +46,7 @@ if (!swapped) { // Fallback: reload the iframe (first load or if handle unavailable) + viewerReady = false; const viewerUrl = new URL(`/static/rerun/${RERUN_VERSION}/index.html`, window.location.origin); viewerUrl.searchParams.set('url', newRrdUrl); viewerUrl.searchParams.set('hide_welcome_screen', ''); @@ -54,7 +55,10 @@ } // Update browser URL - history.pushState({episodeId: currentEpisodeId}, '', `/episode/${currentEpisodeId}`); + const historyState = {episodeId: currentEpisodeId}; + const url = `/episode/${currentEpisodeId}`; + if (replace) history.replaceState(historyState, '', url); + else history.pushState(historyState, '', url); document.title = `Episode ${currentEpisodeId} / ${maxEpisodes}`; // Update nav controls @@ -95,10 +99,12 @@ const idx = filteredIds.indexOf(currentEpisodeId); input.value = idx + 1; input.dataset.original = idx + 1; + input.maxLength = String(filteredIds.length).length; document.getElementById('episode-count').textContent = ` / ${filteredIds.length}`; } else { input.value = currentEpisodeId; input.dataset.original = currentEpisodeId; + input.maxLength = String(maxEpisodes - 1).length; document.getElementById('episode-count').textContent = ` / ${maxEpisodes}`; } } @@ -127,7 +133,7 @@ // Handle browser back/forward window.addEventListener('popstate', (e) => { if (e.state && e.state.episodeId !== undefined) { - navigateToEpisode(e.state.episodeId); + navigateToEpisode(e.state.episodeId, {replace: true}); } }); diff --git a/pyproject.toml b/pyproject.toml index fb43baba..f7cdd7db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "dearpygui", "dm_control", "fastapi", + "jinja2", # Required by starlette's Jinja2Templates (optional dep of fastapi) "fire", "httpx", "msgpack",