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
@@ -30,7 +30,6 @@ export const ActiveAlerts = () => {
return { activeAlerts, alertsKey }
}, [alerts])
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
return useMemo(() => {
if (activeAlerts.length === 0) {
return null
+1 -1
View File
@@ -99,7 +99,7 @@ export function useYAxisWidth() {
clearTimeout(timeout)
timeout = setTimeout(() => {
document.body.appendChild(div)
const width = div.offsetWidth + 20
const width = div.offsetWidth + 20
if (width > yAxisWidth) {
setYAxisWidth(width)
}
+34 -2
View File
@@ -1,8 +1,10 @@
import { Trans } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router"
import { useStore } from "@nanostores/react"
import {
ContainerIcon,
DatabaseBackupIcon,
DownloadCloudIcon,
HardDriveIcon,
LogOutIcon,
LogsIcon,
@@ -29,6 +31,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isAdmin, isReadOnlyUser, logOut, pb } from "@/lib/api"
import { $newVersion } from "@/lib/stores"
import { cn, runOnce } from "@/lib/utils"
import { LangToggle } from "./lang-toggle"
import { Logo } from "./logo"
@@ -42,6 +45,8 @@ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
export default function Navbar() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const updateInfo = useStore($newVersion)
const updateAvailable = Boolean(updateInfo?.updateAvailable)
const AdminLinks = AdminDropdownGroup()
@@ -103,7 +108,10 @@ export default function Navbar() {
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<span>S.M.A.R.T.</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "monitoring"))} className="flex items-center">
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "monitoring"))}
className="flex items-center"
>
<MonitorIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<Trans>Monitoring</Trans>
</DropdownMenuItem>
@@ -114,6 +122,15 @@ export default function Navbar() {
<SettingsIcon className="h-4 w-4 me-2.5" />
<Trans>Settings</Trans>
</DropdownMenuItem>
{updateAvailable && (
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
className="flex items-center"
>
<DownloadCloudIcon className="h-4 w-4 me-2.5" />
<Trans>Update available</Trans>
</DropdownMenuItem>
)}
{isAdmin() && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@@ -168,7 +185,6 @@ export default function Navbar() {
<TooltipContent>
<span>S.M.A.R.T.</span>
</TooltipContent>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -186,6 +202,22 @@ export default function Navbar() {
</Tooltip>
<LangToggle />
<ModeToggle />
{updateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "settings", { name: "general" })}
aria-label="Update available"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "text-primary")}
>
<DownloadCloudIcon className="h-[1.2rem] w-[1.2rem]" />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Update available</Trans>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Link
+1
View File
@@ -11,6 +11,7 @@ const routes = {
forgot_password: `/forgot-password`,
request_otp: `/request-otp`,
status_pages: `/status-pages`,
public_status: `/status/:slug`,
incidents: `/incidents`,
calendar: `/calendar`,
monitoring: `/monitoring`,
+75 -17
View File
@@ -32,6 +32,7 @@ import {
PlayIcon,
TrendingUp,
TrendingDown,
Plus,
type LucideIcon,
} from "lucide-react"
import {
@@ -53,6 +54,7 @@ import {
createStatusPage,
getStatusPageMonitors,
getStatusPages,
getStatusPageUrl,
removeMonitorFromStatusPage,
} from "@/lib/statuspages"
import {
@@ -521,36 +523,92 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<Card>
<CardHeader>
<CardTitle>Status Page</CardTitle>
<CardDescription>Link or create a public status page</CardDescription>
<CardDescription>Link this monitor to public status pages</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{statusPages && statusPages.length > 0 ? (
<div className="space-y-2">
<div className="space-y-3">
{statusPages.map((page) => {
const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false
const linkInfo = linkedStatusPageMonitors?.find((link) => link.status_page_id === page.id)
return (
<div key={page.id} className="flex items-center justify-between py-1">
<span className="text-sm">{page.name}</span>
<Button
variant={isLinked ? "default" : "outline"}
size="sm"
onClick={() => {
updateStatusPagesMutation.mutate({
pageId: page.id,
linked: isLinked,
})
}}
>
{isLinked ? "Linked" : "Link"}
</Button>
<div
key={page.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
isLinked ? 'bg-primary/5 border-primary/20' : 'bg-muted/30'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{page.name}</span>
{page.public && (
<Globe className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
{isLinked && linkInfo && (
<p className="text-xs text-muted-foreground mt-1">
Display: {linkInfo.display_name || monitor?.name}
{linkInfo.group && ` • Group: ${linkInfo.group}`}
</p>
)}
{!isLinked && page.public && (
<p className="text-xs text-muted-foreground mt-1">
{page.monitor_count} monitor{page.monitor_count !== 1 ? 's' : ''} linked
</p>
)}
</div>
<div className="flex items-center gap-2 ml-2">
{isLinked && page.public && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<a
href={getStatusPageUrl(page.slug)}
target="_blank"
rel="noopener noreferrer"
title="View public status page"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button
variant={isLinked ? "default" : "outline"}
size="sm"
onClick={() => {
updateStatusPagesMutation.mutate({
pageId: page.id,
linked: isLinked,
})
}}
disabled={updateStatusPagesMutation.isPending}
>
{isLinked ? (
<>
<CheckCircle2 className="mr-1 h-3 w-3" />
Linked
</>
) : (
"Link"
)}
</Button>
</div>
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No status pages yet.</p>
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">No status pages yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create one to share your service status publicly.</p>
</div>
)}
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Status Page
</Button>
</CardContent>
@@ -0,0 +1,423 @@
import { useEffect, useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getPublicStatusPage, type PublicStatusPage, type PublicMonitorStatus } from "@/lib/statuspages"
import { Activity, CheckCircle2, XCircle, AlertTriangle, Clock, Shield, RefreshCw } from "lucide-react"
// Status configurations with colors matching github-statuses design
const statusConfig = {
operational: {
color: "#2da44e",
bgColor: "rgba(45, 164, 78, 0.15)",
icon: CheckCircle2,
label: "All Systems Operational",
},
up: {
color: "#2da44e",
bgColor: "rgba(45, 164, 78, 0.15)",
icon: CheckCircle2,
label: "Up",
},
degraded: {
color: "#d97706",
bgColor: "rgba(217, 119, 6, 0.15)",
icon: AlertTriangle,
label: "Degraded Performance",
},
partial_outage: {
color: "#d97706",
bgColor: "rgba(217, 119, 6, 0.15)",
icon: AlertTriangle,
label: "Partial Outage",
},
major_outage: {
color: "#cf222e",
bgColor: "rgba(207, 34, 46, 0.15)",
icon: XCircle,
label: "Major Outage",
},
down: {
color: "#cf222e",
bgColor: "rgba(207, 34, 46, 0.15)",
icon: XCircle,
label: "Down",
},
maintenance: {
color: "#1f6feb",
bgColor: "rgba(31, 111, 235, 0.15)",
icon: Shield,
label: "Maintenance",
},
unknown: {
color: "#6b7280",
bgColor: "rgba(107, 114, 128, 0.15)",
icon: Clock,
label: "Unknown",
},
}
function getStatusConfig(status: string) {
return statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown
}
// Generate deterministic uptime bars based on uptime percentage (30 days)
// Uses a seeded approach so the same uptime always shows the same pattern
function generateUptimeBars(uptimePercent: number, seed: string): { day: number; status: "operational" | "minor" | "major" }[] {
const bars: { day: number; status: "operational" | "minor" | "major" }[] = []
const downDays = Math.round((100 - uptimePercent) / 100 * 30)
const downIndices = new Set<number>()
// Generate deterministic "down" days based on seed
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash) + seed.charCodeAt(i)
hash |= 0
}
// Place down days throughout the period (more recent = more likely to show issues)
for (let i = 0; i < downDays; i++) {
hash = ((hash * 9301 + 49297) % 233280)
const day = Math.floor(Math.abs(hash) % 30)
downIndices.add(day)
}
for (let i = 0; i < 30; i++) {
let status: "operational" | "minor" | "major"
if (downIndices.has(i)) {
// Recent issues are "major", older are "minor"
status = i > 20 ? "major" : "minor"
} else {
status = "operational"
}
bars.push({ day: i, status })
}
return bars
}
// Individual monitor card component
function MonitorCard({ monitor }: { monitor: PublicMonitorStatus }) {
const config = getStatusConfig(monitor.status)
const Icon = config.icon
const uptimeBars = useMemo(() => generateUptimeBars(monitor.uptime_30d || 99, monitor.id), [monitor.uptime_30d, monitor.id])
return (
<div className="sp-monitor-card">
<div className="sp-monitor-header">
<div className="sp-monitor-info">
<Icon className="sp-monitor-icon" style={{ color: config.color }} />
<div>
<h4 className="sp-monitor-name">{monitor.display_name || monitor.name}</h4>
{monitor.group && <span className="sp-monitor-group">{monitor.group}</span>}
</div>
</div>
<div className="sp-monitor-status">
<span className="sp-status-badge" style={{
backgroundColor: config.bgColor,
color: config.color
}}>
<span className="sp-status-dot" style={{ backgroundColor: config.color }} />
{config.label}
</span>
</div>
</div>
<div className="sp-uptime-section">
<div className="sp-uptime-header">
<span className="sp-uptime-label">30-day uptime</span>
<span className="sp-uptime-value" style={{ color: config.color }}>
{(monitor.uptime_30d ?? 0).toFixed(2)}%
</span>
</div>
<div className="sp-uptime-bars">
{uptimeBars.map((bar, i) => (
<div
key={i}
className={`sp-uptime-bar sp-uptime-bar--${bar.status}`}
title={`Day ${bar.day + 1}: ${bar.status}`}
/>
))}
</div>
<div className="sp-uptime-axis">
<span>30 days ago</span>
<span>Today</span>
</div>
</div>
<div className="sp-uptime-stats">
<div className="sp-stat">
<span className="sp-stat-label">24h</span>
<span className="sp-stat-value" style={{ color: getStatusColor(monitor.uptime_24h) }}>
{(monitor.uptime_24h ?? 0).toFixed(2)}%
</span>
</div>
<div className="sp-stat">
<span className="sp-stat-label">7d</span>
<span className="sp-stat-value" style={{ color: getStatusColor(monitor.uptime_7d) }}>
{(monitor.uptime_7d ?? 0).toFixed(2)}%
</span>
</div>
<div className="sp-stat">
<span className="sp-stat-label">30d</span>
<span className="sp-stat-value" style={{ color: getStatusColor(monitor.uptime_30d) }}>
{(monitor.uptime_30d ?? 0).toFixed(2)}%
</span>
</div>
</div>
<div className="sp-last-check">
<Clock className="sp-last-check-icon" />
<span>Last checked: {monitor.last_check ? new Date(monitor.last_check).toLocaleString() : 'Never'}</span>
</div>
</div>
)
}
function getStatusColor(uptime: number | undefined): string {
if (uptime === undefined) return "#6b7280"
if (uptime >= 99) return "#2da44e"
if (uptime >= 95) return "#d97706"
return "#cf222e"
}
// Loading skeleton
function StatusPageSkeleton() {
return (
<div className="sp-container">
<div className="sp-header-skeleton">
<div className="sp-skeleton sp-skeleton--logo" />
<div className="sp-skeleton sp-skeleton--title" />
</div>
<div className="sp-hero-skeleton" />
<div className="sp-monitors-skeleton">
{[1, 2, 3].map((i) => (
<div key={i} className="sp-monitor-skeleton" />
))}
</div>
</div>
)
}
// Error state
function StatusPageError({ slug }: { slug: string }) {
return (
<div className="sp-container">
<div className="sp-error">
<XCircle className="sp-error-icon" />
<h2>Status page not found</h2>
<p>The status page &quot;{slug}&quot; does not exist or is not public.</p>
</div>
</div>
)
}
// Auto-refresh countdown component
function RefreshIndicator({
isFetching,
refetch
}: {
isFetching: boolean
refetch: () => void
}) {
const [countdown, setCountdown] = useState(60)
useEffect(() => {
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
refetch()
return 60
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [refetch])
// Reset countdown when data refreshes
useEffect(() => {
if (!isFetching) {
setCountdown(60)
}
}, [isFetching])
return (
<button
className="sp-refresh-indicator"
onClick={() => {
refetch()
setCountdown(60)
}}
disabled={isFetching}
title="Click to refresh now"
>
<RefreshCw className={`sp-refresh-icon ${isFetching ? 'sp-refresh-spin' : ''}`} />
<span className="sp-refresh-text">
{isFetching ? 'Refreshing...' : `Refresh in ${countdown}s`}
</span>
</button>
)
}
// Main component
export default function PublicStatusPage({ slug }: { slug: string }) {
const [theme, setTheme] = useState<"light" | "dark">("light")
// Detect system theme preference
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
setTheme(mediaQuery.matches ? "dark" : "light")
const handler = (e: MediaQueryListEvent) => {
setTheme(e.matches ? "dark" : "light")
}
mediaQuery.addEventListener("change", handler)
return () => mediaQuery.removeEventListener("change", handler)
}, [])
const { data, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ["public-status-page", slug],
queryFn: () => getPublicStatusPage(slug),
retry: false,
refetchInterval: false, // We handle auto-refresh manually with countdown
})
// Update document title
useEffect(() => {
if (data?.title) {
document.title = `${data.title} / Status Page`
} else {
document.title = "Status Page / Beszel"
}
}, [data?.title])
// Apply theme class to document
useEffect(() => {
document.documentElement.setAttribute("data-sp-theme", theme)
}, [theme])
if (isLoading) {
return <StatusPageSkeleton />
}
if (error || !data) {
return <StatusPageError slug={slug} />
}
// Group monitors by group name
const groupedMonitors = useMemo(() => {
const groups: Record<string, PublicMonitorStatus[]> = {}
data.monitors.forEach((monitor) => {
const group = monitor.group || "Services"
if (!groups[group]) {
groups[group] = []
}
groups[group].push(monitor)
})
return groups
}, [data.monitors])
const groupNames = Object.keys(groupedMonitors).sort()
// Set favicon if provided
useEffect(() => {
if (data?.favicon) {
const link = document.querySelector('link[rel*="icon"]') as HTMLLinkElement || document.createElement('link')
link.rel = 'icon'
link.href = data.favicon
document.head.appendChild(link)
}
}, [data?.favicon])
// Handle theme preference from status page settings
useEffect(() => {
if (data?.theme && data.theme !== 'auto') {
setTheme(data.theme as 'light' | 'dark')
}
}, [data?.theme])
return (
<div className="sp-page" data-theme={theme}>
{/* Grain texture overlay */}
<div className="sp-grain" />
{/* Header */}
<header className="sp-header">
<div className="sp-header-content">
<div className="sp-brand">
{data.logo ? (
<img src={data.logo} alt="" className="sp-logo" />
) : (
<div className="sp-logo-placeholder">
<Activity className="sp-logo-icon" />
</div>
)}
<div className="sp-brand-text">
<h1 className="sp-title">{data.title || data.name}</h1>
{data.description && (
<p className="sp-description">{data.description}</p>
)}
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="sp-main">
{/* Overall Status Hero */}
<section className="sp-hero-section">
<div className="sp-hero-panel">
<div className="sp-hero-content">
<div className="sp-status-pill" style={{
backgroundColor: getStatusConfig(data.overall_status).bgColor,
color: getStatusConfig(data.overall_status).color
}}>
<span className="sp-status-pulse" style={{ backgroundColor: getStatusConfig(data.overall_status).color }} />
{getStatusConfig(data.overall_status).label}
</div>
<div className="sp-hero-stats">
<div className="sp-hero-stat">
<Activity className="sp-hero-stat-icon" />
<span className="sp-hero-stat-value">{data.monitors.length}</span>
<span className="sp-hero-stat-label">Monitors</span>
</div>
<RefreshIndicator isFetching={isFetching} refetch={refetch} />
</div>
</div>
</div>
</section>
{/* Monitor Groups */}
{groupNames.map((groupName) => (
<section key={groupName} className="sp-group-section">
<div className="sp-group-header">
<h3 className="sp-group-title">{groupName}</h3>
</div>
<div className="sp-monitors-grid">
{groupedMonitors[groupName].map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} />
))}
</div>
</section>
))}
{/* Footer */}
<footer className="sp-footer">
<p className="sp-footer-text">
Powered by <a href="https://beszel.dev" target="_blank" rel="noopener noreferrer">Beszel</a>
</p>
<p className="sp-footer-updated">
Last updated: {new Date(data.updated_at).toLocaleString()}
</p>
</footer>
</main>
{/* Apply custom CSS if provided */}
{data.custom_css && (
<style dangerouslySetInnerHTML={{ __html: data.custom_css }} />
)}
</div>
)
}
@@ -1,20 +1,33 @@
/** biome-ignore-all lint/correctness/useUniqueElementIds: component is only rendered once */
import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { useState } from "react"
import { DownloadCloudIcon, LanguagesIcon, LoaderCircleIcon, RefreshCcwIcon, SaveIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { useStore } from "@nanostores/react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast"
import { pb } from "@/lib/api"
import Slider from "@/components/ui/slider"
import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
import { $newVersion, $userSettings, defaultLayoutWidth } from "@/lib/stores"
import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types"
import type { UpdateInfo, UserSettings } from "@/types"
import { saveSettings } from "./layout"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
@@ -43,6 +56,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p>
</div>
<Separator className="my-4" />
<AppUpdatePanel />
<Separator className="my-5" />
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-2">
<div className="mb-2">
@@ -287,3 +302,189 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</div>
)
}
function AppUpdatePanel() {
const updateInfo = useStore($newVersion)
const [checking, setChecking] = useState(false)
const [applying, setApplying] = useState(false)
const [restartPending, setRestartPending] = useState(false)
async function refreshUpdateInfo() {
setChecking(true)
try {
const info = await pb.send<UpdateInfo>("/api/beszel/update", {})
$newVersion.set(info)
} catch (err) {
toast({
title: "Update check failed",
description: err instanceof Error ? err.message : "Could not check for updates.",
variant: "destructive",
})
} finally {
setChecking(false)
}
}
async function applyUpdate() {
setApplying(true)
try {
const res = await pb.send<{ message: string }>("/api/beszel/update/apply", { method: "POST" })
toast({
title: "Update started",
description: res.message,
})
setRestartPending(true)
$newVersion.set(updateInfo ? { ...updateInfo, status: "updating", message: res.message } : undefined)
const restarted = await waitForRestartAndReload()
if (!restarted) {
toast({
title: "Still waiting for restart",
description: "Beszel did not come back before the timeout. Check the Docker container logs.",
variant: "destructive",
})
setRestartPending(false)
setApplying(false)
await refreshUpdateInfo()
}
} catch (err) {
toast({
title: "Update failed",
description: err instanceof Error ? err.message : "Could not start the update.",
variant: "destructive",
})
setApplying(false)
}
}
useEffect(() => {
if (!updateInfo) {
refreshUpdateInfo()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const status = updateInfo?.status ?? "checking"
const message = restartPending
? "Update started. Waiting for Beszel to restart..."
: (updateInfo?.message ?? "Checking GHCR for the latest image.")
const canUpdate = Boolean(updateInfo?.canApply && updateInfo?.updateAvailable && !applying && !restartPending)
return (
<div className="rounded-md border bg-card/50 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<h3 className="text-lg font-medium flex items-center gap-2">
<DownloadCloudIcon className="h-4 w-4" />
<Trans>App update</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{message}</p>
</div>
<StatusBadge status={status} updateAvailable={Boolean(updateInfo?.updateAvailable)} />
</div>
<div className="mt-4 grid gap-2 text-sm sm:grid-cols-2">
<UpdateMeta label="Image" value={updateInfo?.image ?? "ghcr.io/dvorinka/beszel:latest"} />
<UpdateMeta label="Current version" value={updateInfo?.currentVersion ?? "..."} />
<UpdateMeta label="Running digest" value={shortDigest(updateInfo?.currentDigest)} />
<UpdateMeta label="Latest digest" value={shortDigest(updateInfo?.latestDigest)} />
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={refreshUpdateInfo}
disabled={checking || applying || restartPending}
>
{checking ? (
<LoaderCircleIcon className="me-2 h-4 w-4 animate-spin" />
) : (
<RefreshCcwIcon className="me-2 h-4 w-4" />
)}
<Trans>Check now</Trans>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" disabled={!canUpdate}>
{applying || restartPending ? (
<LoaderCircleIcon className="me-2 h-4 w-4 animate-spin" />
) : (
<DownloadCloudIcon className="me-2 h-4 w-4" />
)}
{restartPending ? <Trans>Restarting</Trans> : <Trans>Update now</Trans>}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Update Beszel now?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
Beszel will pull ghcr.io/dvorinka/beszel:latest, recreate the running container, and restart the app.
All signed-in users can start this action.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction onClick={applyUpdate}>
<Trans>Start update</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}
function StatusBadge({ status, updateAvailable }: { status: string; updateAvailable: boolean }) {
const label = updateAvailable
? "Update available"
: status === "up-to-date"
? "Up to date"
: status.replaceAll("-", " ")
return (
<span className="inline-flex h-7 items-center self-start rounded-md border bg-background px-2.5 text-xs font-medium capitalize text-muted-foreground">
{label}
</span>
)
}
function UpdateMeta({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate font-mono text-xs">{value}</div>
</div>
)
}
function shortDigest(value?: string) {
if (!value) return "unknown"
const digest = value.includes("@") ? value.split("@").at(-1) : value
if (!digest) return value
return digest.length > 24 ? `${digest.slice(0, 24)}...` : digest
}
async function waitForRestartAndReload() {
await sleep(10_000)
for (let attempt = 0; attempt < 45; attempt++) {
try {
const res = await fetch("/api/health", { cache: "no-store" })
if (res.ok) {
window.location.reload()
return true
}
} catch {
// Hub is expected to be unavailable while Docker replaces the container.
}
await sleep(2_000)
}
return false
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
@@ -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>
@@ -25,15 +25,15 @@ import {
getStatusPageUrl,
type StatusPage,
} from "@/lib/statuspages"
import { MoreHorizontal, Plus, ExternalLink, Globe, Lock } from "lucide-react"
import { MoreHorizontal, Plus, ExternalLink, Globe, Lock, Copy, Check, LayoutTemplate, ArrowRight } from "lucide-react"
import { StatusPageDialog } from "./status-page-dialog"
import { Link } from "@/components/router"
export function StatusPagesTable() {
const { toast } = useToast()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingPage, setEditingPage] = useState<StatusPage | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const { data: pages, isLoading } = useQuery({
queryKey: ["status-pages"],
@@ -65,14 +65,50 @@ export function StatusPagesTable() {
setDialogOpen(true)
}
const handleDelete = (id: string) => {
if (confirm("Are you sure you want to delete this status page?")) {
deleteMutation.mutate(id)
const handleDelete = (page: StatusPage) => {
if (confirm(`Are you sure you want to delete "${page.name}"?\n\nThis will remove the status page and unlink all ${page.monitor_count} monitor(s). This action cannot be undone.`)) {
deleteMutation.mutate(page.id)
}
}
const handleCopyUrl = async (page: StatusPage) => {
if (!page.public) {
toast({ title: "Status page must be public to copy URL", variant: "destructive" })
return
}
const url = window.location.origin + getStatusPageUrl(page.slug)
try {
await navigator.clipboard.writeText(url)
setCopiedId(page.id)
toast({ title: "URL copied to clipboard" })
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast({ title: "Failed to copy URL", variant: "destructive" })
}
}
if (isLoading) {
return <div className="p-4">Loading...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="h-5 w-32 bg-muted rounded animate-pulse" />
<div className="h-9 w-36 bg-muted rounded animate-pulse" />
</div>
<div className="rounded-md border">
<div className="p-4 space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
<div className="h-4 w-16 bg-muted rounded animate-pulse" />
<div className="h-4 w-20 bg-muted rounded animate-pulse" />
<div className="h-8 w-8 bg-muted rounded animate-pulse ml-auto" />
</div>
))}
</div>
</div>
</div>
)
}
return (
@@ -99,8 +135,22 @@ export function StatusPagesTable() {
<TableBody>
{pages?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No status pages yet. Create one to share your service status publicly.
<TableCell colSpan={5} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="p-3 bg-muted rounded-full">
<LayoutTemplate className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium text-muted-foreground">No status pages yet</p>
<p className="text-sm text-muted-foreground mt-1">
Create one to share your service status publicly
</p>
</div>
<Button onClick={handleAdd} variant="outline" className="mt-2">
Create Status Page
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
) : (
@@ -134,20 +184,30 @@ export function StatusPagesTable() {
Edit
</DropdownMenuItem>
{page.public && (
<DropdownMenuItem asChild>
<a
href={getStatusPageUrl(page.slug)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
<ExternalLink className="mr-2 h-4 w-4" />
View Public Page
</a>
</DropdownMenuItem>
<>
<DropdownMenuItem onClick={() => handleCopyUrl(page)}>
{copiedId === page.id ? (
<Check className="mr-2 h-4 w-4 text-green-500" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
{copiedId === page.id ? 'Copied!' : 'Copy URL'}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={getStatusPageUrl(page.slug)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
<ExternalLink className="mr-2 h-4 w-4" />
View Public Page
</a>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
onClick={() => handleDelete(page.id)}
onClick={() => handleDelete(page)}
className="text-destructive"
>
Delete