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
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:
parent
c3cd7c2621
commit
c81965326a
@ -84,6 +84,23 @@ def create_app() -> FastAPI:
|
|||||||
return FileResponse(favicon_path)
|
return FileResponse(favicon_path)
|
||||||
return FileResponse(settings.base_dir / "favicon.ico")
|
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)
|
@app.get("/api", response_class=HTMLResponse)
|
||||||
async def api_home():
|
async def api_home():
|
||||||
"""Page d'accueil de l'API."""
|
"""Page d'accueil de l'API."""
|
||||||
|
|||||||
@ -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://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://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">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@ -6056,5 +6063,16 @@
|
|||||||
|
|
||||||
<!-- CodeMirror Editor Bundle -->
|
<!-- CodeMirror Editor Bundle -->
|
||||||
<script src="/static/codemirror-editor.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
43
app/main.js
43
app/main.js
@ -89,6 +89,7 @@ class DashboardManager {
|
|||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
this.wsReconnectDelay = 1000;
|
||||||
|
|
||||||
this.debugModeEnabled = false;
|
this.debugModeEnabled = false;
|
||||||
|
|
||||||
@ -722,6 +723,7 @@ class DashboardManager {
|
|||||||
this.setDebugBadgeVisible(this.isDebugEnabled());
|
this.setDebugBadgeVisible(this.isDebugEnabled());
|
||||||
await this.loadAllData();
|
await this.loadAllData();
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
|
this.initNetworkListeners();
|
||||||
this.startRunningTasksPolling();
|
this.startRunningTasksPolling();
|
||||||
|
|
||||||
this.showNotification('Connexion réussie', 'success');
|
this.showNotification('Connexion réussie', 'success');
|
||||||
@ -1006,7 +1008,40 @@ class DashboardManager {
|
|||||||
|
|
||||||
// ===== WEBSOCKET =====
|
// ===== 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() {
|
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 wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
||||||
|
|
||||||
@ -1015,6 +1050,7 @@ class DashboardManager {
|
|||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log('WebSocket connecté');
|
console.log('WebSocket connecté');
|
||||||
|
this.wsReconnectDelay = 1000; // Reset backoff on success
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
@ -1023,8 +1059,11 @@ class DashboardManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log('WebSocket déconnecté, reconnexion dans 5s...');
|
const delay = this.wsReconnectDelay || 1000;
|
||||||
setTimeout(() => this.connectWebSocket(), 5000);
|
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) => {
|
this.ws.onerror = (error) => {
|
||||||
|
|||||||
BIN
app/static/icons/icon-192x192.png
Normal file
BIN
app/static/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
app/static/icons/icon-512x512.png
Normal file
BIN
app/static/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
30
app/static/manifest.json
Normal file
30
app/static/manifest.json
Normal 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
160
app/static/sw.js
Normal 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
68
tests/backend/test_pwa.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user