Files
claudetools/projects/msp-tools/guru-rmm/dashboard/src/pages/Dashboard.tsx
azcomputerguru 666d06af1b feat(dashboard): Complete "Mission Control" UI redesign
Overhaul the GuruRMM dashboard with a dark cyberpunk aesthetic featuring
glassmorphism effects, cyan accent lighting, and smooth animations.

Visual Changes:
- Dark theme with CSS variables for consistent theming
- Glassmorphism card effects with colored glow variants
- Grid pattern backgrounds and floating geometric shapes
- JetBrains Mono + Inter font pairing for tech aesthetic
- Cyan, green, amber, and rose accent colors with glow effects

Component Updates:
- index.css: Complete CSS overhaul with utility classes, animations,
  and glassmorphism foundations (1300+ lines added)
- Login.tsx: Glassmorphism login card with gradient logo and
  floating background shapes
- Layout.tsx: Dark sidebar with cyan nav highlights, grid pattern
  main area, animated user profile section
- Dashboard.tsx: Animated stat cards with staggered entrances,
  live status indicator with pulse animation, relative timestamps
- Card.tsx: Added glow variants (cyan/green/amber/rose) with
  hover lift effects
- Button.tsx: Gradient backgrounds, glow-on-hover, scale animations
- Input.tsx: Dark styling with cyan focus glow, added Textarea component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:23:59 -07:00

