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
@@ -1,12 +1,21 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
// Generate slug from name
const generateSlug = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50)
}
import {
Dialog,
DialogContent,
@@ -38,8 +47,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
createStatusPage,
updateStatusPage,
getStatusPageUrl,
type StatusPage,
} from "@/lib/statuspages"
import { ExternalLink, RefreshCw } from "lucide-react"
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -162,6 +173,29 @@ export function StatusPageDialog({
const isPending = createMutation.isPending || updateMutation.isPending
// Auto-generate slug from name when creating new status page
const lastAutoSlug = useRef<string>("")
useEffect(() => {
if (isEdit) return // Don't auto-generate in edit mode
const subscription = form.watch((value, { name: fieldName }) => {
if (fieldName === 'name') {
const name = value.name || ''
const currentSlug = form.getValues('slug') || ''
const newSlug = generateSlug(name)
// Only auto-generate if:
// 1. Slug is empty, OR
// 2. Current slug matches the last auto-generated slug (user hasn't manually edited)
if (!currentSlug || currentSlug === lastAutoSlug.current) {
form.setValue('slug', newSlug, { shouldValidate: true })
lastAutoSlug.current = newSlug
}
}
})
return () => subscription.unsubscribe()
}, [form, isEdit])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
@@ -203,12 +237,45 @@ export function StatusPageDialog({
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>URL Slug</FormLabel>
<FormLabel className="flex items-center justify-between">
<span>URL Slug</span>
{!isEdit && form.getValues('name') && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
const newSlug = generateSlug(form.getValues('name') || '')
form.setValue('slug', newSlug, { shouldValidate: true })
lastAutoSlug.current = newSlug
}}
>
<RefreshCw className="mr-1 h-3 w-3" />
Regenerate
</Button>
)}
</FormLabel>
<FormControl>
<Input placeholder="my-services" {...field} />
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground whitespace-nowrap">/status/</span>
<Input {...field} placeholder="my-services" className="flex-1" />
</div>
</FormControl>
<FormDescription>
The URL will be: /status/{field.value}
<FormDescription className="flex items-center justify-between">
<span>Full URL: {typeof window !== 'undefined' ? window.location.origin : ''}{getStatusPageUrl(field.value)}</span>
{field.value && (
<a
href={getStatusPageUrl(field.value)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-3 w-3" />
Preview
</a>
)}
</FormDescription>
<FormMessage />
</FormItem>