feat(nixserver): version display and one-click panel upgrade
Add version badge to sidebar footer showing installed vs latest version, fetched from /api/nixserver/system/version. Badge shows green when up to date or amber with target version when an update is available. Upgrade button triggers POST /api/nixserver/system/upgrade which spawns scripts/update.sh detached (git pull + npm install + prisma migrate + build + restart). Response is returned before the service restarts so the client receives confirmation. Panel comes back online in ~60 s.
This commit is contained in:
@@ -6,6 +6,17 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.3] — 2026-04-11
|
||||
|
||||
### New Features
|
||||
- **Version display in sidebar** — the sidebar footer shows the currently installed version (e.g. `v0.4.3`), fetched live from the API. A green checkmark indicates up to date; an amber alert indicates an update is available.
|
||||
- **One-click panel upgrade** — "Upgrade panel" button in the sidebar footer. When a newer version is available it highlights amber and shows the target version. Clicking requires a confirmation step before running. Calls `POST /api/nixserver/system/upgrade` which fires `scripts/update.sh` in the background (git pull → npm install → prisma migrate → build frontends + server → restart service). The panel comes back online automatically after ~60 s.
|
||||
- **Check for updates button** — small refresh icon next to the version badge re-fetches version info on demand.
|
||||
- **Backend: `GET /api/nixserver/system/version`** — returns `currentVersion` (read from `/opt/nixpanel/package.json`), `latestVersion` (fetched from GitHub raw content with a 5 s timeout), and `updateAvailable` boolean.
|
||||
- **Backend: `POST /api/nixserver/system/upgrade`** — admin-only; responds immediately then spawns `scripts/update.sh` detached so the HTTP response is flushed before the service restarts.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] — 2026-04-11
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/cpanel",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/whm",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
|
||||
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
|
||||
ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
|
||||
ChevronDown, ChevronRight, Search, RotateCcw
|
||||
ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw,
|
||||
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2
|
||||
} from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
// ─── Nav data ────────────────────────────────────────────────────────────────
|
||||
@@ -456,6 +458,37 @@ export default function Layout() {
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// ── Version / upgrade state ──────────────────────────────────────────────
|
||||
type VersionInfo = { currentVersion: string; latestVersion: string | null; updateAvailable: boolean }
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
|
||||
const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle')
|
||||
|
||||
const fetchVersion = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/nixserver/system/version')
|
||||
setVersionInfo(data.data)
|
||||
} catch { /* non-fatal */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchVersion() }, [fetchVersion])
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (upgradeState === 'confirm') {
|
||||
setUpgradeState('running')
|
||||
try {
|
||||
await axios.post('/api/nixserver/system/upgrade')
|
||||
setUpgradeState('done')
|
||||
} catch {
|
||||
setUpgradeState('error')
|
||||
}
|
||||
} else {
|
||||
setUpgradeState('confirm')
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(() => {
|
||||
// Auto-open the category that contains the active route on first render
|
||||
const active = NAV.find(cat =>
|
||||
@@ -618,6 +651,89 @@ export default function Layout() {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Version + upgrade */}
|
||||
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
|
||||
{versionInfo ? (
|
||||
<div className="space-y-1.5">
|
||||
{/* Version row */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{versionInfo.updateAvailable ? (
|
||||
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-[#64748b] text-[10px]">
|
||||
v{versionInfo.currentVersion}
|
||||
</span>
|
||||
{versionInfo.updateAvailable && versionInfo.latestVersion && (
|
||||
<span className="text-amber-400 text-[10px]">
|
||||
→ v{versionInfo.latestVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchVersion}
|
||||
title="Check for updates"
|
||||
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
|
||||
>
|
||||
<RefreshCcw size={10} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upgrade button */}
|
||||
{upgradeState === 'done' ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
|
||||
<CheckCircle2 size={11} />
|
||||
<span>Upgrade started — panel restarting…</span>
|
||||
</div>
|
||||
) : upgradeState === 'error' ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
|
||||
<AlertCircle size={11} />
|
||||
<span>Upgrade failed — check logs</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={upgradeState === 'running'}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
upgradeState === 'confirm'
|
||||
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
|
||||
: versionInfo.updateAvailable
|
||||
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
|
||||
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
|
||||
}`}
|
||||
>
|
||||
{upgradeState === 'running' ? (
|
||||
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
|
||||
) : upgradeState === 'confirm' ? (
|
||||
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
|
||||
) : versionInfo.updateAvailable ? (
|
||||
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
|
||||
) : (
|
||||
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
|
||||
)}
|
||||
</button>
|
||||
{upgradeState === 'confirm' && (
|
||||
<button
|
||||
onClick={() => setUpgradeState('idle')}
|
||||
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
|
||||
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User + logout */}
|
||||
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
|
||||
<div className="px-1 mb-2">
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nixpanel-server",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/server",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export async function buildApp() {
|
||||
|
||||
fastify.get('/api/health', async () => ({
|
||||
status: 'ok',
|
||||
version: '0.4.2',
|
||||
version: '0.4.3',
|
||||
timestamp: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { spawn } from 'child_process'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { getSystemStats } from '../../core/system.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR ?? '/opt/nixpanel'
|
||||
|
||||
export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixserver/system/stats
|
||||
fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
@@ -121,4 +124,56 @@ export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Settings updated' } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/system/version
|
||||
// Returns the installed version and checks GitHub for the latest available version.
|
||||
fastify.get('/version', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||
// Read installed version from package.json
|
||||
let currentVersion = '0.0.0'
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(`${INSTALL_DIR}/package.json`, 'utf8'))
|
||||
currentVersion = pkg.version ?? '0.0.0'
|
||||
} catch { /* running in dev or non-standard path */ }
|
||||
|
||||
// Check GitHub for latest version (5 s timeout, non-fatal)
|
||||
let latestVersion: string | null = null
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/package.json',
|
||||
{ signal: AbortSignal.timeout(5_000) }
|
||||
)
|
||||
if (resp.ok) {
|
||||
const json = await resp.json() as { version?: string }
|
||||
latestVersion = json.version ?? null
|
||||
}
|
||||
} catch { /* offline or rate-limited */ }
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable: latestVersion !== null && latestVersion !== currentVersion,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/nixserver/system/upgrade
|
||||
// Fires the update script in the background and returns immediately.
|
||||
// The service will restart ~60 s later once the build finishes.
|
||||
fastify.post('/upgrade', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||
reply.send({
|
||||
success: true,
|
||||
data: { message: 'Upgrade started. The panel will be back online in about 60 seconds.' },
|
||||
})
|
||||
|
||||
// Delay slightly to ensure the HTTP response is flushed before the service restarts
|
||||
setTimeout(() => {
|
||||
const child = spawn('bash', [`${INSTALL_DIR}/scripts/update.sh`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
child.unref()
|
||||
}, 800)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user