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:
Alejandro Gutiérrez
2026-02-03 22:51:01 +01:00
parent 2e0bf435fe
commit d81f39c28d
2 changed files with 173 additions and 127 deletions

View File

@@ -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,15 +97,17 @@ 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="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Services */}
<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="server" size={16} className="text-slate-400 dark:text-stone-500" /> <Icon name="server" size={16} className="text-slate-400 dark:text-stone-500" />
@@ -99,7 +158,7 @@ export function OverviewTab() {
</div> </div>
</div> </div>
{/* Row 2 right: Recent Deployments */} {/* 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"> <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="rocket" size={16} className="text-slate-400 dark:text-stone-500" /> <Icon name="rocket" size={16} className="text-slate-400 dark:text-stone-500" />
@@ -142,15 +201,34 @@ export function OverviewTab() {
<p className="text-sm text-slate-400 dark:text-stone-600">No deployments yet</p> <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>
); );
} }

View File

@@ -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">