Add Overview tab with system vitals and replace WhyRating tab

- Add /stats Python API endpoint on NUC (CPU, RAM, Swap, Disk, uptime, load avg)
- Add VitalsBar component in header showing CPU/RAM/Disk mini bars
- Add Overview as new default landing tab with system health, service summary,
  recent deployments, quick links, and WhyRating project status
- Poll system stats every 30s, deployments every 30s on overview tab
- Remove standalone WhyRating tab (content moved to Overview)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-03 22:12:00 +01:00
parent d70f7a902f
commit 8583ae52c6
8 changed files with 494 additions and 42 deletions

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import type { SystemStats } from '@/lib/stats';
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
const STATS_API_URL = 'http://192.168.1.3:9876/stats';
async function fetchStats(): Promise<SystemStats> {
if (IS_PRODUCTION) {
const response = await fetch(STATS_API_URL, {
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Stats API error: ${response.status}`);
}
return await response.json();
} else {
// Development: use SSH to read /proc on NUC
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
const { stdout } = await execAsync(
'ssh nuc "curl -s http://localhost:9876/stats"',
{ timeout: 10000 }
);
return JSON.parse(stdout.trim());
}
}
export async function GET() {
try {
const stats = await fetchStats();
return NextResponse.json(stats, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch (error) {
console.error('Error fetching stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,13 @@
'use client';
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable } from '@/components';
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable, OverviewTab } from '@/components';
import { usePortal } from '@/lib/PortalContext';
import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services';
type TabId = 'services' | 'bookmarks' | 'ai' | 'whyrating' | 'deployments' | 'settings';
type TabId = 'overview' | 'services' | 'bookmarks' | 'ai' | 'deployments' | 'settings';
const tabs: { id: TabId; label: string; icon: string }[] = [
{ id: 'whyrating', label: 'WhyRating', icon: 'whyrating' },
{ id: 'overview', label: 'Overview', icon: 'layout' },
{ id: 'services', label: 'Services', icon: 'server' },
{ id: 'deployments', label: 'Deployments', icon: 'rocket' },
{ id: 'ai', label: 'AI', icon: 'bot' },
@@ -27,10 +27,6 @@ const aiTools = [
{ name: 'Together AI', url: 'https://together.ai', icon: 'users', description: 'Open model inference' },
];
const whyratingLinks = [
{ name: 'WhyRating Hub', url: 'http://whyrating.nuc.lan', icon: 'whyrating', description: 'WhyRating project hub and quick links' },
];
export default function Home() {
const {
filteredServices,
@@ -220,39 +216,8 @@ export default function Home() {
</div>
);
case 'whyrating':
return (
<div className="max-w-2xl">
<div className="mb-6">
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
WhyRating.com
</h2>
<p className="text-sm text-slate-500 dark:text-stone-500">
Project resources and documentation
</p>
</div>
<div className="grid gap-4">
{whyratingLinks.map(link => (
<a
key={link.name}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm hover:shadow-md hover:border-slate-200 dark:hover:border-stone-600/50 transition-all"
>
<div className="w-12 h-12 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800">
<Icon name={link.icon} size={24} className="text-slate-600 dark:text-stone-400" />
</div>
<div className="flex-1">
<h3 className="font-medium text-slate-900 dark:text-stone-100">{link.name}</h3>
<p className="text-sm text-slate-500 dark:text-stone-500">{link.description}</p>
</div>
<Icon name="external-link" size={16} className="text-slate-400 dark:text-stone-600" />
</a>
))}
</div>
</div>
);
case 'overview':
return <OverviewTab />;
case 'deployments':
return (
@@ -359,6 +324,7 @@ export default function Home() {
<main className={`mx-auto px-4 sm:px-6 py-8 overflow-hidden ${
activeTab === 'deployments' ? 'max-w-[1600px]' : 'max-w-6xl'
}`}>
{renderTabContent()}
</main>

View File

@@ -2,6 +2,7 @@
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
import { VitalsBar } from './VitalsBar';
interface Tab {
id: string;
@@ -69,6 +70,9 @@ export function Header({ activeTab, onTabChange, tabs }: HeaderProps) {
</div>
</div>
{/* Vitals */}
<VitalsBar />
{/* Tabs */}
<nav className="flex gap-1 mt-2">
{tabs.map(tab => (

View File

@@ -0,0 +1,285 @@
'use client';
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
import { getVitalsBg, getVitalsTrack, formatUptime } from '@/lib/stats';
import { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
import type { DeploymentStatus } from '@/lib/deployments';
function StatBar({ label, percent, used, total, unit }: {
label: string;
percent: number;
used: string;
total: string;
unit: string;
}) {
const bg = getVitalsBg(percent);
const track = getVitalsTrack(percent);
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-600 dark:text-stone-400">{label}</span>
<span className="text-slate-900 dark:text-stone-100 tabular-nums font-medium">{Math.round(percent)}%</span>
</div>
<div className={`w-full h-2 rounded-full ${track}`}>
<div
className={`h-full rounded-full ${bg} transition-all duration-500`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<p className="text-xs text-slate-400 dark:text-stone-600 tabular-nums">{used} / {total} {unit}</p>
</div>
);
}
const quickLinks = [
{ name: 'Coolify', url: 'http://192.168.1.3:8000', icon: 'coolify', desc: 'Service manager' },
{ name: 'Dozzle', url: 'http://192.168.1.3:9999', icon: 'scroll-text', desc: 'Container logs' },
{ name: 'Uptime Kuma', url: 'http://192.168.1.3:3001', icon: 'activity', desc: 'Monitoring' },
{ name: 'Ntfy', url: 'http://192.168.1.3:8333', icon: 'bell', desc: 'Notifications' },
{ name: 'Gitea', url: 'http://192.168.1.3:3030', icon: 'git-branch', desc: 'Git hosting' },
{ name: 'Adminer', url: 'http://192.168.1.3:8088', icon: 'database', desc: 'DB admin' },
];
const whyratingApps = [
{ name: 'WhyRating Hub', url: 'http://whyrating.nuc.lan', icon: 'whyrating' },
{ name: 'Brand Site', url: 'http://brand.nuc.lan', icon: 'whyrating' },
{ name: 'Templates', url: 'http://templates.nuc.lan', icon: 'whyrating' },
];
export function OverviewTab() {
const {
systemStats,
statsLoading,
services,
healthStatus,
deployments,
deploymentsLoading,
discoveredServices,
} = usePortal();
const runningCount = services.filter(s => healthStatus[s.name] === 'running').length;
const stoppedCount = services.filter(s => healthStatus[s.name] === 'stopped').length;
const totalCount = services.length;
const recentDeployments = deployments.slice(0, 5);
const isDiscovered = discoveredServices.length > 0;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-6xl">
{/* System Health */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-4 flex items-center gap-2">
<Icon name="activity" size={16} className="text-slate-400 dark:text-stone-500" />
System Health
</h2>
{statsLoading && !systemStats ? (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="space-y-1">
<div className="h-4 w-24 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="h-2 w-full rounded-full bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="h-3 w-20 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
</div>
))}
</div>
) : systemStats ? (
<div className="space-y-4">
<StatBar
label="CPU"
percent={systemStats.cpu_percent}
used={`${systemStats.cpu_percent}%`}
total="100%"
unit=""
/>
<StatBar
label="RAM"
percent={systemStats.ram_percent}
used={`${(systemStats.ram_used_mb / 1024).toFixed(1)}G`}
total={`${(systemStats.ram_total_mb / 1024).toFixed(1)}G`}
unit=""
/>
<StatBar
label="Disk"
percent={systemStats.disk_percent}
used={`${systemStats.disk_used_gb}G`}
total={`${systemStats.disk_total_gb}G`}
unit=""
/>
{systemStats.swap_percent > 50 && (
<StatBar
label="Swap"
percent={systemStats.swap_percent}
used={`${(systemStats.swap_used_mb / 1024).toFixed(1)}G`}
total={`${(systemStats.swap_total_mb / 1024).toFixed(1)}G`}
unit=""
/>
)}
<div className="flex items-center gap-4 pt-2 text-xs text-slate-500 dark:text-stone-500 border-t border-slate-100 dark:border-stone-800">
<span>Uptime: {formatUptime(systemStats.uptime_seconds)}</span>
<span>Load: {systemStats.load_avg.join(' / ')}</span>
</div>
</div>
) : (
<p className="text-sm text-slate-400 dark:text-stone-600">Stats unavailable</p>
)}
</div>
{/* Service Summary */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-4 flex items-center gap-2">
<Icon name="server" size={16} className="text-slate-400 dark:text-stone-500" />
Services
{isDiscovered && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-normal">
Auto-discovered
</span>
)}
</h2>
<div className="flex items-baseline gap-3 mb-4">
<span className="text-3xl font-bold text-slate-900 dark:text-stone-100 tabular-nums">{runningCount}</span>
<span className="text-sm text-slate-500 dark:text-stone-500">of {totalCount} running</span>
</div>
<div className="flex gap-3 mb-4">
<div className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
<span className="text-slate-600 dark:text-stone-400">{runningCount} running</span>
</div>
{stoppedCount > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-slate-600 dark:text-stone-400">{stoppedCount} stopped</span>
</div>
)}
{totalCount - runningCount - stoppedCount > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-slate-400" />
<span className="text-slate-600 dark:text-stone-400">{totalCount - runningCount - stoppedCount} unknown</span>
</div>
)}
</div>
{/* Mini service status dots */}
<div className="flex flex-wrap gap-1">
{services.map(s => {
const status = healthStatus[s.name];
const color = status === 'running' ? 'bg-emerald-500' : status === 'stopped' ? 'bg-red-500' : 'bg-slate-400';
return (
<div
key={s.name}
title={`${s.name}: ${status || 'unknown'}`}
className={`w-2.5 h-2.5 rounded-sm ${color}`}
/>
);
})}
</div>
</div>
{/* Recent Deployments */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-4 flex items-center gap-2">
<Icon name="rocket" size={16} className="text-slate-400 dark:text-stone-500" />
Recent Deployments
</h2>
{deploymentsLoading && deployments.length === 0 ? (
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="h-4 flex-1 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="h-3 w-12 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
</div>
))}
</div>
) : recentDeployments.length > 0 ? (
<div className="space-y-2">
{recentDeployments.map(d => (
<div key={d.deployment_uuid} className="flex items-center gap-3 text-sm">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_COLORS[d.status]}`} />
<span className="text-slate-900 dark:text-stone-100 truncate flex-1 font-medium">
{d.application_name}
</span>
<span className="text-xs text-slate-400 dark:text-stone-600 flex-shrink-0">
{STATUS_LABELS[d.status as DeploymentStatus] || d.status}
</span>
{d.duration != null && d.duration > 0 && (
<span className="text-xs text-slate-400 dark:text-stone-600 tabular-nums flex-shrink-0">
{formatDuration(d.duration)}
</span>
)}
<span className="text-xs text-slate-400 dark:text-stone-600 tabular-nums flex-shrink-0">
{formatRelativeTime(d.created_at)}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-400 dark:text-stone-600">No deployments yet</p>
)}
</div>
{/* Quick Links */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-4 flex items-center gap-2">
<Icon name="external-link" size={16} className="text-slate-400 dark:text-stone-500" />
Quick Links
</h2>
<div className="grid grid-cols-2 gap-2">
{quickLinks.map(link => (
<a
key={link.name}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800/50 transition-colors"
>
<Icon name={link.icon} size={16} className="text-slate-500 dark:text-stone-500 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-stone-100 truncate">{link.name}</p>
<p className="text-[11px] text-slate-400 dark:text-stone-600 truncate">{link.desc}</p>
</div>
</a>
))}
</div>
</div>
{/* WhyRating Project */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5 lg:col-span-2">
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-4 flex items-center gap-2">
<Icon name="whyrating" size={16} />
WhyRating Project
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{whyratingApps.map(app => {
const svc = services.find(s => s.url === app.url);
const status = svc ? healthStatus[svc.name] : undefined;
const statusColor = status === 'running' ? 'bg-emerald-500' : status === 'stopped' ? 'bg-red-500' : 'bg-slate-400';
return (
<a
key={app.name}
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-slate-100 dark:border-stone-700/50 hover:bg-slate-50 dark:hover:bg-stone-800/50 transition-colors"
>
<Icon name={app.icon} size={20} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-stone-100">{app.name}</p>
</div>
{status && <span className={`w-2 h-2 rounded-full ${statusColor}`} />}
</a>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { usePortal } from '@/lib/PortalContext';
import { getVitalsBg, getVitalsTrack } from '@/lib/stats';
function MiniBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
const bg = getVitalsBg(percent);
const track = getVitalsTrack(percent);
return (
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-slate-500 dark:text-stone-500 w-8 text-right">{label}</span>
<div className={`w-16 h-1.5 rounded-full ${track}`}>
<div
className={`h-full rounded-full ${bg} transition-all duration-500`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<span className="text-[11px] tabular-nums text-slate-600 dark:text-stone-400 w-8">{Math.round(percent)}%</span>
{detail && (
<span className="text-[10px] text-slate-400 dark:text-stone-600 hidden lg:inline">{detail}</span>
)}
</div>
);
}
export function VitalsBar() {
const { systemStats, statsLoading, statsError } = usePortal();
if (statsError && !systemStats) return null;
if (statsLoading && !systemStats) {
return (
<div className="hidden sm:flex items-center gap-4 px-4 sm:px-6 py-1 border-t border-slate-100 dark:border-stone-800/50">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-1.5">
<div className="w-8 h-2 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="w-16 h-1.5 rounded-full bg-slate-200 dark:bg-stone-800 animate-pulse" />
<div className="w-8 h-2 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
</div>
))}
</div>
);
}
if (!systemStats) return null;
const ramDetail = `${(systemStats.ram_used_mb / 1024).toFixed(1)}/${(systemStats.ram_total_mb / 1024).toFixed(1)}G`;
const showSwap = systemStats.swap_percent > 50;
return (
<div className="hidden sm:flex items-center gap-4 px-4 sm:px-6 py-1 border-t border-slate-100 dark:border-stone-800/50">
<MiniBar label="CPU" percent={systemStats.cpu_percent} />
<MiniBar label="RAM" percent={systemStats.ram_percent} detail={ramDetail} />
<MiniBar label="Disk" percent={systemStats.disk_percent} />
{showSwap && <MiniBar label="Swap" percent={systemStats.swap_percent} />}
</div>
);
}

View File

@@ -7,3 +7,5 @@ export { Header } from './Header';
export { Section } from './ui/Section';
export { DeploymentsTable } from './DeploymentsTable';
export { DeploymentLogs } from './DeploymentLogs';
export { VitalsBar } from './VitalsBar';
export { OverviewTab } from './OverviewTab';

View File

@@ -3,6 +3,7 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { services, bookmarks, Service, Bookmark, DiscoveredService, fallbackServices } from './services';
import type { Deployment } from './deployments';
import type { SystemStats } from './stats';
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
@@ -33,6 +34,11 @@ interface PortalContextType {
discoveryLoading: boolean;
discoveryError: boolean;
refreshDiscover: () => Promise<void>;
// System stats
systemStats: SystemStats | null;
statsLoading: boolean;
statsError: boolean;
refreshStats: () => Promise<void>;
}
const PortalContext = createContext<PortalContextType | undefined>(undefined);
@@ -51,13 +57,18 @@ export function PortalProvider({ children }: { children: ReactNode }) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [deploymentsLoading, setDeploymentsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('whyrating');
const [activeTab, setActiveTab] = useState('overview');
// Discovery state
const [discoveredServices, setDiscoveredServices] = useState<DiscoveredService[]>([]);
const [discoveryLoading, setDiscoveryLoading] = useState(true);
const [discoveryError, setDiscoveryError] = useState(false);
// System stats state
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [statsError, setStatsError] = useState(false);
// Apply dark mode to document
useEffect(() => {
if (darkMode) {
@@ -147,6 +158,32 @@ export function PortalProvider({ children }: { children: ReactNode }) {
}
}, [discoveryError, discoveredServices.length, refreshHealth]);
// Fetch system stats
const refreshStats = useCallback(async () => {
try {
const response = await fetch('/api/stats');
if (response.ok) {
const data = await response.json();
setSystemStats(data);
setStatsError(false);
} else {
setStatsError(true);
}
} catch (error) {
console.error('Failed to fetch stats:', error);
setStatsError(true);
} finally {
setStatsLoading(false);
}
}, []);
// Poll stats every 30s
useEffect(() => {
refreshStats();
const interval = setInterval(refreshStats, 30000);
return () => clearInterval(interval);
}, [refreshStats]);
// Fetch deployments
const refreshDeployments = useCallback(async () => {
setDeploymentsLoading(true);
@@ -163,13 +200,18 @@ export function PortalProvider({ children }: { children: ReactNode }) {
}
}, []);
// Fetch deployments when tab is active, poll every 10 seconds
// Fetch deployments when tab is active, poll every 10s on deployments tab, 30s on overview
useEffect(() => {
if (activeTab === 'deployments') {
refreshDeployments();
const interval = setInterval(refreshDeployments, 10000);
return () => clearInterval(interval);
}
if (activeTab === 'overview') {
refreshDeployments();
const interval = setInterval(refreshDeployments, 30000);
return () => clearInterval(interval);
}
}, [activeTab, refreshDeployments]);
// Determine which services to show: discovered or fallback
@@ -221,6 +263,10 @@ export function PortalProvider({ children }: { children: ReactNode }) {
discoveryLoading,
discoveryError,
refreshDiscover,
systemStats,
statsLoading,
statsError,
refreshStats,
}}
>
{children}

40
src/lib/stats.ts Normal file
View File

@@ -0,0 +1,40 @@
export interface SystemStats {
cpu_percent: number;
ram_total_mb: number;
ram_used_mb: number;
ram_percent: number;
swap_total_mb: number;
swap_used_mb: number;
swap_percent: number;
disk_total_gb: number;
disk_used_gb: number;
disk_percent: number;
uptime_seconds: number;
load_avg: [number, number, number];
}
export function getVitalsColor(percent: number): string {
if (percent >= 90) return 'text-red-500';
if (percent >= 70) return 'text-amber-500';
return 'text-emerald-500';
}
export function getVitalsBg(percent: number): string {
if (percent >= 90) return 'bg-red-500';
if (percent >= 70) return 'bg-amber-500';
return 'bg-emerald-500';
}
export function getVitalsTrack(percent: number): string {
if (percent >= 90) return 'bg-red-500/20';
if (percent >= 70) return 'bg-amber-500/20';
return 'bg-emerald-500/20';
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h`;
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}