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:
NixReaper
2026-04-11 11:55:25 -05:00
parent da576bdbdc
commit a0e3fc7b1e
8 changed files with 189 additions and 7 deletions
+11
View File
@@ -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 ## [0.4.2] — 2026-04-11
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/cpanel", "name": "@nixpanel/cpanel",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/whm", "name": "@nixpanel/whm",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+118 -2
View File
@@ -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 { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { import {
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog, LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
@@ -6,8 +6,10 @@ import {
Users, UserCheck, ArrowRightLeft, Paintbrush, Package, Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock, Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
ShoppingBag, Code2, Puzzle, LogOut, Menu, X, ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
ChevronDown, ChevronRight, Search, RotateCcw ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw,
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2
} from 'lucide-react' } from 'lucide-react'
import axios from 'axios'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
// ─── Nav data ──────────────────────────────────────────────────────────────── // ─── Nav data ────────────────────────────────────────────────────────────────
@@ -456,6 +458,37 @@ export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const [search, setSearch] = useState('') 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>>(() => { const [openCats, setOpenCats] = useState<Set<string>>(() => {
// Auto-open the category that contains the active route on first render // Auto-open the category that contains the active route on first render
const active = NAV.find(cat => const active = NAV.find(cat =>
@@ -618,6 +651,89 @@ export default function Layout() {
)} )}
</nav> </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 */} {/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0"> <div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
<div className="px-1 mb-2"> <div className="px-1 mb-2">
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nixpanel-server", "name": "nixpanel-server",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/server", "name": "@nixpanel/server",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+1 -1
View File
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({ fastify.get('/api/health', async () => ({
status: 'ok', status: 'ok',
version: '0.4.2', version: '0.4.3',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
})) }))
+55
View File
@@ -1,10 +1,13 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { spawn } from 'child_process'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAdmin } from '../../middleware/auth.js' import { requireAdmin } from '../../middleware/auth.js'
import { getSystemStats } from '../../core/system.js' import { getSystemStats } from '../../core/system.js'
import { exec } from '../../core/exec.js' import { exec } from '../../core/exec.js'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
const INSTALL_DIR = process.env.INSTALL_DIR ?? '/opt/nixpanel'
export default async function systemRoutes(fastify: FastifyInstance) { export default async function systemRoutes(fastify: FastifyInstance) {
// GET /api/nixserver/system/stats // GET /api/nixserver/system/stats
fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => { 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' } }) 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)
})
} }