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 @@
+
{{ t('shell.offline') }}
@@ -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