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:
50
src/app/api/stats/route.ts
Normal file
50
src/app/api/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
285
src/components/OverviewTab.tsx
Normal file
285
src/components/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/VitalsBar.tsx
Normal file
59
src/components/VitalsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
40
src/lib/stats.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user