Keep cold launches usable during dashboard HTTP outages #231

Merged
timmy merged 1 commits from timmy/230-http-outage-offline-launch into main 2026-08-07 21:34:13 +00:00
5 changed files with 80 additions and 12 deletions

View File

@ -71,10 +71,12 @@ HTTP 503 with an error when Gitea is unavailable or authentication fails.
## Offline mobile shell ## Offline mobile shell
After one successful online load, the installed dashboard precaches a versioned, After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage, navigation falls back subpath-scoped application shell. During a network outage or a dashboard HTTP
to that shell so local issue and review drafts remain reachable. A visible, `500`, `502`, `503`, or `504` response, navigation falls back to that shell when
accessible offline notice distinguishes this mode from live Gitea data, and the it is available so local issue and review drafts remain reachable. Authentication
dashboard refreshes its live snapshot when connectivity returns. and other client-error responses are never hidden. A visible, accessible offline
or server-outage notice distinguishes cached mode from live Gitea data, and the
dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in refresh then stores a seven-day, versioned snapshot containing only the signed-in

View File

@ -1186,6 +1186,7 @@ textarea { resize: vertical; min-height: 120px; }
function handleContextError(e) { function handleContextError(e) {
console.error('context failed', e); console.error('context failed', e);
liveMode = false; liveMode = false;
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable'); setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
if (!hasContextSnapshot) { if (!hasContextSnapshot) {
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>'; qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
@ -2095,6 +2096,8 @@ textarea { resize: vertical; min-height: 120px; }
} }
function renderLiveSnapshot(snapshot) { function renderLiveSnapshot(snapshot) {
setOfflineWorkMode(false);
offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context; const contextFreshness = snapshot.freshness?.sections?.context;
const eventsFreshness = snapshot.freshness?.sections?.events; const eventsFreshness = snapshot.freshness?.sections?.events;
const notificationFreshness = snapshot.freshness?.sections?.notifications; const notificationFreshness = snapshot.freshness?.sections?.notifications;
@ -3033,9 +3036,10 @@ textarea { resize: vertical; min-height: 120px; }
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read'] ['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; }); .forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
} }
function hydrateOfflineWork() { function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load(); const saved = offlineWorkStore.load();
if (!saved) return false; if (!saved) return false;
const outage = mode === 'outage';
lastNotifications = saved.notifications || []; lastNotifications = saved.notifications || [];
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {}; workPagination = saved.work_pagination || {};
@ -3044,9 +3048,18 @@ textarea { resize: vertical; min-height: 120px; }
paintMyWork(saved); paintMyWork(saved);
setOfflineWorkMode(true); setOfflineWorkMode(true);
const savedLabel = fmt(saved.saved_at); const savedLabel = fmt(saved.saved_at);
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only'; if (outage) {
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.'; qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.'; offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.';
offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.';
setStatus('Outage · saved snapshot');
} else {
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
setStatus('Offline · saved snapshot');
}
offlineStatus.hidden = false;
return true; return true;
} }
function showOfflineStatus() { function showOfflineStatus() {

View File

@ -1,4 +1,5 @@
const CACHE = 'stackchain-dashboard-shell-v3'; const CACHE = 'stackchain-dashboard-shell-v4';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
const SHELL = [ const SHELL = [
BASE, BASE,
@ -41,9 +42,12 @@ self.addEventListener('fetch', event => {
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(request).then(async response => { fetch(request).then(async response => {
const cache = await caches.open(CACHE);
if (response.ok) { if (response.ok) {
const cache = await caches.open(CACHE);
await cache.put(BASE, response.clone()); await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
if (cached) return cached;
} }
return response; return response;
}).catch(async () => { }).catch(async () => {

View File

@ -102,3 +102,14 @@ async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydr
assert "setOfflineWorkMode(true)" in html assert "setOfflineWorkMode(true)" in html
assert "if (offlineWorkMode)" in html assert "if (offlineWorkMode)" in html
assert '.offline-work-controls button { min-height:44px;' in html assert '.offline-work-controls button { min-height:44px;' in html
@pytest.mark.anyio
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
html = await dashboard()
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html

View File

@ -2,6 +2,8 @@ import json
import subprocess import subprocess
from pathlib import Path from pathlib import Path
import pytest
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js" WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
@ -140,16 +142,52 @@ def test_cross_origin_navigation_is_not_intercepted_or_cached():
assert result["state"]["puts"] == [] assert result["state"]["puts"] == []
def test_failed_online_navigation_does_not_replace_working_cached_shell(): @pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_server_outage_navigation_returns_working_cached_shell_without_replacing_it(status):
result = run_worker_scenario(
f"""
state.fetchStatus = {status};
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {{
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}});
process.stdout.write(JSON.stringify({{ status: response.status, body: await response.text(), state }}));
"""
)
assert result["status"] == 200
assert result["body"] == "cached dashboard"
assert result["state"]["puts"] == []
def test_server_outage_without_cached_shell_preserves_network_error():
result = run_worker_scenario( result = run_worker_scenario(
""" """
state.fetchStatus = 503; state.fetchStatus = 503;
const response = await dispatch('fetch', { const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/', method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}); });
process.stdout.write(JSON.stringify({ status: response.status, state })); process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
""" """
) )
assert result["status"] == 503 assert result["status"] == 503
assert result["body"] == "network"
assert result["state"]["puts"] == []
def test_client_error_navigation_is_not_hidden_by_cached_shell():
result = run_worker_scenario(
"""
state.fetchStatus = 401;
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
"""
)
assert result["status"] == 401
assert result["body"] == "network"
assert result["state"]["puts"] == [] assert result["state"]["puts"] == []