From b2fd50211ef82361583c4dc24ca84f1a4d34a8ca Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Sun, 22 Mar 2026 22:23:54 +0100 Subject: [PATCH] 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 --- internal/ghupdate/ghupdate.go | 31 +++++----- internal/hub/hub.go | 60 ++++++++++++++++++- internal/hub/hub_test.go | 11 ++++ .../site/src/components/footer-repo-link.tsx | 17 ++++++ internal/site/src/lib/stores.ts | 5 +- internal/site/src/main.tsx | 12 +++- internal/site/src/types.d.ts | 13 +++- 7 files changed, 125 insertions(+), 24 deletions(-) diff --git a/internal/ghupdate/ghupdate.go b/internal/ghupdate/ghupdate.go index 6a068a2b..deb61b01 100644 --- a/internal/ghupdate/ghupdate.go +++ b/internal/ghupdate/ghupdate.go @@ -110,21 +110,13 @@ func (p *updater) update() (updated bool, err error) { } var latest *release - var useMirror bool - // Determine the API endpoint based on UseMirror flag - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo) + apiURL := getApiURL(p.config.UseMirror, p.config.Owner, p.config.Repo) 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.") } - latest, err = fetchLatestRelease( - p.config.Context, - p.config.HttpClient, - apiURL, - ) + latest, err = FetchLatestRelease(p.config.Context, p.config.HttpClient, apiURL) if err != nil { return false, err } @@ -150,7 +142,7 @@ func (p *updater) update() (updated bool, err error) { // download the release asset 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 } @@ -226,11 +218,11 @@ func (p *updater) update() (updated bool, err error) { return true, nil } -func fetchLatestRelease( - ctx context.Context, - client HttpClient, - url string, -) (*release, error) { +func FetchLatestRelease(ctx context.Context, client HttpClient, url string) (*release, error) { + if url == "" { + url = getApiURL(false, "henrygd", "beszel") + } + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err @@ -375,3 +367,10 @@ func isGlibc() bool { } 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) +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 4f9f4be6..5a9761da 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -2,6 +2,7 @@ package hub import ( + "context" "crypto/ed25519" "encoding/pem" "errors" @@ -14,8 +15,10 @@ import ( "strings" "time" + "github.com/blang/semver" "github.com/henrygd/beszel" "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/heartbeat" "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 func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // 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}) }) // get public key and version - apiAuth.GET("/getkey", func(e *core.RequestEvent) error { - return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version}) - }) + apiAuth.GET("/info", h.getInfo) + 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 apiAuth.POST("/test-notification", h.SendTestNotification) // heartbeat status and test @@ -240,6 +277,23 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { 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) func (h *Hub) getUniversalToken(e *core.RequestEvent) error { tokenMap := universalTokenMap.GetMap() diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index a7a36e19..a43243ca 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -606,6 +606,17 @@ func TestApiRoutesAuthentication(t *testing.T) { ExpectedContent: []string{"\"key\":", "\"v\":"}, 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", Method: http.MethodGet, diff --git a/internal/site/src/components/footer-repo-link.tsx b/internal/site/src/components/footer-repo-link.tsx index 70368d43..2106b120 100644 --- a/internal/site/src/components/footer-repo-link.tsx +++ b/internal/site/src/components/footer-repo-link.tsx @@ -1,7 +1,11 @@ +import { useStore } from "@nanostores/react" import { GithubIcon } from "lucide-react" +import { $newVersion } from "@/lib/stores" import { Separator } from "./ui/separator" +import { Trans } from "@lingui/react/macro" export function FooterRepoLink() { + const newVersion = useStore($newVersion) return (
Beszel {globalThis.BESZEL.HUB_VERSION} + {newVersion?.v && ( + <> + + + {newVersion.v} available + + + )}
) } diff --git a/internal/site/src/lib/stores.ts b/internal/site/src/lib/stores.ts index 5d96150b..fd77788a 100644 --- a/internal/site/src/lib/stores.ts +++ b/internal/site/src/lib/stores.ts @@ -1,5 +1,5 @@ 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 { Unit } from "./enums" @@ -28,6 +28,9 @@ export const $alerts = map({}) /** SSH public key */ export const $publicKey = atom("") +/** New version info if an update is available, otherwise undefined */ +export const $newVersion = atom() + /** Chart time period */ export const $chartTime = atom("1h") diff --git a/internal/site/src/main.tsx b/internal/site/src/main.tsx index 5c06021a..5f005c0e 100644 --- a/internal/site/src/main.tsx +++ b/internal/site/src/main.tsx @@ -12,17 +12,19 @@ import Settings from "@/components/routes/settings/layout.tsx" import { ThemeProvider } from "@/components/theme-provider.tsx" import { Toaster } from "@/components/ui/toaster.tsx" 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 { $authenticated, $copyContent, $direction, + $newVersion, $publicKey, $userSettings, defaultLayoutWidth, } from "@/lib/stores.ts" import * as systemsManager from "@/lib/systemsManager.ts" +import type { BeszelInfo, UpdateInfo } from "./types" const LoginPage = lazy(() => import("@/components/login/login.tsx")) const Home = lazy(() => import("@/components/routes/home.tsx")) @@ -39,9 +41,13 @@ const App = memo(() => { pb.authStore.onChange(() => { $authenticated.set(pb.authStore.isValid) }) - // get version / public key - pb.send("/api/beszel/getkey", {}).then((data) => { + // get general info for authenticated users, such as public key and version + pb.send("/api/beszel/info", {}).then((data) => { $publicKey.set(data.key) + // check for updates if enabled + if (data.cu && isAdmin()) { + pb.send("/api/beszel/update", {}).then($newVersion.set) + } }) // get user settings updateUserSettings() diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 122882b5..1ee46a0d 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -525,4 +525,15 @@ export interface SystemdServiceDetails { WantedBy: any[]; Wants: string[]; WantsMountsFor: any[]; -} \ No newline at end of file +} + +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 +}