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 { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
|
||||
import type { DeploymentStatus } from '@/lib/deployments';
|
||||
import type { HealthStatus } from '@/lib/PortalContext';
|
||||
import type { Service } from '@/lib/services';
|
||||
|
||||
const quickLinks = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
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' },
|
||||
interface ProjectDef {
|
||||
name: string;
|
||||
icon: string;
|
||||
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() {
|
||||
const {
|
||||
systemStats,
|
||||
@@ -40,15 +97,17 @@ export function OverviewTab() {
|
||||
const isDiscovered = discoveredServices.length > 0;
|
||||
|
||||
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
|
||||
uptimeLabel={systemStats ? formatUptime(systemStats.uptime_seconds) : undefined}
|
||||
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">
|
||||
<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" />
|
||||
@@ -99,7 +158,7 @@ export function OverviewTab() {
|
||||
</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">
|
||||
<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" />
|
||||
@@ -142,15 +201,34 @@ export function OverviewTab() {
|
||||
<p className="text-sm text-slate-400 dark:text-stone-600">No deployments yet</p>
|
||||
)}
|
||||
</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">
|
||||
<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" />
|
||||
Quick Links
|
||||
</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 => (
|
||||
<a
|
||||
key={link.name}
|
||||
@@ -168,38 +246,6 @@ export function OverviewTab() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
||||
if (error && !data) return null;
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user