feat: implement QuickStack version update feature with GitHub API integration

This commit is contained in:
biersoeckli
2025-12-28 15:16:44 +00:00
parent 5de461d9b1
commit 2ef0608998
9 changed files with 313 additions and 78 deletions

View File

@@ -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.');
});

View File

@@ -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({
<TabsTrigger value="general"><Settings className="mr-2 h-4 w-4" />General</TabsTrigger>
<TabsTrigger value="networking"><Network className="mr-2 h-4 w-4" />Networking / Traefik</TabsTrigger>
<TabsTrigger value="storage"><HardDrive className="mr-2 h-4 w-4" />Storage & Backups</TabsTrigger>
<TabsTrigger value="updates"><Rocket className="mr-2 h-4 w-4" />Updates</TabsTrigger>
<TabsTrigger value="updates"><Rocket className="mr-2 h-4 w-4" />Updates {newVersionInfo && <div className="h-2 w-2 ml-2 rounded-full bg-orange-500 animate-pulse" />}</TabsTrigger>
<TabsTrigger value="maintenance"><Wrench className="mr-2 h-4 w-4" />Maintenance</TabsTrigger>
</TabsList>
@@ -90,7 +113,7 @@ export default async function ProjectPage({
<TabsContent value="updates" className="space-y-4">
<div className="grid gap-6">
<QuickStackVersionInfo currentVersion={currentVersion} useCanaryChannel={useCanaryChannel!} />
<QuickStackVersionInfo newVersionInfo={newVersionInfo} currentVersion={currentVersion} useCanaryChannel={useCanaryChannel!} />
</div>
</TabsContent>
<TabsContent value="maintenance" className="space-y-4">

View File

@@ -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 <>
<Card>
<CardHeader>
<CardTitle>QuickStack Version</CardTitle>
<CardDescription>Update your QuickStack cluster or change to the experimental Canary version.</CardDescription>
<CardTitle className="flex items-center gap-2">
QuickStack Version
</CardTitle>
<CardDescription>Manage your QuickStack version and update channel preferences</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-2 pl-1">
<Switch id="canary-channel-mode" disabled={loading} checked={useCanaryChannel} onCheckedChange={(checked) => {
try {
setLoading(true);
Toast.fromAction(() => setCanaryChannel(checked));
} finally {
setLoading(false);
}
}} />
<Label htmlFor="canary-channel-mode">Use Canary Channel for Updates</Label>
<div className="rounded-lg border bg-muted/50 p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Current Version</p>
<p className="text-2xl font-bold">{currentVersion ?? 'unknown'}</p>
</div>
{newVersionInfo && (
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 cursor-pointer" onClick={handleUpdate}>
<div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" />
<span className="text-xs font-medium text-primary">Update Available</span>
</div>
<div className="text-sm text-muted-foreground flex gap-1">
<span>Version {newVersionInfo.version} | </span>
<Link href={newVersionInfo.url} target="_blank" className="flex gap-1 items-center hover:underline">
<ExternalLink className=" h-4 w-4" />
View Release Notes
</Link>
</div>
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<Button variant="secondary" disabled={loading} onClick={async () => {
if (await useConfirm.openConfirmDialog({
title: 'Update QuickStack',
description: 'This action will restart the QuickStack service and installs the lastest version. It may take a few minutes to complete.',
okButton: "Update QuickStack",
})) {
Toast.fromAction(() => updateQuickstack());
}
}}><Rocket /> Update QuickStack</Button>
<p className="text-slate-500 text-sm flex-1 text-right">Installed: {currentVersion ?? 'unknown'}</p>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4 hover:bg-accent/50 transition-colors">
<div className="space-y-0.5">
<Label htmlFor="canary-channel-mode" className="text-base cursor-pointer">
Canary Channel
</Label>
<p className="text-sm text-muted-foreground">
Get early access to experimental features and updates (not recommended for production environments).
</p>
</div>
<Switch
id="canary-channel-mode"
disabled={loading}
checked={useCanaryChannel}
onCheckedChange={async (checked) => {
// 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);
}
}}
/>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between items-center border-t pt-6">
{useCanaryChannel ?
<p className="text-sm text-muted-foreground">
Cannot check for updates while on the canary channel.
</p> :
<p className="text-sm text-muted-foreground">
{newVersionInfo ? 'Update to the latest version' : 'You are up to date'}
</p>}
<Button
disabled={loading}
onClick={handleUpdate}
size="lg"
className="gap-2"
>
<Rocket className="h-4 w-4" />
Update QuickStack
</Button>
</CardFooter>
</Card >
</>;
}

View File

@@ -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<string | null>(null);
const [currentlySelectedAppId, setCurrentlySelectedAppId] = useState<string | null>(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: <span className="flex items-center gap-2">QuickStack Settings {newVersionInfo && <div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" />}</span>,
url: "/settings/server",
adminOnly: true,
},
]
useEffect(() => {
if (path.startsWith('/project/app/')) {
const appId = path.split('/')[3];
@@ -265,7 +267,7 @@ export function SidebarCient({
<SidebarMenuSub>
{(UserGroupUtils.isAdmin(session) ? settingsMenu :
settingsMenu.filter(x => !x.adminOnly)).map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubItem key={item.url}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<span>{item.title}</span>

View File

@@ -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 <SidebarCient projects={relevantProjectsForUser} session={session} />
return <SidebarCient newVersionInfo={newVersionInfo} projects={relevantProjectsForUser} session={session} />
}

View File

@@ -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<GithubReleaseInfo> {
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();

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -39,4 +39,8 @@ export class Tags {
static nodeInfos() {
return `node-infos`;
}
static quickStackVersionInfo() {
return `quickstack-version-info`;
}
}