diff --git a/panel/frontend/index.html b/panel/frontend/index.html index 665a169..4bad0e9 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -5,6 +5,9 @@ + + + GHard Monitor diff --git a/panel/frontend/public/icons/apple-touch-icon.png b/panel/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..422e903 --- /dev/null +++ b/panel/frontend/public/icons/apple-touch-icon.png Binary files differ diff --git a/panel/frontend/public/icons/icon-192.png b/panel/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..550a585 --- /dev/null +++ b/panel/frontend/public/icons/icon-192.png Binary files differ diff --git a/panel/frontend/public/icons/icon-512.png b/panel/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..ea4310d --- /dev/null +++ b/panel/frontend/public/icons/icon-512.png Binary files differ diff --git a/panel/frontend/public/icons/maskable-192.png b/panel/frontend/public/icons/maskable-192.png new file mode 100644 index 0000000..96746f8 --- /dev/null +++ b/panel/frontend/public/icons/maskable-192.png Binary files differ diff --git a/panel/frontend/public/icons/maskable-512.png b/panel/frontend/public/icons/maskable-512.png new file mode 100644 index 0000000..b0d8aaf --- /dev/null +++ b/panel/frontend/public/icons/maskable-512.png Binary files differ diff --git a/panel/frontend/public/manifest.webmanifest b/panel/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..a7f6ae3 --- /dev/null +++ b/panel/frontend/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "GHard Monitor", + "short_name": "GHard", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#1E2522", + "theme_color": "#1E2522", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} \ No newline at end of file diff --git a/panel/frontend/public/sw.js b/panel/frontend/public/sw.js new file mode 100644 index 0000000..0fa06bf --- /dev/null +++ b/panel/frontend/public/sw.js @@ -0,0 +1,123 @@ +/* GHard Monitor — service worker PWA. + * + * Стратегии: + * - навигации: network-first (свежая оболочка), офлайн/ошибка → кэш; + * - /assets/* — hashed, immutable → cache-first; + * - мелкая статика (манифест, favicon, logo, шрифты) — stale-while-revalidate; + * - /api/, /auth/, /webhooks/, /mcp — не кэшируются никогда. + * + * Версию кэша (ghard-pwa-vN) поднимать при изменении списка стратегий: + * activate стирает кэши с чужими префиксами. + */ +const CACHE = 'ghard-pwa-v2' + +const API_BYPASS = ['/api/', '/auth/', '/webhooks/', '/mcp'] + +const SWR = [ + '/manifest.webmanifest', + '/favicon.svg', + '/logo.svg', + '/assets/fonts/IBM_Plex_Mono/', + '/assets/fonts/phosphor-icons/', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE) + // оболочка '/' сразу: первая офлайн-сессия не упирается в пустой кэш + .then((cache) => + fetch('/').then((r) => (r.ok ? cache.put('/', r) : null)).catch(() => {}), + ) + .then(() => self.skipWaiting()), + ) +}) + +// Страница после первого визита (SW берёт контроль после load, ассеты уже +// проскочили) передаёт список текущих файлов оболочки — докэшируем их, +// чтобы офлайн-сборка собиралась целиком. +self.addEventListener('message', (event) => { + if (event.data?.type !== 'shell') return + event.waitUntil( + caches.open(CACHE).then((cache) => + Promise.all( + (event.data.urls || []).slice(0, 50).map((u) => + cache.match(u).then( + (hit) => + hit || + fetch(u) + .then((r) => (r.ok ? cache.put(u, r) : null)) + .catch(() => {}), + ), + ), + ), + ), + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))), + ).then(() => self.clients.claim()), + ) +}) + +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'GET') return + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + if (API_BYPASS.some((p) => url.pathname === p || url.pathname.startsWith(p))) return + + // навигация (SPA): старый index.html лучше, чем обрыв сети + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + // кэшируем только оболочку (любой SPA-путь отдаёт index.html) + const copy = response.clone() + caches.open(CACHE).then((cache) => cache.put(request, copy)).catch(() => {}) + return response + }) + .catch(() => + caches.match(request).then((hit) => hit || caches.match('/')), + ), + ) + return + } + + // hashed-ассеты vite: неизменяемые → cache-first + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + caches.match(request).then( + (hit) => + hit || + fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone() + caches.open(CACHE).then((cache) => cache.put(request, copy)).catch(() => {}) + } + return response + }), + ), + ) + return + } + + // мелкая статика: из кэша сразу, фоном обновляем + if (SWR.some((p) => url.pathname === p || url.pathname.startsWith(p))) { + event.respondWith( + caches.open(CACHE).then((cache) => + cache.match(request).then((hit) => { + const refresh = fetch(request) + .then((response) => { + if (response.ok) cache.put(request, response.clone()).catch(() => {}) + return response + }) + .catch(() => hit) + return hit || refresh + }), + ), + ) + } +}) \ No newline at end of file diff --git a/panel/frontend/src/App.vue b/panel/frontend/src/App.vue index 6572967..ce8ec89 100644 --- a/panel/frontend/src/App.vue +++ b/panel/frontend/src/App.vue @@ -110,13 +110,22 @@ authState.value = 'denied' } +// --- Офлайн (PWA): оболочка живёт из кэша SW — объясняем, что сеть пропала +const online = ref(navigator.onLine) +const onOnline = () => { online.value = true } +const onOffline = () => { online.value = false } + onMounted(() => { loadMe() window.addEventListener('ghard:unauthenticated', onUnauthenticated) + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) }) onBeforeUnmount(() => { window.removeEventListener('ghard:unauthenticated', onUnauthenticated) + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) }) @@ -137,6 +146,7 @@

