feat: Add Progressive Web App (PWA) support with service worker, manifest, and icons, including associated tests.
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
Bruno Charest 2026-03-03 22:56:54 -05:00
parent c3cd7c2621
commit c81965326a
9 changed files with 335 additions and 3 deletions

View File

@ -84,6 +84,23 @@ def create_app() -> FastAPI:
return FileResponse(favicon_path)
return FileResponse(settings.base_dir / "favicon.ico")
@app.get("/manifest.json", include_in_schema=False)
async def manifest():
"""Serve le Web App Manifest pour la PWA."""
return FileResponse(
settings.base_dir / "static" / "manifest.json",
media_type="application/manifest+json",
)
@app.get("/sw.js", include_in_schema=False)
async def service_worker():
"""Serve le Service Worker à la racine pour un scope maximal."""
return FileResponse(
settings.base_dir / "static" / "sw.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/"},
)
@app.get("/api", response_class=HTMLResponse)
async def api_home():
"""Page d'accueil de l'API."""

View File

@ -10,6 +10,13 @@
<link href="https://cdn.jsdelivr.net/npm/@splidejs/splide@4.1.4/dist/css/splide.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#7c3aed">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<style>
:root {
@ -6056,5 +6063,16 @@
<!-- CodeMirror Editor Bundle -->
<script src="/static/codemirror-editor.js"></script>
<!-- PWA Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
console.log('SW registered:', reg.scope);
}).catch(function(err) {
console.warn('SW registration failed:', err);
});
}
</script>
</body>
</html>

View File

