feat(hub): implement native in-app container updates

Introduces the ability for registered users to trigger Beszel container updates directly from the web interface.

- Added `app_update` logic to the hub to pull the latest image from GHCR and recreate the container.
- Implemented `/api/beszel/update` and `/api/beszel/update/apply` endpoints.
- Added a new `AppUpdatePanel` in the settings UI to check for and apply updates.
- Added update notifications in the navbar and settings.
- Updated `docker-compose.yml` and `README.md` to include the required Docker socket mount for update functionality.
- Added a new public status page route that bypasses authentication.
- Refactored several TypeScript interfaces to replace `any` with `unknown` or specific types for better type safety.
- Updated localization files to support new update-related strings.
This commit is contained in:
Tomas Dvorak
2026-04-30 14:38:13 +02:00
parent 67254f89a9
commit 7727be166b
63 changed files with 582907 additions and 636 deletions
+67 -67
View File
@@ -1,69 +1,69 @@
{
"name": "Beszel - Monitoring Dashboard",
"short_name": "Beszel",
"description": "All-in-one monitoring dashboard for devices, websites, and domains",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#171717",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/favicon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/favicon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/favicon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/favicon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/favicon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"categories": ["utilities", "productivity"],
"screenshots": [
{
"src": "/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshot-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
]
"name": "Beszel - Monitoring Dashboard",
"short_name": "Beszel",
"description": "All-in-one monitoring dashboard for devices, websites, and domains",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#171717",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/favicon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/favicon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/favicon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/favicon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/favicon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"categories": ["utilities", "productivity"],
"screenshots": [
{
"src": "/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshot-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
]
}
+1 -1
View File
@@ -7,7 +7,7 @@
"type": "image/png"
}
],
"start_url": "../",
"start_url": "../",
"display": "standalone",
"background_color": "#202225",
"theme_color": "#202225"
+126 -136
View File
@@ -1,166 +1,156 @@
// Beszel Service Worker
const CACHE_NAME = 'beszel-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/favicon.svg',
];
const CACHE_NAME = "beszel-v1"
const STATIC_ASSETS = ["/", "/index.html", "/manifest.json", "/favicon.ico", "/favicon.svg"]
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(STATIC_ASSETS)
})
.then(() => self.skipWaiting())
)
})
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
.then(() => self.clients.claim())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)))
})
.then(() => self.clients.claim())
)
})
// Fetch event - serve from cache or network
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
self.addEventListener("fetch", (event) => {
const { request } = event
const url = new URL(request.url)
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip non-GET requests
if (request.method !== "GET") {
return
}
// Skip API requests
if (url.pathname.startsWith('/api/')) {
return;
}
// Skip API requests
if (url.pathname.startsWith("/api/")) {
return
}
// Skip PocketBase API
if (url.pathname.startsWith('/_/')) {
return;
}
// Skip PocketBase API
if (url.pathname.startsWith("/_/")) {
return
}
event.respondWith(
caches.match(request).then((cached) => {
if (cached) {
// Return cached version and update in background
fetch(request).then((response) => {
if (response.ok) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, response);
});
}
});
return cached;
}
event.respondWith(
caches
.match(request)
.then((cached) => {
if (cached) {
// Return cached version and update in background
fetch(request).then((response) => {
if (response.ok) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, response)
})
}
})
return cached
}
// Fetch from network
return fetch(request).then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Fetch from network
return fetch(request).then((response) => {
if (!response || response.status !== 200 || response.type !== "basic") {
return response
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
const responseToCache = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseToCache)
})
return response;
});
}).catch(() => {
// Return offline page if available
return caches.match('/offline.html');
})
);
});
return response
})
})
.catch(() => {
// Return offline page if available
return caches.match("/offline.html")
})
)
})
// Push notification event
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
self.addEventListener("push", (event) => {
if (!event.data) {
return
}
const data = event.data.json();
const options = {
body: data.body || 'New notification',
icon: data.icon || '/favicon-192x192.png',
badge: data.badge || '/favicon-72x72.png',
tag: data.tag || 'default',
requireInteraction: data.requireInteraction || false,
data: data.data || {},
actions: data.actions || [
{ action: 'open', title: 'Open' },
{ action: 'close', title: 'Dismiss' }
]
};
const data = event.data.json()
const options = {
body: data.body || "New notification",
icon: data.icon || "/favicon-192x192.png",
badge: data.badge || "/favicon-72x72.png",
tag: data.tag || "default",
requireInteraction: data.requireInteraction || false,
data: data.data || {},
actions: data.actions || [
{ action: "open", title: "Open" },
{ action: "close", title: "Dismiss" },
],
}
event.waitUntil(
self.registration.showNotification(
data.title || 'Beszel Alert',
options
)
);
});
event.waitUntil(self.registration.showNotification(data.title || "Beszel Alert", options))
})
// Notification click event
self.addEventListener('notificationclick', (event) => {
event.notification.close();
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const { action, data } = event.notification;
const urlToOpen = data?.url || '/';
const { data } = event.notification
const urlToOpen = data?.url || "/"
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window if not found
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && "focus" in client) {
return client.focus()
}
}
// Open new window if not found
if (clients.openWindow) {
return clients.openWindow(urlToOpen)
}
})
)
})
// Background sync for offline support
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync());
}
});
self.addEventListener("sync", (event) => {
if (event.tag === "background-sync") {
event.waitUntil(doBackgroundSync())
}
})
async function doBackgroundSync() {
// Retry any pending API requests stored in IndexedDB
// This is a placeholder - implement with actual pending request logic
console.log('Background sync executed');
function doBackgroundSync() {
// Retry any pending API requests stored in IndexedDB
// This is a placeholder - implement with actual pending request logic
console.log("Background sync executed")
}
// Periodic background sync (if supported)
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'update-check') {
event.waitUntil(checkForUpdates());
}
});
self.addEventListener("periodicsync", (event) => {
if (event.tag === "update-check") {
event.waitUntil(checkForUpdates())
}
})
async function checkForUpdates() {
// Check for new data and show notifications if needed
console.log('Periodic sync executed');
function checkForUpdates() {
// Check for new data and show notifications if needed
console.log("Periodic sync executed")
}