feat: open mobile dashboard offline (#226)
This commit is contained in:
parent
1c2dbd2168
commit
6bf5512b47
14
README.md
14
README.md
|
|
@ -68,6 +68,20 @@ API process is running and does not contact Gitea. GET `/readyz` is the
|
|||
readiness check: it validates the configured Gitea credentials and returns
|
||||
HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage, navigation falls back
|
||||
to that shell so local issue and review drafts remain reachable. A visible,
|
||||
accessible offline notice distinguishes this mode from live Gitea data, and the
|
||||
dashboard refreshes its live snapshot when connectivity returns.
|
||||
|
||||
The offline guarantee covers only the HTML/JavaScript shell, manifest, icons, and
|
||||
browser-local drafts. API responses and mutations are never cached or queued;
|
||||
submissions still require connectivity. Service-worker upgrades are atomic and
|
||||
remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
|
||||
the same origin.
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
|
|
@ -273,6 +275,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<span class="small muted" id="clock"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel my-work" id="my-work">
|
||||
|
|
@ -2989,6 +2994,20 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
});
|
||||
function load() { return contextPoller.refresh(); }
|
||||
|
||||
const offlineStatus = qs('#offline-status');
|
||||
function showOfflineStatus() {
|
||||
offlineStatus.hidden = false;
|
||||
setStatus('Offline');
|
||||
}
|
||||
function reconnectLiveData() {
|
||||
offlineStatus.hidden = true;
|
||||
setStatus('Reconnecting…');
|
||||
contextPoller.refresh();
|
||||
}
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
const CACHE = 'stackchain-shell-v1';
|
||||
const CACHE = 'stackchain-dashboard-shell-v2';
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
|
|
@ -12,7 +27,8 @@ self.addEventListener('install', event => {
|
|||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE).map(key => caches.delete(key))
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
});
|
||||
|
||||
|
|
@ -20,8 +36,20 @@ self.addEventListener('fetch', event => {
|
|||
const request = event.request;
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(fetch(request));
|
||||
event.respondWith(
|
||||
fetch(request).then(async response => {
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(BASE, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
return cache.match(BASE);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
|||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
|
||||
response_headers = dict(headers or {})
|
||||
response_headers["Cache-Control"] = "no-cache"
|
||||
super().__init__(content, status_code, response_headers, media_type, background)
|
||||
|
||||
|
||||
@router.get("/", response_class=RevalidatingHTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return DASHBOARD_FILE.read_text()
|
||||
|
||||
|
|
|
|||
154
tests/test_service_worker.py
Normal file
154
tests/test_service_worker.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_worker_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
|
||||
}};
|
||||
const context = {{
|
||||
URL, Request, Response,
|
||||
console,
|
||||
self: {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||
}},
|
||||
caches: {{
|
||||
open: async () => cache,
|
||||
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
|
||||
delete: async key => {{ state.deleted.push(key); return true; }},
|
||||
match: async request => cache.match(request),
|
||||
}},
|
||||
fetch: async request => {{
|
||||
state.fetches.push(String(request.url || request));
|
||||
if (state.failFetch) throw new Error('offline');
|
||||
return new Response('network', {{ status: state.fetchStatus }});
|
||||
}},
|
||||
}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
|
||||
async function dispatch(name, request) {{
|
||||
let pending;
|
||||
let response;
|
||||
listeners[name]({{
|
||||
request,
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
respondWith: promise => {{ response = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
return response ? await response : null;
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, check=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatch('install');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["skipped"] is True
|
||||
assert result["added"][0] == "/dashboard/"
|
||||
assert set(result["added"]) == {
|
||||
"/dashboard/",
|
||||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
}
|
||||
|
||||
|
||||
def test_activate_deletes_only_stale_stackchain_caches():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatch('activate');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["claimed"] is True
|
||||
assert result["deleted"] == ["stackchain-dashboard-old"]
|
||||
|
||||
|
||||
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate',
|
||||
url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["fetches"] == [
|
||||
"https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
|
||||
]
|
||||
|
||||
|
||||
def test_cross_origin_navigation_is_not_intercepted_or_cached():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["intercepted"] is False
|
||||
assert result["state"]["fetches"] == []
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_failed_online_navigation_does_not_replace_working_cached_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.fetchStatus = 503;
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ status: response.status, state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 503
|
||||
assert result["state"]["puts"] == []
|
||||
|
|
@ -85,4 +85,29 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
|
|||
assert worker.headers["service-worker-allowed"] == "/"
|
||||
assert "request.url.includes('/api/')" in worker.text
|
||||
assert "request.method !== 'GET'" in worker.text
|
||||
assert "new URL('./', self.location.href).pathname" in worker.text
|
||||
assert "new URL('./', self.location.href).pathname" in worker.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="offline-status"' in response.text
|
||||
assert 'role="status"' in response.text
|
||||
assert 'aria-live="polite"' in response.text
|
||||
assert "window.addEventListener('offline'" in response.text
|
||||
assert "window.addEventListener('online'" in response.text
|
||||
assert "contextPoller.refresh()" in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_shell_revalidates_instead_of_being_stored_as_fresh():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-cache"
|
||||
Loading…
Reference in New Issue
Block a user