Keep cold launches usable during dashboard HTTP outages #231
10
README.md
10
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
if (outage) {
|
||||||
|
qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
|
||||||
|
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';
|
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.';
|
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
|
||||||
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
|
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
|
||||||
|
setStatus('Offline · saved snapshot');
|
||||||
|
}
|
||||||
|
offlineStatus.hidden = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function showOfflineStatus() {
|
function showOfflineStatus() {
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
if (response.ok) {
|
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
|
if (response.ok) {
|
||||||
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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"] == []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user