9 Commits
Author SHA1 Message Date
NixReaper 5af2762634 fix(version): cache-bust refresh + clear stale state on manual check, bump to v0.4.5
The refresh/retry button called fetchVersion() without clearing
versionInfo first, so the old value stayed displayed with no feedback.
On manual refresh it now immediately sets versionInfo to null (shows
spinner) then fetches with ?_t=Date.now() to bypass any browser cache
of the API response.

Bump version to 0.4.5 to include all fixes since 0.4.4 (day/night
toggle, uptime/kill allowlist, auto-retry on version error).
2026-04-11 14:27:19 -05:00
NixReaper ddb51a8b4d feat: day/night mode toggle with theme context
Add ThemeContext (dark/light, persisted to localStorage, applied as
data-theme on <html>). Theme toggle is a sun/moon flip switch at the
right end of the dashboard info bar.

CSS overrides in index.css target every hardcoded Tailwind arbitrary
colour class used across all pages via [data-theme="light"] selectors,
so the entire panel switches without touching individual page files.

Light palette: page #f0f4f8, cards/sidebar #ffffff, hover #f1f5f9,
borders #e2e8f0, text #0f172a. Chart tooltip is made theme-aware via
inline-style interpolation since Tailwind CSS overrides cannot reach
Recharts inline styles.
2026-04-11 14:24:51 -05:00
NixReaper 4de2a7fa8e fix: auto-retry version fetch + add uptime/kill to exec allowlist
Layout.tsx: when versionError is true, automatically retry fetchVersion
after 5 s. This handles the case where the initial fetch runs during
the service-restart window after an upgrade (server not yet up), which
caused the sidebar footer to permanently show 'Version unavailable'
even though the server came back online shortly after.

exec.ts: add 'uptime' and 'kill' to ALLOWED_COMMANDS. 'uptime' was
missing, causing exec('uptime', ['-p']) in GET /api/nixserver/system/info
to throw 'Command not allowed', which rejected the entire Promise.all
and returned 500 — leaving Hostname and OS as '...' on the dashboard.
2026-04-11 14:19:24 -05:00
NixReaper 90dbf94c18 fix(layout): use authenticated api client for version/upgrade calls
Plain axios was called without the Authorization header, so the
requireAdmin middleware rejected every request with 401. The catch
block set versionError but the error state was also not yet deployed,
leaving the spinner stuck on 'Checking version…' forever.

Switch both axios.get('/api/nixserver/system/version') and
axios.post('/api/nixserver/system/upgrade') to the api client from
../api/client, which automatically attaches Bearer token from
localStorage on every request.
2026-04-11 14:15:48 -05:00
NixReaper 146c17abd8 feat(dashboard): add cPanel-style server info bar and bump to v0.4.4
Adds a compact info strip at the top of the NixPanel dashboard showing
Username, Hostname, OS, NixPanel Version, Load Averages (live via
WebSocket), and Server Monitoring alert count. Alert count is derived
from GET /api/nixserver/services — green "All Clear" when all services
are running, amber "N Alerts" for any stopped/failed service.

