Restructure Overview: project cards side by side, ready to scale
- Extract ProjectCard component with status dots per app - Projects row uses responsive grid (2/3/4 cols) for side-by-side cards - Added Knosia project alongside WhyRating - Quick Links moved to full-width row with 6-col grid on desktop - Switched outer layout from CSS Grid to space-y for cleaner stacking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { SystemTrends } from './SystemTrends';
|
|||||||
import { formatUptime } from '@/lib/stats';
|
import { formatUptime } from '@/lib/stats';
|
||||||
import { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
|
import { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
|
||||||
import type { DeploymentStatus } from '@/lib/deployments';
|
import type { DeploymentStatus } from '@/lib/deployments';
|
||||||
|
import type { HealthStatus } from '@/lib/PortalContext';
|
||||||
|
import type { Service } from '@/lib/services';
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ name: 'Coolify', url: 'http://192.168.1.3:8000', icon: 'coolify', desc: 'Service manager' },
|
{ name: 'Coolify', url: 'http://192.168.1.3:8000', icon: 'coolify', desc: 'Service manager' },
|
||||||
@@ -16,12 +18,67 @@ const quickLinks = [
|
|||||||
{ name: 'Adminer', url: 'http://192.168.1.3:8088', icon: 'database', desc: 'DB admin' },
|
{ name: 'Adminer', url: 'http://192.168.1.3:8088', icon: 'database', desc: 'DB admin' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const whyratingApps = [
|
interface ProjectDef {
|
||||||
{ name: 'WhyRating Hub', url: 'http://whyrating.nuc.lan', icon: 'whyrating' },
|
name: string;
|
||||||
{ name: 'Brand Site', url: 'http://brand.nuc.lan', icon: 'whyrating' },
|
icon: string;
|
||||||
{ name: 'Templates', url: 'http://templates.nuc.lan', icon: 'whyrating' },
|
apps: { name: string; url: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects: ProjectDef[] = [
|
||||||
|
{
|
||||||
|
name: 'WhyRating',
|
||||||
|
icon: 'whyrating',
|
||||||
|
apps: [
|
||||||
|
{ name: 'Hub', url: 'http://whyrating.nuc.lan' },
|
||||||
|
{ name: 'Brand', url: 'http://brand.nuc.lan' },
|
||||||
|
{ name: 'Templates', url: 'http://templates.nuc.lan' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Knosia',
|
||||||
|
icon: 'book-open',
|
||||||
|
apps: [
|
||||||
|
{ name: 'App', url: 'http://knosia.nuc.lan' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function ProjectCard({ project, services, healthStatus }: {
|
||||||
|
project: ProjectDef;
|
||||||
|
services: Service[];
|
||||||
|
healthStatus: Record<string, HealthStatus>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
|
<Icon name={project.icon} size={16} />
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{project.apps.map(app => {
|
||||||
|
const svc = services.find(s => s.url === app.url);
|
||||||
|
const status = svc ? healthStatus[svc.name] : undefined;
|
||||||
|
const dot = status === 'running' ? 'bg-emerald-500' : status === 'stopped' ? 'bg-red-500' : 'bg-slate-300 dark:bg-stone-700';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={app.url}
|
||||||
|
href={app.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2.5 p-2 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${dot}`} />
|
||||||
|
<span className="text-sm text-slate-700 dark:text-stone-300 flex-1 truncate">{app.name}</span>
|
||||||
|
<Icon name="external-link" size={12} className="text-slate-300 dark:text-stone-700 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OverviewTab() {
|
export function OverviewTab() {
|
||||||
const {
|
const {
|
||||||
systemStats,
|
systemStats,
|
||||||
@@ -40,117 +97,138 @@ export function OverviewTab() {
|
|||||||
const isDiscovered = discoveredServices.length > 0;
|
const isDiscovered = discoveredServices.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 max-w-6xl">
|
<div className="space-y-5 max-w-6xl">
|
||||||
|
|
||||||
{/* Row 1: System Trends (full-width hero) with live stats in header */}
|
{/* Row 1: System Trends (full-width hero) */}
|
||||||
<SystemTrends
|
<SystemTrends
|
||||||
uptimeLabel={systemStats ? formatUptime(systemStats.uptime_seconds) : undefined}
|
uptimeLabel={systemStats ? formatUptime(systemStats.uptime_seconds) : undefined}
|
||||||
loadAvg={systemStats?.load_avg}
|
loadAvg={systemStats?.load_avg}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Row 2 left: Services */}
|
{/* Row 2: Services | Deployments */}
|
||||||
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
{/* Services */}
|
||||||
<Icon name="server" size={16} className="text-slate-400 dark:text-stone-500" />
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
Services
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
{isDiscovered && (
|
<Icon name="server" size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
<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">
|
Services
|
||||||
Auto-discovered
|
{isDiscovered && (
|
||||||
</span>
|
<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
|
||||||
</h2>
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="flex items-baseline gap-3 mb-3">
|
<div className="flex items-baseline gap-3 mb-3">
|
||||||
<span className="text-3xl font-bold text-slate-900 dark:text-stone-100 tabular-nums">{runningCount}</span>
|
<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>
|
<span className="text-sm text-slate-500 dark:text-stone-500">of {totalCount} running</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 mb-3">
|
|
||||||
<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>
|
</div>
|
||||||
{stoppedCount > 0 && (
|
|
||||||
|
<div className="flex gap-3 mb-3">
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
<span className="text-slate-600 dark:text-stone-400">{stoppedCount} stopped</span>
|
<span className="text-slate-600 dark:text-stone-400">{runningCount} running</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{stoppedCount > 0 && (
|
||||||
{totalCount - runningCount - stoppedCount > 0 && (
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
<span className="w-2 h-2 rounded-full bg-slate-400" />
|
<span className="text-slate-600 dark:text-stone-400">{stoppedCount} stopped</span>
|
||||||
<span className="text-slate-600 dark:text-stone-400">{totalCount - runningCount - stoppedCount} unknown</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Row 2 right: 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-3 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>
|
||||||
))}
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
) : recentDeployments.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap gap-1">
|
||||||
{recentDeployments.map(d => (
|
{services.map(s => {
|
||||||
<div key={d.deployment_uuid} className="flex items-center gap-3 text-sm">
|
const status = healthStatus[s.name];
|
||||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_COLORS[d.status]}`} />
|
const color = status === 'running' ? 'bg-emerald-500' : status === 'stopped' ? 'bg-red-500' : 'bg-slate-400';
|
||||||
<span className="text-slate-900 dark:text-stone-100 truncate flex-1 font-medium">
|
return (
|
||||||
{d.application_name}
|
<div
|
||||||
</span>
|
key={s.name}
|
||||||
<span className="text-xs text-slate-400 dark:text-stone-600 flex-shrink-0">
|
title={`${s.name}: ${status || 'unknown'}`}
|
||||||
{STATUS_LABELS[d.status as DeploymentStatus] || d.status}
|
className={`w-2.5 h-2.5 rounded-sm ${color}`}
|
||||||
</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)}
|
</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-3 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>
|
||||||
)}
|
<span className="text-xs text-slate-400 dark:text-stone-600 flex-shrink-0">
|
||||||
<span className="text-xs text-slate-400 dark:text-stone-600 tabular-nums flex-shrink-0">
|
{STATUS_LABELS[d.status as DeploymentStatus] || d.status}
|
||||||
{formatRelativeTime(d.created_at)}
|
</span>
|
||||||
</span>
|
{d.duration != null && d.duration > 0 && (
|
||||||
</div>
|
<span className="text-xs text-slate-400 dark:text-stone-600 tabular-nums flex-shrink-0">
|
||||||
))}
|
{formatDuration(d.duration)}
|
||||||
</div>
|
</span>
|
||||||
) : (
|
)}
|
||||||
<p className="text-sm text-slate-400 dark:text-stone-600">No deployments yet</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3 left: Quick Links */}
|
{/* Row 3: Projects (side by side, scales with more projects) */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="box" size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{projects.map(project => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.name}
|
||||||
|
project={project}
|
||||||
|
services={services}
|
||||||
|
healthStatus={healthStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: 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">
|
<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-3 flex items-center gap-2">
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
<Icon name="external-link" size={16} className="text-slate-400 dark:text-stone-500" />
|
<Icon name="external-link" size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
Quick Links
|
Quick Links
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||||
{quickLinks.map(link => (
|
{quickLinks.map(link => (
|
||||||
<a
|
<a
|
||||||
key={link.name}
|
key={link.name}
|
||||||
@@ -168,38 +246,6 @@ export function OverviewTab() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3 right: 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">
|
|
||||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
|
||||||
<Icon name="whyrating" size={16} />
|
|
||||||
WhyRating Project
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
|||||||
if (error && !data) return null;
|
if (error && !data) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
{/* Header with title, live stats, and Grafana link */}
|
{/* Header with title, live stats, and Grafana link */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user