diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index 9aad2654..7f8b4b52 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -25,6 +25,8 @@ import { PathUtils } from "@/server/utils/path.utils"; import { FsUtils } from "@/server/utils/fs.utils"; import fs from "fs"; import { z } from "zod"; +import { revalidateTag } from "next/cache"; +import { Tags } from "@/server/utils/cache-tag-generator.utils"; export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) => @@ -108,6 +110,7 @@ export const updateQuickstack = async () => simpleAction(async () => { await getAdminUserSession(); const useCaranyChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); + revalidateTag(Tags.quickStackVersionInfo()); await quickStackService.updateQuickStack(useCaranyChannel); return new SuccessActionResult(undefined, 'QuickStack will be updated, refresh the page in a few seconds.'); }); diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx index 5aa9cbab..5a0fdfc0 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -21,6 +21,7 @@ import podService from "@/server/services/pod.service"; import quickStackService from "@/server/services/qs.service"; import { ServerSettingsTabs } from "./server-settings-tabs"; import { Settings, Network, HardDrive, Rocket, Wrench } from "lucide-react"; +import quickStackUpdateService from "@/server/services/qs-update.service"; export default async function ProjectPage({ searchParams @@ -29,18 +30,40 @@ export default async function ProjectPage({ }) { const session = await getAdminUserSession(); - const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''); - const disableNodePortAccess = await paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false); - const letsEncryptMail = await paramService.getString(ParamService.LETS_ENCRYPT_MAIL, session.email); - const regitryStorageLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION); - const ipv4Address = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS); - const systemBackupLocation = await paramService.getString(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED); - const s3Targets = await s3TargetService.getAll(); - const traefikStatus = await traefikService.getStatus(); - const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); - const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME); + + const [ + serverUrl, + disableNodePortAccess, + letsEncryptMail, + regitryStorageLocation, + ipv4Address, + systemBackupLocation, + useCanaryChannel + ] = await Promise.all([ + paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''), + paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false), + paramService.getString(ParamService.LETS_ENCRYPT_MAIL, session.email), + paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION), + paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS), + paramService.getString(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED), + paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false) + ]); + + const [ + s3Targets, + traefikStatus, + qsPodInfos, + currentVersion, + newVersionInfo + ] = await Promise.all([ + s3TargetService.getAll(), + traefikService.getStatus(), + podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME), + quickStackService.getVersionOfCurrentQuickstackInstance(), + quickStackUpdateService.getNewVersionInfo() + ]); + const qsPodInfo = qsPodInfos.find(p => !!p); - const currentVersion = await quickStackService.getVersionOfCurrentQuickstackInstance(); const defaultTab = typeof searchParams?.tab === 'string' ? searchParams.tab : 'general'; return ( @@ -63,7 +86,7 @@ export default async function ProjectPage({ General Networking / Traefik Storage & Backups - Updates + Updates {newVersionInfo &&
} Maintenance @@ -90,7 +113,7 @@ export default async function ProjectPage({
- +
diff --git a/src/app/settings/server/qs-version-info.tsx b/src/app/settings/server/qs-version-info.tsx index 948bdb18..b132d647 100644 --- a/src/app/settings/server/qs-version-info.tsx +++ b/src/app/settings/server/qs-version-info.tsx @@ -1,63 +1,133 @@ 'use client'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { purgeRegistryImages, setCanaryChannel, updateQuickstack, updateRegistry } from "./actions"; +import { setCanaryChannel, updateQuickstack } from "./actions"; import { Button } from "@/components/ui/button"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; -import { LogsDialog } from "@/components/custom/logs-overlay"; -import { Constants } from "@/shared/utils/constants"; -import { Rocket, RotateCcw, SquareTerminal, Trash } from "lucide-react"; +import { Rocket, ExternalLink } from "lucide-react"; import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import React from "react"; +import { GithubReleaseInfo } from "@/server/adapter/github.adapter"; +import Link from "next/link"; export default function QuickStackVersionInfo({ useCanaryChannel, - currentVersion + currentVersion, + newVersionInfo }: { useCanaryChannel: boolean; currentVersion?: string; + newVersionInfo?: GithubReleaseInfo }) { const useConfirm = useConfirmDialog(); const [loading, setLoading] = React.useState(false); + const handleUpdate = async () => { + if (await useConfirm.openConfirmDialog({ + title: 'Update QuickStack', + description: 'This action will restart the QuickStack service and installs the latest version. It may take a few minutes to complete.', + okButton: "Update QuickStack", + })) { + Toast.fromAction(() => updateQuickstack()); + } + }; + return <> - QuickStack Version - Update your QuickStack cluster or change to the experimental Canary version. + + QuickStack Version + + Manage your QuickStack version and update channel preferences - -
- { - try { - setLoading(true); - Toast.fromAction(() => setCanaryChannel(checked)); - } finally { - setLoading(false); - } - }} /> - +
+
+
+

Current Version

+

{currentVersion ?? 'unknown'}

+
+ {newVersionInfo && ( +
+
+
+ Update Available +
+
+ Version {newVersionInfo.version} | + + + View Release Notes + +
+
+ )} +
-
- -

Installed: {currentVersion ?? 'unknown'}

+
+
+
+ +

+ Get early access to experimental features and updates (not recommended for production environments). +

+
+ { + // Show warning when enabling canary channel + if (checked) { + const confirmed = await useConfirm.openConfirmDialog({ + title: 'Enable Canary Channel', + description: 'Canary channel provides early access to experimental features and updates. These versions may contain bugs, make your QuickStack cluster unusable and are not recommended for production environments. Are you sure you want to continue?', + okButton: "Enable Canary Channel", + }); + + if (!confirmed) { + return; + } + } + try { + setLoading(true); + Toast.fromAction(() => setCanaryChannel(checked)); + } finally { + setLoading(false); + } + }} + /> +
+ + + + {useCanaryChannel ? +

+ Cannot check for updates while on the canary channel. +

: +

+ {newVersionInfo ? 'Update to the latest version' : 'You are up to date'} +

} + +
; } \ No newline at end of file diff --git a/src/app/sidebar-client.tsx b/src/app/sidebar-client.tsx index 9188ff25..7f1241fb 100644 --- a/src/app/sidebar-client.tsx +++ b/src/app/sidebar-client.tsx @@ -32,44 +32,16 @@ import { useRouter } from "next/router" import { useEffect, useState } from "react" import QuickStackLogo from "@/components/custom/quickstack-logo" import { UserGroupUtils } from "@/shared/utils/role.utils" - - -const settingsMenu = [ - { - title: "Profile", - url: "/settings/profile", - icon: User, - }, - { - title: "Users & Groups", - url: "/settings/users", - icon: User2, - adminOnly: true, - }, - { - title: "S3 Targets", - url: "/settings/s3-targets", - icon: Settings, - adminOnly: true, - }, - { - title: "Cluster", - url: "/settings/cluster", - adminOnly: true, - }, - { - title: "QuickStack Settings", - url: "/settings/server", - adminOnly: true, - }, -] +import { GithubReleaseInfo } from "@/server/adapter/github.adapter" export function SidebarCient({ projects, - session + session, + newVersionInfo }: { projects: (Project & { apps: App[] })[]; session: UserSession; + newVersionInfo?: GithubReleaseInfo; }) { const path = usePathname(); @@ -77,6 +49,36 @@ export function SidebarCient({ const [currentlySelectedProjectId, setCurrentlySelectedProjectId] = useState(null); const [currentlySelectedAppId, setCurrentlySelectedAppId] = useState(null); + const settingsMenu = [ + { + title: "Profile", + url: "/settings/profile", + icon: User, + }, + { + title: "Users & Groups", + url: "/settings/users", + icon: User2, + adminOnly: true, + }, + { + title: "S3 Targets", + url: "/settings/s3-targets", + icon: Settings, + adminOnly: true, + }, + { + title: "Cluster", + url: "/settings/cluster", + adminOnly: true, + }, + { + title: QuickStack Settings {newVersionInfo &&
}, + url: "/settings/server", + adminOnly: true, + }, + ] + useEffect(() => { if (path.startsWith('/project/app/')) { const appId = path.split('/')[3]; @@ -265,7 +267,7 @@ export function SidebarCient({ {(UserGroupUtils.isAdmin(session) ? settingsMenu : settingsMenu.filter(x => !x.adminOnly)).map((item) => ( - + {item.title} diff --git a/src/app/sidebar.tsx b/src/app/sidebar.tsx index b33593dc..c58261a5 100644 --- a/src/app/sidebar.tsx +++ b/src/app/sidebar.tsx @@ -2,6 +2,7 @@ import projectService from "@/server/services/project.service" import { getUserSession } from "@/server/utils/action-wrapper.utils" import { SidebarCient } from "./sidebar-client" import { UserGroupUtils } from "@/shared/utils/role.utils"; +import quickStackUpdateService from "@/server/services/qs-update.service"; export async function AppSidebar() { @@ -12,12 +13,12 @@ export async function AppSidebar() { } const projects = await projectService.getAllProjects(); - + const newVersionInfo = await quickStackUpdateService.getNewVersionInfo(); const relevantProjectsForUser = projects.filter((project) => UserGroupUtils.sessionHasReadAccessToProject(session, project.id)); for (const project of relevantProjectsForUser) { project.apps = project.apps.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.id)); } - return + return } diff --git a/src/server/adapter/github.adapter.ts b/src/server/adapter/github.adapter.ts new file mode 100644 index 00000000..2b3d12be --- /dev/null +++ b/src/server/adapter/github.adapter.ts @@ -0,0 +1,97 @@ +export interface GithubReleaseInfo { + version: string; + url: string; + publishedAt: string; + body: string; +} + +class GithubAdapter { + + private readonly GITHUB_API_BASE_URL = 'https://api.github.com'; + + public async getLatestQuickStackVersion(): Promise { + const response = await fetch(`${this.GITHUB_API_BASE_URL}/repos/biersoeckli/QuickStack/releases/latest`, { + cache: 'no-cache', + method: 'GET', + headers: { + + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`Failed to fetch latest QuickStack version from GitHub: HTTP ${response.status} ${response.statusText}`); + } + const data = await response.json(); + return { + version: data.tag_name, + url: data.html_url, + publishedAt: data.published_at, + body: data.body + }; + } + + /* example: + { + "url": "https://api.github.com/repos/biersoeckli/QuickStack/releases/267434818", + "assets_url": "https://api.github.com/repos/biersoeckli/QuickStack/releases/267434818/assets", + "upload_url": "https://uploads.github.com/repos/biersoeckli/QuickStack/releases/267434818/assets{?name,label}", + "html_url": "https://github.com/biersoeckli/QuickStack/releases/tag/0.0.6", + "id": 267434818, + "author": { + "login": "biersoeckli", + "id": 24962453, + "node_id": "MDQ6VXNlcjI0OTYyNDUz", + "avatar_url": "https://avatars.githubusercontent.com/u/24962453?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/biersoeckli", + "html_url": "https://github.com/biersoeckli", + "followers_url": "https://api.github.com/users/biersoeckli/followers", + "following_url": "https://api.github.com/users/biersoeckli/following{/other_user}", + "gists_url": "https://api.github.com/users/biersoeckli/gists{/gist_id}", + "starred_url": "https://api.github.com/users/biersoeckli/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/biersoeckli/subscriptions", + "organizations_url": "https://api.github.com/users/biersoeckli/orgs", + "repos_url": "https://api.github.com/users/biersoeckli/repos", + "events_url": "https://api.github.com/users/biersoeckli/events{/privacy}", + "received_events_url": "https://api.github.com/users/biersoeckli/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDONfVBr84P8LtC", + "tag_name": "0.0.6", + "target_commitish": "main", + "name": "0.0.6", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2025-12-04T13:33:14Z", + "updated_at": "2025-12-04T13:34:32Z", + "published_at": "2025-12-04T13:34:32Z", + "assets": [ + + ], + "tarball_url": "https://api.github.com/repos/biersoeckli/QuickStack/tarball/0.0.6", + "zipball_url": "https://api.github.com/repos/biersoeckli/QuickStack/zipball/0.0.6", + "body": "## What's Changed\r\n* fix: use sudo for kubectl commands in setup scripts to ensure proper permissions by @biersoeckli in https://github.com/biersoeckli/QuickStack/pull/42\r\n* Feat/replace traefikme dns service by @biersoeckli in https://github.com/biersoeckli/QuickStack/pull/48\r\n* feat/upgrade prisma orm to v7 by @biersoeckli in https://github.com/biersoeckli/QuickStack/pull/47\r\n\r\n\r\n**Full Changelog**: https://github.com/biersoeckli/QuickStack/compare/0.0.5...0.0.6", + "reactions": { + "url": "https://api.github.com/repos/biersoeckli/QuickStack/releases/267434818/reactions", + "total_count": 1, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 1, + "rocket": 0, + "eyes": 0 + }, + "mentions_count": 1 +} +*/ + +} + + +export const githubAdapter = new GithubAdapter(); diff --git a/src/server/services/qs-update.service.ts b/src/server/services/qs-update.service.ts new file mode 100644 index 00000000..84031bc0 --- /dev/null +++ b/src/server/services/qs-update.service.ts @@ -0,0 +1,35 @@ +import { unstable_cache } from "next/cache"; +import quickStackService from "./qs.service"; +import { githubAdapter } from "../adapter/github.adapter"; +import { Tags } from "../utils/cache-tag-generator.utils"; + +class QuickStackUpdateService { + + async getNewVersionInfo() { + try { + const currentVersion = quickStackService.getVersionOfCurrentQuickstackInstance(); + if (!currentVersion) { + return undefined; + } + + if (currentVersion.includes('canary')) { + return undefined; + } + + const latestVersionInfo = await unstable_cache(async () => githubAdapter.getLatestQuickStackVersion(), + [Tags.quickStackVersionInfo()], { + tags: [Tags.quickStackVersionInfo()], + revalidate: 60 * 15, // 15 minutes + })(); + if (currentVersion === latestVersionInfo.version) { + return undefined; + } + return latestVersionInfo; + } catch (error) { + console.error("Error fetching latest QuickStack version:", error); + } + } +} + +const quickStackUpdateService = new QuickStackUpdateService(); +export default quickStackUpdateService; \ No newline at end of file diff --git a/src/server/services/qs.service.ts b/src/server/services/qs.service.ts index 05b3b1ab..4d965155 100644 --- a/src/server/services/qs.service.ts +++ b/src/server/services/qs.service.ts @@ -15,7 +15,7 @@ class QuickStackService { private readonly QUICKSTACK_SERVICEACCOUNT_NAME = 'qs-service-account'; private readonly CLUSTER_ISSUER_NAME = 'letsencrypt-production'; - async getVersionOfCurrentQuickstackInstance() { + getVersionOfCurrentQuickstackInstance() { return process.env.QS_VERSION || undefined; } diff --git a/src/server/utils/cache-tag-generator.utils.ts b/src/server/utils/cache-tag-generator.utils.ts index 5a55f910..85765a7b 100644 --- a/src/server/utils/cache-tag-generator.utils.ts +++ b/src/server/utils/cache-tag-generator.utils.ts @@ -39,4 +39,8 @@ export class Tags { static nodeInfos() { return `node-infos`; } + + static quickStackVersionInfo() { + return `quickstack-version-info`; + } } \ No newline at end of file