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
|
## [0.4.2] — 2026-04-11
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user