@ -89,6 +89,7 @@ class DashboardManager {
// WebSocket
this.ws = null;
this.wsReconnectDelay = 1000;
this.debugModeEnabled = false;
@ -722,6 +723,7 @@ class DashboardManager {
this.setDebugBadgeVisible(this.isDebugEnabled());
await this.loadAllData();
this.connectWebSocket();
this.initNetworkListeners();
this.startRunningTasksPolling();
this.showNotification('Connexion réussie', 'success');
@ -1006,7 +1008,40 @@ class DashboardManager {
// ===== WEBSOCKET =====
initNetworkListeners() {
window.addEventListener('online', () => {
console.log('Réseau rétabli — reconnexion WebSocket...');
this.hideOfflineBanner();
this.wsReconnectDelay = 1000;
this.connectWebSocket();
});
window.addEventListener('offline', () => {
console.log('Réseau perdu — mode hors ligne');
this.showOfflineBanner();
});
}
showOfflineBanner() {
if (document.getElementById('offline-banner')) return;
const banner = document.createElement('div');
banner.id = 'offline-banner';
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff;text-align:center;padding:8px 16px;font-size:14px;font-weight:500;font-family:Inter,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
banner.innerHTML = '<i class="fas fa-wifi" style="margin-right:8px;opacity:0.8"></i>Hors ligne — les données seront rafraîchies au retour du réseau';
document.body.prepend(banner);
}
hideOfflineBanner() {
const banner = document.getElementById('offline-banner');
if (banner) banner.remove();
}
connectWebSocket() {
// Don't attempt to connect while offline
if (typeof navigator !== 'undefined' && !navigator.onLine) {
console.log('Hors ligne — connexion WebSocket reportée');
return;
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
@ -1015,6 +1050,7 @@ class DashboardManager {
this.ws.onopen = () => {
console.log('WebSocket connecté');
this.wsReconnectDelay = 1000; // Reset backoff on success
};
this.ws.onmessage = (event) => {
@ -1023,8 +1059,11 @@ class DashboardManager {
};
this.ws.onclose = () => {
console.log('WebSocket déconnecté, reconnexion dans 5s...');
setTimeout(() => this.connectWebSocket(), 5000);
const delay = this.wsReconnectDelay || 1000;
console.log(`WebSocket déconnecté, reconnexion dans ${delay / 1000}s...`);
setTimeout(() => this.connectWebSocket(), delay);
// Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s max
this.wsReconnectDelay = Math.min((delay) * 2, 30000);
};
this.ws.onerror = (error) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

30
app/static/manifest.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "Homelab Automation Dashboard",
"short_name": "Homelab",
"description": "Dashboard de gestion automatisée de homelab avec Ansible",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#7c3aed",
"background_color": "#0a0a0a",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": [
"utilities",
"productivity"
],
"lang": "fr",
"dir": "ltr"
}

160
app/static/sw.js Normal file
View File

@ -0,0 +1,160 @@
// ============================================================================
// Service Worker — Homelab Automation Dashboard PWA
// ============================================================================
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `homelab-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `homelab-dynamic-${CACHE_VERSION}`;
// App shell — resources to pre-cache on install
const APP_SHELL = [
'/',
'/static/main.js',
'/static/containers_page.js',
'/static/dashboard_core.js',
'/static/docker_section.js',
'/static/favorites_manager.js',
'/static/icon_picker.js',
'/static/playbook_editor.js',
'/static/container_customizations_manager.js',
'/manifest.json',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png',
];
// External CDN resources to cache on first fetch
const CDN_PATTERNS = [
'cdn.tailwindcss.com',
'cdnjs.cloudflare.com',
'fonts.googleapis.com',
'fonts.gstatic.com',
'cdn.jsdelivr.net',
];
// ---- INSTALL: pre-cache app shell ----
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
.catch((err) => console.warn('[SW] Pre-cache failed for some resources:', err))
);
});
// ---- ACTIVATE: clean up old caches ----
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map((key) => {
console.log('[SW] Removing old cache:', key);
return caches.delete(key);
})
)
)
.then(() => self.clients.claim())
);
});
// ---- FETCH: routing strategies ----
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip WebSocket upgrades
if (url.protocol === 'ws:' || url.protocol === 'wss:') return;
// Skip /docs and /redoc (Swagger UI) — always network
if (url.pathname.startsWith('/docs') || url.pathname.startsWith('/redoc')) return;
// --- API calls: Network First ---
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
return;
}
// --- CDN resources: Cache First ---
if (CDN_PATTERNS.some((pattern) => url.hostname.includes(pattern))) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// --- Static assets & app shell: Cache First ---
if (
url.pathname.startsWith('/static/') ||
url.pathname === '/manifest.json' ||
url.pathname === '/favicon.ico' ||
url.pathname === '/sw.js'
) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// --- HTML pages (/, etc.): Network First ---
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
});
// ============================================================================
// Caching strategies
// ============================================================================
/**
* Cache First try cache, fall back to network (and cache the response).
*/
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
// If both cache and network fail, return a basic offline page
return new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Network First try network, fall back to cache.
*/
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
// For HTML navigations, serve the cached root page
if (request.headers.get('Accept')?.includes('text/html')) {
const fallback = await caches.match('/');
if (fallback) return fallback;
}
return new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' },
});
}
}

File diff suppressed because one or more lines are too long

68
tests/backend/test_pwa.py Normal file
View File

@ -0,0 +1,68 @@
"""
Tests pour les routes PWA (manifest.json, sw.js).
Couvre:
- Servir le manifest à la racine
- Servir le Service Worker à la racine avec l'en-tête Service-Worker-Allowed
- Validation du contenu JSON du manifest
"""
import json
import pytest
pytestmark = pytest.mark.unit
class TestManifest:
"""Tests pour GET /manifest.json."""
@pytest.mark.asyncio
async def test_manifest_served(self, client):
"""Le manifest est servi à /manifest.json avec le bon content-type."""
resp = await client.get("/manifest.json")
assert resp.status_code == 200
content_type = resp.headers.get("content-type", "")
assert "manifest+json" in content_type or "application/json" in content_type
@pytest.mark.asyncio
async def test_manifest_valid_json(self, client):
"""Le manifest contient les champs PWA requis."""
resp = await client.get("/manifest.json")
assert resp.status_code == 200
data = json.loads(resp.text)
assert "name" in data
assert "icons" in data
assert "start_url" in data
assert "display" in data
assert data["display"] == "standalone"
assert len(data["icons"]) >= 2
class TestServiceWorker:
"""Tests pour GET /sw.js."""
@pytest.mark.asyncio
async def test_sw_served(self, client):
"""Le Service Worker est servi à /sw.js."""
resp = await client.get("/sw.js")
assert resp.status_code == 200
content_type = resp.headers.get("content-type", "")
assert "javascript" in content_type
@pytest.mark.asyncio
async def test_sw_has_scope_header(self, client):
"""Le Service Worker a l'en-tête Service-Worker-Allowed."""
resp = await client.get("/sw.js")
assert resp.status_code == 200
assert resp.headers.get("service-worker-allowed") == "/"
@pytest.mark.asyncio
async def test_sw_contains_cache_logic(self, client):
"""Le Service Worker contient la logique de cache."""
resp = await client.get("/sw.js")
assert resp.status_code == 200
body = resp.text
assert "caches" in body
assert "install" in body
assert "fetch" in body