feat(pwa): make dashboard installable as a progressive web app
All checks were successful
CI / Clippy (push) Successful in 2m24s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 2s
CI / Format (push) Successful in 3s
CI / Clippy (pull_request) Successful in 2m19s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped

Add web manifest, service worker with cache-first static assets and
network-first navigation, and register from the App component head.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-02-19 20:05:23 +01:00
parent cbb664c7d9
commit 20b3279bb5
3 changed files with 104 additions and 0 deletions

17
assets/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "CERTifAI Dashboard",
"short_name": "CERTifAI",
"description": "Self-hosted GenAI infrastructure management dashboard",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#0f1117",
"theme_color": "#4B3FE0",
"icons": [
{
"src": "/assets/logo.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

67
assets/sw.js Normal file
View File

@@ -0,0 +1,67 @@
// CERTifAI Service Worker — network-first with offline fallback cache.
// Static assets (CSS, JS, WASM, fonts) use cache-first for speed.
// API and navigation requests always try the network first.
const CACHE_NAME = "certifai-v1";
// Pre-cache the app shell on install
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) =>
cache.addAll([
"/",
"/dashboard",
"/assets/logo.svg",
"/assets/favicon.ico",
])
)
);
self.skipWaiting();
});
// Clean up old caches on activate
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Skip non-GET and API requests (let them go straight to network)
if (event.request.method !== "GET" || url.pathname.startsWith("/api/")) {
return;
}
// Cache-first for static assets (hashed filenames make this safe)
const isStatic = /\.(js|wasm|css|ico|svg|png|jpg|woff2?)(\?|$)/.test(url.pathname);
if (isStatic) {
event.respondWith(
caches.match(event.request).then((cached) =>
cached || fetch(event.request).then((resp) => {
const clone = resp.clone();
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
return resp;
})
)
);
return;
}
// Network-first for navigation / HTML
event.respondWith(
fetch(event.request)
.then((resp) => {
const clone = resp.clone();
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
return resp;
})
.catch(() => caches.match(event.request))
);
});

View File

@@ -52,6 +52,7 @@ pub enum Route {
const FAVICON: Asset = asset!("/assets/favicon.ico"); const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css"); const MAIN_CSS: Asset = asset!("/assets/main.css");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const MANIFEST: Asset = asset!("/assets/manifest.json");
/// Google Fonts URL for Inter (body) and Space Grotesk (headings). /// Google Fonts URL for Inter (body) and Space Grotesk (headings).
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
@@ -64,6 +65,14 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
pub fn App() -> Element { pub fn App() -> Element {
rsx! { rsx! {
document::Link { rel: "icon", href: FAVICON } document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "manifest", href: MANIFEST }
document::Meta { name: "theme-color", content: "#4B3FE0" }
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
document::Meta {
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
}
document::Link { rel: "apple-touch-icon", href: FAVICON }
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
document::Link { document::Link {
rel: "preconnect", rel: "preconnect",
@@ -73,6 +82,17 @@ pub fn App() -> Element {
document::Link { rel: "stylesheet", href: GOOGLE_FONTS } document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS }
document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: MAIN_CSS }
// Register PWA service worker
document::Script {
r#"
if ('serviceWorker' in navigator) {{
navigator.serviceWorker.register('/assets/sw.js')
.catch(function(e) {{ console.warn('SW registration failed:', e); }});
}}
"#
}
div { "data-theme": "certifai-dark", Router::<Route> {} } div { "data-theme": "certifai-dark", Router::<Route> {} }
} }
} }