feat(hub): show "update available" notification in hub web UI (#1830)

* refactor, make opt-in, and deprecate /api/beszel/getkey in favor of /api/beszel/info

---------

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-03-22 22:23:54 +01:00
committed by GitHub
parent c159eaacd1
commit b2fd50211e
7 changed files with 125 additions and 24 deletions

View File

@@ -110,21 +110,13 @@ func (p *updater) update() (updated bool, err error) {
} }
var latest *release var latest *release
var useMirror bool
// Determine the API endpoint based on UseMirror flag apiURL := getApiURL(p.config.UseMirror, p.config.Owner, p.config.Repo)
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo)
if p.config.UseMirror { if p.config.UseMirror {
useMirror = true
apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo)
ColorPrint(ColorYellow, "Using mirror for update.") ColorPrint(ColorYellow, "Using mirror for update.")
} }
latest, err = fetchLatestRelease( latest, err = FetchLatestRelease(p.config.Context, p.config.HttpClient, apiURL)
p.config.Context,
p.config.HttpClient,
apiURL,
)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -150,7 +142,7 @@ func (p *updater) update() (updated bool, err error) {
// download the release asset // download the release asset
assetPath := filepath.Join(releaseDir, asset.Name) assetPath := filepath.Join(releaseDir, asset.Name)
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil { if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
return false, err return false, err
} }
@@ -226,11 +218,11 @@ func (p *updater) update() (updated bool, err error) {
return true, nil return true, nil
} }
func fetchLatestRelease( func FetchLatestRelease(ctx context.Context, client HttpClient, url string) (*release, error) {
ctx context.Context, if url == "" {
client HttpClient, url = getApiURL(false, "henrygd", "beszel")
url string, }
) (*release, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -375,3 +367,10 @@ func isGlibc() bool {
} }
return false return false
} }
func getApiURL(useMirror bool, owner, repo string) string {
if useMirror {
return fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", owner, repo)
}
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
}

View File

@@ -2,6 +2,7 @@
package hub package hub
import ( import (
"context"
"crypto/ed25519" "crypto/ed25519"
"encoding/pem" "encoding/pem"
"errors" "errors"
@@ -14,8 +15,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/blang/semver"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/heartbeat"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
@@ -191,6 +194,36 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
} }
} }
type UpdateInfo struct {
lastCheck time.Time
Version string `json:"v"`
Url string `json:"url"`
}
func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
if time.Since(info.lastCheck) < 6*time.Hour {
return e.JSON(http.StatusOK, info)
}
latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "")
if err != nil {
return err
}
currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v"))
if err != nil {
return err
}
latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v"))
if err != nil {
return err
}
info.lastCheck = time.Now()
if latestVersion.GT(currentVersion) {
info.Version = strings.TrimPrefix(latestRelease.Tag, "v")
info.Url = latestRelease.Url
}
return e.JSON(http.StatusOK, info)
}
// registerApiRoutes registers custom API routes // registerApiRoutes registers custom API routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes // auth protected routes
@@ -209,9 +242,13 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0}) return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
}) })
// get public key and version // get public key and version
apiAuth.GET("/getkey", func(e *core.RequestEvent) error { apiAuth.GET("/info", h.getInfo)
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version}) apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
}) // check for updates
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
var updateInfo UpdateInfo
apiAuth.GET("/update", updateInfo.getUpdate)
}
// send test notification // send test notification
apiAuth.POST("/test-notification", h.SendTestNotification) apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test // heartbeat status and test
@@ -240,6 +277,23 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
return nil return nil
} }
// getInfo returns data needed by authenticated users, such as the public key and current version
func (h *Hub) getInfo(e *core.RequestEvent) error {
type infoResponse struct {
Key string `json:"key"`
Version string `json:"v"`
CheckUpdate bool `json:"cu"`
}
info := infoResponse{
Key: h.pubKey,
Version: beszel.Version,
}
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
info.CheckUpdate = true
}
return e.JSON(http.StatusOK, info)
}
// GetUniversalToken handles the universal token API endpoint (create, read, delete) // GetUniversalToken handles the universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error { func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
tokenMap := universalTokenMap.GetMap() tokenMap := universalTokenMap.GetMap()

View File