430 lines
13 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import {
Activity,
Server,
AlertTriangle,
Wifi,
WifiOff,
Terminal,
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { agentsApi, Agent } from "../api/client";
/**
* Formats a date to a relative time string (e.g., "2 minutes ago", "1 hour ago")
*/
function formatRelativeTime(dateString: string | null): string {
if (!dateString) return "Never seen";
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return "Just now";
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes}m ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours}h ago`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `${days}d ago`;
}
}
/**
* Loading skeleton for stat cards with shimmer animation
*/
function StatCardSkeleton({ delay }: { delay: number }) {
return (
<div
className="glass-card relative overflow-hidden opacity-0"
style={{
animation: `fadeInUp 0.4s ease-out ${delay}s forwards`,
}}
>
<div className="flex items-start justify-between">
<div className="space-y-3">
<div className="skeleton skeleton-text w-24 h-4" />
<div className="skeleton skeleton-text w-16 h-10" />
<div className="skeleton skeleton-text w-32 h-3" />
</div>
<div className="skeleton w-12 h-12 rounded-lg" />
</div>
</div>
);
}
/**
* Stat card component with Mission Control styling
*/
function StatCard({
title,
value,
icon: Icon,
description,
accentColor,
delay,
}: {
title: string;
value: string | number;
icon: React.ComponentType<{ className?: string }>;
description?: string;
accentColor: "cyan" | "green" | "amber" | "rose";
delay: number;
}) {
const colorClasses = {
cyan: {
icon: "text-cyan",
glow: "glow-cyan",
bg: "bg-[var(--accent-cyan-muted)]",
border: "border-l-[var(--accent-cyan)]",
value: "text-cyan",
},
green: {
icon: "text-green",
glow: "glow-green",
bg: "bg-[var(--accent-green-muted)]",
border: "border-l-[var(--accent-green)]",
value: "text-green",
},
amber: {
icon: "text-amber",
glow: "glow-amber",
bg: "bg-[var(--accent-amber-muted)]",
border: "border-l-[var(--accent-amber)]",
value: "text-amber",
},
rose: {
icon: "text-rose",
glow: "glow-rose",
bg: "bg-[var(--accent-rose-muted)]",
border: "border-l-[var(--accent-rose)]",
value: "text-rose",
},
};
const colors = colorClasses[accentColor];
return (
<div
className="glass-card relative overflow-hidden opacity-0 border-l-4 group cursor-default"
style={{
animation: `fadeInUp 0.4s ease-out ${delay}s forwards`,
borderLeftColor: `var(--accent-${accentColor})`,
}}
>
{/* Subtle gradient overlay on hover */}
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{
background: `radial-gradient(circle at top right, var(--accent-${accentColor}-muted), transparent 70%)`,
}}
/>
<div className="relative flex items-start justify-between">
<div className="space-y-1">
<p className="text-muted font-mono text-xs uppercase tracking-widest-custom">
{title}
</p>
<p className={`font-mono text-4xl font-bold ${colors.value}`}>
{value}
</p>
{description && (
<p className="text-muted text-sm">{description}</p>
)}
</div>
{/* Icon with glow effect */}
<div
className={`p-3 rounded-lg ${colors.bg} ${colors.glow} transition-all duration-300 group-hover:scale-110`}
>
<Icon className={`h-6 w-6 ${colors.icon}`} />
</div>
</div>
</div>
);
}
/**
* Status indicator dot with pulse animation
*/
function StatusDot({ status }: { status: string }) {
const statusClasses = {
online: "status-dot online",
offline: "status-dot offline",
error: "status-dot error",
};
return (
<span
className={statusClasses[status as keyof typeof statusClasses] || "status-dot offline"}
/>
);
}
/**
* Activity list item with hover effects
*/
function ActivityItem({ agent }: { agent: Agent }) {
return (
<div className="flex items-center justify-between p-3 rounded-lg transition-all duration-200 hover:bg-[rgba(6,182,212,0.05)] group cursor-default">
<div className="flex items-center gap-4">
<StatusDot status={agent.status} />
<div>
<p className="font-mono text-sm font-medium text-primary group-hover:text-cyan transition-colors">
{agent.hostname}
</p>
<p className="text-xs text-muted uppercase tracking-wide">
{agent.os_type}
</p>
</div>
</div>
<span className="font-mono text-xs text-muted tabular-nums">
{formatRelativeTime(agent.last_seen)}
</span>
</div>
);
}
/**
* Quick action button with glow effect
*/
function QuickActionButton({
icon: Icon,
label,
description,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
description: string;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className="flex items-center gap-4 p-4 w-full text-left rounded-lg border border-[var(--border-primary)] bg-[var(--bg-tertiary)] hover:border-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)] transition-all duration-200 group"
>
<div className="p-2 rounded-lg bg-[var(--bg-secondary)] group-hover:glow-cyan transition-all duration-200">
<Icon className="h-5 w-5 text-cyan" />
</div>
<div>
<p className="font-mono text-sm font-medium text-primary">{label}</p>
<p className="text-xs text-muted">{description}</p>
</div>
</button>
);
}
/**
* Loading skeleton for activity list
*/
function ActivityListSkeleton() {
return (
<div className="space-y-3">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-4 p-3">
<div className="skeleton w-2 h-2 rounded-full" />
<div className="flex-1 space-y-2">
<div className="skeleton skeleton-text w-32 h-4" />
<div className="skeleton skeleton-text w-20 h-3" />
</div>
<div className="skeleton skeleton-text w-16 h-3" />
</div>
))}
</div>
);
}
/**
* Main Dashboard component with Mission Control aesthetic
*/
export function Dashboard() {
const { data: agents = [], isLoading } = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list().then((res) => res.data),
refetchInterval: 30000,
});
const onlineAgents = agents.filter((a: Agent) => a.status === "online");
const offlineAgents = agents.filter((a: Agent) => a.status === "offline");
const errorAgents = agents.filter((a: Agent) => a.status === "error");
// Determine system status
const hasErrors = errorAgents.length > 0;
const allOffline = agents.length > 0 && onlineAgents.length === 0;
const systemStatus = hasErrors
? "ATTENTION REQUIRED"
: allOffline
? "ALL SYSTEMS OFFLINE"
: "SYSTEMS OPERATIONAL";
const statusClass = hasErrors
? "status-error"
: allOffline
? "status-warning"
: "status-online";
return (
<div className="space-y-8">
{/* Page Header */}
<header className="space-y-2 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-gradient font-mono text-4xl font-bold tracking-tight">
DASHBOARD
</h1>
<p className="text-muted text-sm uppercase tracking-widest-custom mt-1">
Mission Control Overview
</p>
</div>
{/* System Status Indicator */}
<div className={`status-indicator ${statusClass}`}>
<span className="status-dot" />
<span className="font-mono text-xs uppercase tracking-wide">
{systemStatus}
</span>
</div>
</div>
</header>
{/* Stat Cards Grid */}
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{isLoading ? (
<>
<StatCardSkeleton delay={0.1} />
<StatCardSkeleton delay={0.2} />
<StatCardSkeleton delay={0.3} />
<StatCardSkeleton delay={0.4} />
</>
) : (
<>
<StatCard
title="Total Agents"
value={agents.length}
icon={Server}
description="Registered endpoints"
accentColor="cyan"
delay={0.1}
/>
<StatCard
title="Online"
value={onlineAgents.length}
icon={Wifi}
description="Currently connected"
accentColor="green"
delay={0.2}
/>
<StatCard
title="Offline"
value={offlineAgents.length}
icon={WifiOff}
description="Not responding"
accentColor="amber"
delay={0.3}
/>
<StatCard
title="Errors"
value={errorAgents.length}
icon={AlertTriangle}
description="Requires attention"
accentColor="rose"
delay={0.4}
/>
</>
)}
</section>
{/* Bottom Grid: Activity + Quick Actions */}
<section className="grid gap-6 md:grid-cols-2">
{/* Recent Activity Card */}
<div
className="glass-card opacity-0"
style={{
animation: "fadeInUp 0.4s ease-out 0.5s forwards",
}}
>
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[var(--border-secondary)]">
<h2 className="card-title flex items-center gap-2">
<Activity className="h-4 w-4 text-cyan" />
Recent Activity
</h2>
{!isLoading && agents.length > 0 && (
<span className="font-mono text-xs text-muted">
{agents.length} agent{agents.length !== 1 ? "s" : ""}
</span>
)}
</div>
{isLoading ? (
<ActivityListSkeleton />
) : agents.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-muted mx-auto mb-4 opacity-50" />
<p className="text-secondary font-medium mb-2">
No agents registered
</p>
<p className="text-muted text-sm">
Deploy an agent to start monitoring endpoints.
</p>
</div>
) : (
<div className="space-y-1 -mx-3">
{agents.slice(0, 5).map((agent: Agent) => (
<ActivityItem key={agent.id} agent={agent} />
))}
</div>
)}
</div>
{/* Quick Actions Card */}
<div
className="glass-card opacity-0"
style={{
animation: "fadeInUp 0.4s ease-out 0.6s forwards",
}}
>
<div className="flex items-center gap-2 mb-4 pb-4 border-b border-[var(--border-secondary)]">
<Zap className="h-4 w-4 text-cyan" />
<h2 className="card-title">Quick Actions</h2>
</div>
<div className="space-y-3">
<QuickActionButton
icon={Terminal}
label="Deploy Agent"
description="Install agent on a new endpoint"
/>
<QuickActionButton
icon={RefreshCw}
label="Refresh All"
description="Force update all agent statuses"
/>
<QuickActionButton
icon={Shield}
label="Security Scan"
description="Run security audit on all endpoints"
/>
</div>
{/* Terminal-style hint */}
<div className="mt-6 p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-secondary)]">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-cyan">$</span>
<span className="font-mono text-xs text-muted">
guru-rmm --help
</span>
<span className="inline-block w-2 h-4 bg-cyan animate-pulse ml-1" />
</div>
</div>
</div>
</section>
</div>
);
}