mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 14:06:18 +01:00
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:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
11
internal/site/src/types.d.ts
vendored
11
internal/site/src/types.d.ts
vendored
@@ -526,3 +526,14 @@ export interface SystemdServiceDetails {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user