@@ -606,6 +606,17 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"\"key\":", "\"v\":"}, ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{
Name: "GET /info - should return the same as /getkey",
Method: http.MethodGet,
URL: "/api/beszel/info",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{ {
Name: "GET /first-run - no auth should succeed", Name: "GET /first-run - no auth should succeed",
Method: http.MethodGet, Method: http.MethodGet,

View File

@@ -1,7 +1,11 @@
import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react" import { GithubIcon } from "lucide-react"
import { $newVersion } from "@/lib/stores"
import { Separator } from "./ui/separator" import { Separator } from "./ui/separator"
import { Trans } from "@lingui/react/macro"
export function FooterRepoLink() { export function FooterRepoLink() {
const newVersion = useStore($newVersion)
return ( return (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a <a
@@ -21,6 +25,19 @@ export function FooterRepoLink() {
> >
Beszel {globalThis.BESZEL.HUB_VERSION} Beszel {globalThis.BESZEL.HUB_VERSION}
</a> </a>
{newVersion?.v && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href={newVersion.url}
target="_blank"
className="text-yellow-500 hover:text-yellow-400 duration-75"
rel="noopener"
>
<Trans context="New version available">{newVersion.v} available</Trans>
</a>
</>
)}
</div> </div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores" import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types" import type { AlertMap, ChartTimes, SystemRecord, UpdateInfo, UserSettings } from "@/types"
import { pb } from "./api" import { pb } from "./api"
import { Unit } from "./enums" import { Unit } from "./enums"
@@ -28,6 +28,9 @@ export const $alerts = map<AlertMap>({})
/** SSH public key */ /** SSH public key */
export const $publicKey = atom("") export const $publicKey = atom("")
/** New version info if an update is available, otherwise undefined */
export const $newVersion = atom<UpdateInfo | undefined>()
/** Chart time period */ /** Chart time period */
export const $chartTime = atom<ChartTimes>("1h") export const $chartTime = atom<ChartTimes>("1h")

View File

@@ -12,17 +12,19 @@ import Settings from "@/components/routes/settings/layout.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx" import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Toaster } from "@/components/ui/toaster.tsx" import { Toaster } from "@/components/ui/toaster.tsx"
import { alertManager } from "@/lib/alerts" import { alertManager } from "@/lib/alerts"
import { pb, updateUserSettings } from "@/lib/api.ts" import { isAdmin, pb, updateUserSettings } from "@/lib/api.ts"
import { dynamicActivate, getLocale } from "@/lib/i18n" import { dynamicActivate, getLocale } from "@/lib/i18n"
import { import {
$authenticated, $authenticated,
$copyContent, $copyContent,
$direction, $direction,
$newVersion,
$publicKey, $publicKey,
$userSettings, $userSettings,
defaultLayoutWidth, defaultLayoutWidth,
} from "@/lib/stores.ts" } from "@/lib/stores.ts"
import * as systemsManager from "@/lib/systemsManager.ts" import * as systemsManager from "@/lib/systemsManager.ts"
import type { BeszelInfo, UpdateInfo } from "./types"
const LoginPage = lazy(() => import("@/components/login/login.tsx")) const LoginPage = lazy(() => import("@/components/login/login.tsx"))
const Home = lazy(() => import("@/components/routes/home.tsx")) const Home = lazy(() => import("@/components/routes/home.tsx"))
@@ -39,9 +41,13 @@ const App = memo(() => {
pb.authStore.onChange(() => { pb.authStore.onChange(() => {
$authenticated.set(pb.authStore.isValid) $authenticated.set(pb.authStore.isValid)
}) })
// get version / public key // get general info for authenticated users, such as public key and version
pb.send("/api/beszel/getkey", {}).then((data) => { pb.send<BeszelInfo>("/api/beszel/info", {}).then((data) => {
$publicKey.set(data.key) $publicKey.set(data.key)
// check for updates if enabled
if (data.cu && isAdmin()) {
pb.send<UpdateInfo>("/api/beszel/update", {}).then($newVersion.set)
}
}) })
// get user settings // get user settings
updateUserSettings() updateUserSettings()

View File

@@ -525,4 +525,15 @@ export interface SystemdServiceDetails {
WantedBy: any[]; WantedBy: any[];
Wants: string[]; Wants: string[];
WantsMountsFor: any[]; WantsMountsFor: any[];
} }
export interface BeszelInfo {
key: string // public key
v: string // version
cu: boolean // check updates
}
export interface UpdateInfo {
v: string // new version
url: string // url to new version
}