{{ t('login.title') }}

{{ t('login.subtitle') }}

{{ t('login.action') }} +

{{ t('login.offline') }}

@@ -170,6 +180,7 @@ @@ -217,6 +228,13 @@ justify-content: center; } +/* офлайн на экране входа: вход невозможен, скажи почему */ +.login-screen-offline { + margin: 12px 0 0; + font-size: 13px; + opacity: 0.75; +} + /* identity текущего пользователя в футере дровера */ .profile-identity { display: block; @@ -233,6 +251,16 @@ box-sizing: border-box; } +/* офлайн-баннер: жёлто-зелёный акцент палитры, полоска над контентом */ +.offline-bar { + margin-bottom: 16px; + padding: 8px 14px; + border: 1px solid rgba(181, 209, 117, 0.4); + border-radius: 6px; + background: rgba(181, 209, 117, 0.08); + font-size: 14px; +} + /* .tabs у кита — flex-wrap: узкая панель помещается рядом со списком табов. Заставляем панели всегда занимать собственную строку на всю ширину. */ .tabs .tabs-panels { diff --git a/panel/frontend/src/i18n/messages/en.js b/panel/frontend/src/i18n/messages/en.js index 031acd3..f9a63be 100644 --- a/panel/frontend/src/i18n/messages/en.js +++ b/panel/frontend/src/i18n/messages/en.js @@ -4,6 +4,7 @@ // app / shell / nav "app.docTitle": "GHard Monitor", "shell.title": "Monitoring", + "shell.offline": "No connection — showing last loaded data", "nav.dashboard": "Dashboard", "nav.storage": "Storage", "nav.settings": "Settings", @@ -12,6 +13,7 @@ // login screen "login.title": "Sign in", "login.subtitle": "GHard Monitor is protected by gnexus-auth", + "login.offline": "No connection — sign-in needs the panel to be reachable", "login.action": "Sign in", // dashboard diff --git a/panel/frontend/src/i18n/messages/ru.js b/panel/frontend/src/i18n/messages/ru.js index 4523be1..6b5681d 100644 --- a/panel/frontend/src/i18n/messages/ru.js +++ b/panel/frontend/src/i18n/messages/ru.js @@ -3,6 +3,7 @@ // app / shell / nav "app.docTitle": "GHard Monitor", "shell.title": "Мониторинг", + "shell.offline": "Нет сети — показаны последние загруженные данные", "nav.dashboard": "Дашборд", "nav.storage": "Хранилища", "nav.settings": "Настройки", @@ -11,6 +12,7 @@ // login screen "login.title": "Вход", "login.subtitle": "GHard Monitor защищён gnexus-auth", + "login.offline": "Нет сети — для входа нужна связь с панелью", "login.action": "Войти", // dashboard diff --git a/panel/frontend/src/i18n/messages/uk.js b/panel/frontend/src/i18n/messages/uk.js index 582d064..0742ca3 100644 --- a/panel/frontend/src/i18n/messages/uk.js +++ b/panel/frontend/src/i18n/messages/uk.js @@ -3,6 +3,7 @@ // app / shell / nav "app.docTitle": "GHard Monitor", "shell.title": "Моніторинг", + "shell.offline": "Мережі немає — показано останні завантажені дані", "nav.dashboard": "Дашборд", "nav.storage": "Сховища", "nav.settings": "Налаштування", @@ -11,6 +12,7 @@ // login screen "login.title": "Вхід", "login.subtitle": "GHard Monitor захищений gnexus-auth", + "login.offline": "Мережі немає — для входу потрібен зв'язок із панеллю", "login.action": "Увійти", // dashboard diff --git a/panel/frontend/src/main.js b/panel/frontend/src/main.js index 998c561..29d038f 100644 --- a/panel/frontend/src/main.js +++ b/panel/frontend/src/main.js @@ -8,4 +8,24 @@ import App from './App.vue' import { router } from './router.js' -createApp(App).use(router).mount('#app') \ No newline at end of file +createApp(App).use(router).mount('#app') + +// PWA: SW кэширует оболочку и hashed-ассеты (стратегии в public/sw.js). +if ('serviceWorker' in navigator && window.location.protocol !== 'file:') { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/sw.js') + .then(() => + navigator.serviceWorker.ready.then((reg) => { + // hashed-ассеты первой отрисовки: вход + стили + modulepreload чанки + const assets = [ + ...document.querySelectorAll( + 'script[type=module][src], link[rel=stylesheet][href], link[rel=modulepreload][href]', + ), + ].map((el) => el.getAttribute('src') || el.getAttribute('href')) + reg.active?.postMessage({ type: 'shell', urls: assets }) + }), + ) + .catch(() => {}) + }) +} \ No newline at end of file