Also fixes the version footer error state in Layout.tsx (versionError
→ "Version unavailable" fallback with retry button) so the spinner no
longer hangs indefinitely when the version API is unreachable.
2026-04-11 14:13:43 -05:00
NixReaper 47ce924a90 fix(update): set web-readable permissions on dist dirs after rebuild 2026-04-11 12:09:31 -05:00
NixReaper 82b9ac30e9 fix(nixserver): widen Lucide icon size prop type to number | string 2026-04-11 12:06:58 -05:00
NixReaper c6f13f2e07 fix(update): use prisma db push instead of migrate deploy 2026-04-11 12:05:30 -05:00
NixReaper 945da97c97 fix(update): remove unsupported --silent flag from prisma generate 2026-04-11 12:04:30 -05:00
10 changed files with 268 additions and 27 deletions
+19
View File
@@ -6,6 +6,25 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
---
## [0.4.5] — 2026-04-11
### Bug Fixes
- **Sidebar version refresh not updating** — clicking the refresh icon now clears the stale version immediately (shows spinner) and adds a `?_t=` cache-bust parameter so the browser never serves a cached response. Same fix applied to the "Retry" button in the error state.
- **`uptime` and `kill` missing from exec allowlist** — caused `GET /api/nixserver/system/info` to throw "Command not allowed", returning 500 and leaving Hostname/OS as `…` on the dashboard.
- **Version fetch auto-retry** — when the version API fails (e.g. during service restart after upgrade), the sidebar now automatically retries every 5 s until it succeeds instead of permanently showing "Version unavailable".
### New Features
- **Day/Night mode toggle** — sun/moon flip switch at the right end of the dashboard info bar. Light palette: page `#f0f4f8`, cards `#ffffff`. Preference persisted to `localStorage`. All pages switch via seven `[data-theme="light"]` CSS overrides in `index.css` without touching individual page files.
---
## [0.4.4] — 2026-04-11
### New Features
- **Server info bar on Dashboard** — cPanel/WHM-style info strip at the top of the NixPanel home page. Shows Username, Hostname, OS, NixPanel Version, Load Averages (live from WebSocket), and Server Monitoring alert count. Alerts are colour-coded: green "All Clear" when all managed services are running; amber "N Alerts" with a count of stopped/failed services.
---
## [0.4.3] — 2026-04-11
### New Features
+3
View File
@@ -1,6 +1,7 @@
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
@@ -233,9 +234,11 @@ function PageLoader() {
export default function App() {
return (
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
)
}
+32 -7
View File
@@ -9,8 +9,8 @@ import {
ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw,
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2
} from 'lucide-react'
import axios from 'axios'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../api/client'
// ─── Nav data ────────────────────────────────────────────────────────────────
@@ -18,7 +18,7 @@ type NavItem = { label: string; to: string }
type NavCategory = {
id: string
label: string
icon: React.ComponentType<{ size?: number; className?: string }>
icon: React.ComponentType<{ size?: number | string; className?: string }>
items: NavItem[]
}
@@ -462,22 +462,40 @@ export default function Layout() {
// ── Version / upgrade state ──────────────────────────────────────────────
type VersionInfo = { currentVersion: string; latestVersion: string | null; updateAvailable: boolean }
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
const [versionError, setVersionError] = useState(false)
const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle')
const fetchVersion = useCallback(async () => {
const fetchVersion = useCallback(async (manual = false) => {
setVersionError(false)
// On a manual refresh, clear the stale value so the spinner shows
if (manual) setVersionInfo(null)
try {
const { data } = await axios.get('/api/nixserver/system/version')
const { data } = await api.get('/nixserver/system/version', {
timeout: 10_000,
// Cache-bust so the browser never serves a stale version response
params: { _t: Date.now() },
})
setVersionInfo(data.data)
} catch { /* non-fatal */ }
} catch {
setVersionError(true)
}
}, [])
// Initial fetch
useEffect(() => { fetchVersion() }, [fetchVersion])
// Auto-retry every 5 s while in error state (handles the service-restart window after an upgrade)
useEffect(() => {
if (!versionError) return
const t = setTimeout(fetchVersion, 5_000)
return () => clearTimeout(t)
}, [versionError, fetchVersion])
const handleUpgrade = async () => {
if (upgradeState === 'confirm') {
setUpgradeState('running')
try {
await axios.post('/api/nixserver/system/upgrade')
await api.post('/nixserver/system/upgrade')
setUpgradeState('done')
} catch {
setUpgradeState('error')
@@ -673,7 +691,7 @@ export default function Layout() {
)}
</div>
<button
onClick={fetchVersion}
onClick={() => fetchVersion(true)}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
@@ -726,6 +744,13 @@ export default function Layout() {
</div>
)}
</div>
) : versionError ? (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
<button onClick={() => fetchVersion(true)} title="Retry" className="text-[#4a5568] hover:text-[#94a3b8] transition-colors">
<RefreshCcw size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-1 py-1">
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
+35
View File
@@ -0,0 +1,35 @@
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() =>
(localStorage.getItem('nixpanel-theme') as Theme) ?? 'dark'
)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('nixpanel-theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
return ctx
}
+42
View File
@@ -45,3 +45,45 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #3a3d4e;
}
/* ── Light theme ─────────────────────────────────────────────────────────── */
[data-theme="light"] {
--bg-primary: #f0f4f8;
--bg-secondary: #ffffff;
--bg-card: #f1f5f9;
--border: #e2e8f0;
--text-primary: #0f172a;
--text-muted: #64748b;
}
[data-theme="light"] body {
background-color: #f0f4f8;
color: #0f172a;
}
/* Scrollbar */
[data-theme="light"] ::-webkit-scrollbar-track { background: #f1f5f9; }
[data-theme="light"] ::-webkit-scrollbar-thumb { background: #cbd5e1; }
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── Background overrides ──────────────────────────────────────────────── */
/* Page background (darkest) */
[data-theme="light"] .bg-\[\#0f1117\] { background-color: #f0f4f8 !important; }
/* Cards / sidebar */
[data-theme="light"] .bg-\[\#1a1d27\] { background-color: #ffffff !important; }
/* Hover / alternate rows */
[data-theme="light"] .bg-\[\#1e2130\] { background-color: #f1f5f9 !important; }
/* ── Border overrides ──────────────────────────────────────────────────── */
[data-theme="light"] .border-\[\#2a2d3e\] { border-color: #e2e8f0 !important; }
/* ── Text overrides ────────────────────────────────────────────────────── */
[data-theme="light"] .text-white { color: #0f172a !important; }
[data-theme="light"] .text-\[\#94a3b8\] { color: #475569 !important; }
[data-theme="light"] .text-\[\#64748b\] { color: #64748b !important; }
[data-theme="light"] .text-\[\#4a5568\] { color: #94a3b8 !important; }
/* ── Input fields ──────────────────────────────────────────────────────── */
[data-theme="light"] input { color: #0f172a !important; }
[data-theme="light"] input::placeholder { color: #94a3b8 !important; }
+120 -9
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useState, useRef } from 'react'
import { Users, Server, HardDrive, Activity } from 'lucide-react'
import { Users, Server, HardDrive, Activity, AlertTriangle, CheckCircle2, Sun, Moon } from 'lucide-react'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import { api } from '../api/client'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
interface SystemStats {
cpu: number
@@ -18,6 +20,17 @@ interface AccountStats {
terminated: number
}
interface SystemInfo {
hostname: string
os: string
}
interface ServiceStatus {
name: string
status: 'running' | 'stopped' | 'failed' | 'unknown'
enabled: boolean
}
interface StatCard {
label: string
value: string | number
@@ -31,15 +44,52 @@ const formatBytes = (bytes: number) => {
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`
}
// ─── Server Info Bar ─────────────────────────────────────────────────────────
interface InfoCellProps {
label: string
value: React.ReactNode
}
function InfoCell({ label, value }: InfoCellProps) {
return (
<div className="flex flex-col min-w-0">
<span className="text-[#4a5568] text-[10px] font-medium uppercase tracking-wide leading-none mb-0.5">
{label}
</span>
<span className="text-[#94a3b8] text-xs font-medium leading-none truncate">
{value}
</span>
</div>
)
}
function Divider() {
return <div className="w-px h-8 bg-[#2a2d3e] flex-shrink-0" />
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function Dashboard() {
const { user } = useAuth()
const { theme, toggleTheme } = useTheme()
const [stats, setStats] = useState<SystemStats | null>(null)
const [accountStats, setAccountStats] = useState<AccountStats | null>(null)
const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
const [version, setVersion] = useState<string | null>(null)
const [alertCount, setAlertCount] = useState<number | null>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
api.get('/nixserver/accounts/stats').then(r => setAccountStats(r.data.data)).catch(() => {})
api.get('/nixserver/system/stats').then(r => setStats(r.data.data)).catch(() => {})
api.get('/nixserver/system/info').then(r => setSystemInfo(r.data.data)).catch(() => {})
api.get('/nixserver/system/version').then(r => setVersion(r.data.data?.currentVersion ?? null)).catch(() => {})
api.get('/nixserver/services').then(r => {
const services: ServiceStatus[] = r.data.data ?? []
setAlertCount(services.filter(s => s.status !== 'running').length)
}).catch(() => { setAlertCount(0) })
// WebSocket live stats
const token = localStorage.getItem('access_token')
@@ -92,14 +142,69 @@ export default function Dashboard() {
},
]
const loadStr = stats
? `${stats.load[0].toFixed(2)}\u00a0\u00a0${stats.load[1].toFixed(2)}\u00a0\u00a0${stats.load[2].toFixed(2)}`
: '…'
const monitoringNode =
alertCount === null ? (
<span className="text-[#4a5568]">…</span>
) : alertCount === 0 ? (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 size={11} />
All Clear
</span>
) : (
<span className="flex items-center gap-1 text-amber-400">
<AlertTriangle size={11} />
{alertCount} Alert{alertCount !== 1 ? 's' : ''}
</span>
)
return (
<div className="space-y-6">
<div>
<h1 className="text-white text-xl font-semibold">Dashboard</h1>
<p className="text-[#64748b] text-sm mt-0.5">Server overview and real-time metrics</p>
{/* ── cPanel-style server info bar ─────────────────────────────────── */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-5 py-3.5 flex flex-wrap items-center gap-x-5 gap-y-3">
<InfoCell label="Username" value={user?.username ?? '…'} />
<Divider />
<InfoCell label="Hostname" value={systemInfo?.hostname ?? '…'} />
<Divider />
<InfoCell label="OS" value={systemInfo?.os ?? '…'} />
<Divider />
<InfoCell label="NixPanel Version" value={version ? `v${version}` : '…'} />
<Divider />
<InfoCell label="Load Averages" value={loadStr} />
<Divider />
<InfoCell label="Server Monitoring" value={monitoringNode} />
{/* ── Day / Night toggle — far right ── */}
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
<Moon
size={13}
className={theme === 'dark' ? 'text-indigo-400' : 'text-[#94a3b8]'}
/>
<button
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to day mode' : 'Switch to night mode'}
className={`relative flex items-center w-10 h-5 rounded-full transition-colors cursor-pointer flex-shrink-0 ${
theme === 'light' ? 'bg-amber-400' : 'bg-[#2a2d3e]'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
theme === 'light' ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<Sun
size={13}
className={theme === 'light' ? 'text-amber-500' : 'text-[#4a5568]'}
/>
</div>
</div>
{/* Stat cards */}
{/* ── Stat cards ───────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{cards.map(card => (
<div key={card.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
@@ -113,7 +218,7 @@ export default function Dashboard() {
))}
</div>
{/* CPU / Memory chart */}
{/* ── CPU / Memory chart ───────────────────────────────────────────── */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<h2 className="text-white text-sm font-medium mb-4">CPU & Memory — Live</h2>
{cpuHistory.length > 0 ? (
@@ -132,8 +237,14 @@ export default function Dashboard() {
<XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ background: '#1e2130', border: '1px solid #2a2d3e', borderRadius: 8, fontSize: 12 }}
labelStyle={{ color: '#94a3b8' }}
contentStyle={{
background: theme === 'light' ? '#ffffff' : '#1e2130',
border: `1px solid ${theme === 'light' ? '#e2e8f0' : '#2a2d3e'}`,
borderRadius: 8,
fontSize: 12,
color: theme === 'light' ? '#0f172a' : '#e2e8f0',
}}
labelStyle={{ color: theme === 'light' ? '#64748b' : '#94a3b8' }}
/>
<Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" />
@@ -146,7 +257,7 @@ export default function Dashboard() {
)}
</div>
{/* Account status breakdown */}
{/* ── Account status breakdown ─────────────────────────────────────── */}
{accountStats && (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<h2 className="text-white text-sm font-medium mb-4">Account Status</h2>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nixpanel-server",
"version": "0.4.3",
"version": "0.4.5",
"private": true,
"workspaces": [
"server",
+8 -2
View File
@@ -24,14 +24,20 @@ success "Dependencies ready"
step "Running migrations"
cd "$INSTALL_DIR/server"
npx prisma generate --silent
npx prisma migrate deploy
npx prisma generate
npx prisma db push
success "Migrations applied"
step "Rebuilding frontends"
cd "$INSTALL_DIR"
npm run build --workspace=nixserver --silent
npm run build --workspace=nixclient --silent
# Ensure Apache (www-data) can read the rebuilt assets
chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR/nixserver/dist" -type f -exec chmod 644 {} \;
find "$INSTALL_DIR/nixclient/dist" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR/nixclient/dist" -type f -exec chmod 644 {} \;
success "Frontends rebuilt"
step "Rebuilding server"
+1 -1
View File
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({
status: 'ok',
version: '0.4.3',
version: '0.4.5',
timestamp: new Date().toISOString(),
}))
+1 -1
View File
@@ -24,7 +24,7 @@ const ALLOWED_COMMANDS = new Set([
// Network
'ss', 'ip', 'hostname',
// System
'uname', 'free', 'ps',
'uname', 'uptime', 'free', 'ps', 'kill',
// PHP
'php8.2', 'php8.3',
// Fail2ban