diff --git a/projects/msp-tools/guru-rmm/dashboard/src/App.tsx b/projects/msp-tools/guru-rmm/dashboard/src/App.tsx index 545043f..a04070b 100644 --- a/projects/msp-tools/guru-rmm/dashboard/src/App.tsx +++ b/projects/msp-tools/guru-rmm/dashboard/src/App.tsx @@ -9,7 +9,7 @@ import { Clients } from "./pages/Clients"; import { Sites } from "./pages/Sites"; import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; -import { Commands } from "./pages/Commands"; +import { History, HistoryDetail } from "./pages/History"; import { Settings } from "./pages/Settings"; import "./index.css"; @@ -118,10 +118,18 @@ function AppRoutes() { } /> - + + + } + /> + + } /> diff --git a/projects/msp-tools/guru-rmm/dashboard/src/components/Layout.tsx b/projects/msp-tools/guru-rmm/dashboard/src/components/Layout.tsx index 311e35d..c04d5ca 100644 --- a/projects/msp-tools/guru-rmm/dashboard/src/components/Layout.tsx +++ b/projects/msp-tools/guru-rmm/dashboard/src/components/Layout.tsx @@ -1,17 +1,22 @@ -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { commandsApi, Command } from "../api/client"; import { LayoutDashboard, Server, - Terminal, Settings, LogOut, Menu, X, Building2, MapPin, + History, + CheckCircle, + XCircle, + Clock, + Loader2, } from "lucide-react"; -import { useState } from "react"; import { useAuth } from "../hooks/useAuth"; import { Button } from "./Button"; @@ -24,16 +29,37 @@ const navItems = [ { path: "/clients", label: "Clients", icon: Building2 }, { path: "/sites", label: "Sites", icon: MapPin }, { path: "/agents", label: "Agents", icon: Server }, - { path: "/commands", label: "Commands", icon: Terminal }, { path: "/settings", label: "Settings", icon: Settings }, ]; +const APP_VERSION = "0.2.0"; +const SERVER_VERSION = "0.1.0"; + +function CommandStatusIcon({ status }: { status: Command["status"] }) { + const config = { + pending: { icon: Clock, color: "text-amber-500" }, + running: { icon: Loader2, color: "text-cyan-500 animate-spin" }, + completed: { icon: CheckCircle, color: "text-emerald-500" }, + failed: { icon: XCircle, color: "text-rose-500" }, + }; + const { icon: Icon, color } = config[status]; + return ; +} + export function Layout({ children }: LayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); + const { data: commands = [] } = useQuery({ + queryKey: ["commands"], + queryFn: () => commandsApi.list().then((res) => res.data), + refetchInterval: 15000, + }); + + const recentCommands = commands.slice(0, 4); + const handleLogout = () => { logout(); navigate("/login"); @@ -85,7 +111,7 @@ export function Layout({ children }: LayoutProps) {
{/* Sidebar - Mission Control Aesthetic */} {/* Main content area */}
-
+
{children}
diff --git a/projects/msp-tools/guru-rmm/dashboard/src/index.css b/projects/msp-tools/guru-rmm/dashboard/src/index.css index 9ece806..b3fb0ef 100644 --- a/projects/msp-tools/guru-rmm/dashboard/src/index.css +++ b/projects/msp-tools/guru-rmm/dashboard/src/index.css @@ -395,7 +395,7 @@ code, pre, .mono { border: 1px solid var(--glass-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); - padding: 1.5rem; + padding: 1rem; transition: all var(--transition-base); } @@ -669,7 +669,7 @@ code, pre, .mono { background: var(--bg-card); border: 1px solid var(--border-primary); border-radius: var(--radius-lg); - padding: 1.5rem; + padding: 1rem; transition: all var(--transition-base); } @@ -683,8 +683,8 @@ code, pre, .mono { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 1rem; - padding-bottom: 1rem; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; border-bottom: 1px solid var(--border-secondary); } @@ -703,10 +703,10 @@ code, pre, .mono { backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); - padding: 1.25rem; + padding: 1rem; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.375rem; } .metric-label { @@ -719,7 +719,7 @@ code, pre, .mono { .metric-value { font-family: var(--font-mono); - font-size: 2rem; + font-size: 1.75rem; font-weight: 700; color: var(--text-primary); line-height: 1; @@ -761,13 +761,13 @@ code, pre, .mono { text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.75rem; - padding: 0.75rem 1rem; + padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border-primary); } .data-grid td { - padding: 0.75rem 1rem; + padding: 0.5rem 0.75rem; color: var(--text-secondary); border-bottom: 1px solid var(--border-secondary); transition: background var(--transition-fast); @@ -790,7 +790,7 @@ code, pre, .mono { align-items: center; justify-content: center; gap: 0.5rem; - padding: 0.625rem 1.25rem; + padding: 0.5rem 1rem; font-family: var(--font-mono); font-size: 0.875rem; font-weight: 600; @@ -954,9 +954,9 @@ code, pre, .mono { display: flex; align-items: center; gap: 0.5rem; - padding: 0.5rem 1rem; + padding: 0.375rem 0.75rem; font-family: var(--font-mono); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 500; color: var(--text-muted); background: transparent; @@ -987,7 +987,7 @@ code, pre, .mono { border-right: 1px solid var(--glass-border); height: 100vh; position: fixed; - width: 260px; + width: 240px; overflow-y: auto; z-index: 100; } @@ -1238,6 +1238,15 @@ code, pre, .mono { letter-spacing: 0.2em; } +/* Compact spacing utilities */ +.space-y-compact > * + * { + margin-top: 0.75rem; +} + +.gap-compact { + gap: 0.75rem; +} + /* ============================================================ SCROLLBAR STYLING ============================================================ */ diff --git a/projects/msp-tools/guru-rmm/dashboard/src/pages/Agents.tsx b/projects/msp-tools/guru-rmm/dashboard/src/pages/Agents.tsx index edd05ed..4de3202 100644 --- a/projects/msp-tools/guru-rmm/dashboard/src/pages/Agents.tsx +++ b/projects/msp-tools/guru-rmm/dashboard/src/pages/Agents.tsx @@ -1,21 +1,36 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Link } from "react-router-dom"; -import { Trash2, Terminal, RefreshCw, MoveRight, Building2, MapPin } from "lucide-react"; +import { Link, useSearchParams } from "react-router-dom"; +import { Trash2, Terminal, RefreshCw, MoveRight, X } from "lucide-react"; import { agentsApi, sitesApi, Agent, Site } from "../api/client"; -import { Card, CardHeader, CardTitle, CardContent } from "../components/Card"; +import { Card, CardContent } from "../components/Card"; import { Button } from "../components/Button"; import { Input } from "../components/Input"; function AgentStatusBadge({ status }: { status: Agent["status"] }) { - const colors = { - online: "bg-green-100 text-green-800", - offline: "bg-gray-100 text-gray-800", - error: "bg-red-100 text-red-800", + const statusConfig = { + online: { + dotClass: "bg-[var(--accent-green)] shadow-[0_0_8px_var(--accent-green)]", + badgeClass: "bg-[var(--accent-green-muted)] text-[var(--accent-green-light)] border border-[rgba(16,185,129,0.3)]", + animate: true, + }, + offline: { + dotClass: "bg-[var(--text-muted)]", + badgeClass: "bg-[rgba(100,116,139,0.2)] text-[var(--text-muted)] border border-[rgba(100,116,139,0.3)]", + animate: false, + }, + error: { + dotClass: "bg-[var(--accent-rose)] shadow-[0_0_8px_var(--accent-rose)]", + badgeClass: "bg-[var(--accent-rose-muted)] text-[var(--accent-rose-light)] border border-[rgba(244,63,94,0.3)]", + animate: true, + }, }; + const config = statusConfig[status]; + return ( - + + {status} ); @@ -37,36 +52,39 @@ function MoveAgentModal({ const [selectedSiteId, setSelectedSiteId] = useState(agent.site_id || ""); return ( -
-
-

Move Agent

-

- Move {agent.hostname} to a different site +

+
+

Move Agent

+

+ Move {agent.hostname} to a different site

-
- +
+
-
+
@@ -82,6 +100,8 @@ export function Agents() { const [movingAgent, setMovingAgent] = useState(null); const [filterClient, setFilterClient] = useState(""); const [filterSite, setFilterSite] = useState(""); + const [searchParams, setSearchParams] = useSearchParams(); + const statusFilter = searchParams.get("status") || ""; const queryClient = useQueryClient(); const { data: agents = [], isLoading, refetch } = useQuery({ @@ -144,209 +164,259 @@ export function Agents() { } const matchesSite = !filterSite || agent.site_id === filterSite; + const matchesStatus = !statusFilter || agent.status === statusFilter; - return matchesSearch && matchesClient && matchesSite; + return matchesSearch && matchesClient && matchesSite && matchesStatus; }); - // Group agents by client > site for display - const groupedAgents = filteredAgents.reduce((acc: Record>, agent: Agent) => { - const clientKey = agent.client_name || "Unassigned"; - const siteKey = agent.site_name || "No Site"; - - if (!acc[clientKey]) acc[clientKey] = {}; - if (!acc[clientKey][siteKey]) acc[clientKey][siteKey] = []; - acc[clientKey][siteKey].push(agent); - - return acc; - }, {}); + const clearStatusFilter = () => { + searchParams.delete("status"); + setSearchParams(searchParams); + }; return (
+ {/* Header */}
-

Agents

-

+

+

Agents

+ {statusFilter && ( + + )} +
+

Manage your monitored endpoints

-
-
- setSearch(e.target.value)} - className="max-w-sm" - /> - - {filterClient && ( + {/* Filters */} +
+
+ setSearch(e.target.value)} + className="max-w-sm bg-[var(--bg-secondary)] border-[var(--border-primary)] text-[var(--text-primary)] font-mono placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)] focus:ring-[var(--accent-cyan-muted)]" + /> - )} + {filterClient && filterClient !== "__unassigned__" && ( + + )} + +
- {/* Grouped View */} - {Object.entries(groupedAgents).map(([clientName, siteGroups]) => ( - - - - - {clientName} - - ({Object.values(siteGroups).flat().length} agents) - - - - - {Object.entries(siteGroups).map(([siteName, siteAgents]) => ( -
-
- - {siteName} - ({siteAgents.length} agents) -
-
- - - - - - - - - - - - - {siteAgents.map((agent: Agent) => ( - 0 && ( + + +
+
HostnameOSStatusLast SeenVersionActions
+ + + + + + + + + + + + + + {filteredAgents.map((agent: Agent) => ( + + + + + + + + + - - - - - - - ))} - -
HostnameClientSiteOSStatusLast SeenVersionActions
+ - - + + {agent.client_name || -} + + {agent.site_name || -} + + {agent.os_type} + {agent.os_version && ( + + {" "}({agent.os_version}) + + )} + + + + {agent.last_seen + ? new Date(agent.last_seen).toLocaleString() + : "Never"} + + {agent.agent_version || "-"} + +
+ + +
- {agent.os_type} - {agent.os_version && ( - - {" "}({agent.os_version}) - - )} - - - - {agent.last_seen - ? new Date(agent.last_seen).toLocaleString() - : "Never"} - - {agent.agent_version || "-"} - -
+ + + + {deleteConfirm === agent.id ? ( +
+ - - - - {deleteConfirm === agent.id ? ( -
- - -
- ) : ( - - )}
-
-
-
- ))} + ) : ( + + )} +
+ + + ))} + + +
- ))} + )} {isLoading && ( -

Loading agents...

+
+
+ + Loading agents... +
+
)} {!isLoading && filteredAgents.length === 0 && ( - - -

- {search || filterClient ? "No agents match your filters." : "No agents registered yet."} + + +

+ {search || filterClient || statusFilter + ? "No agents match your filters." + : "No agents registered yet."}

+ {(search || filterClient || statusFilter) && ( + + )}
)} diff --git a/projects/msp-tools/guru-rmm/dashboard/src/pages/Commands.tsx b/projects/msp-tools/guru-rmm/dashboard/src/pages/Commands.tsx deleted file mode 100644 index c6b88a7..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/src/pages/Commands.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { RefreshCw, CheckCircle, XCircle, Clock, Play } from "lucide-react"; -import { commandsApi, Command } from "../api/client"; -import { Card, CardHeader, CardTitle, CardContent } from "../components/Card"; -import { Button } from "../components/Button"; - -function StatusIcon({ status }: { status: Command["status"] }) { - const icons = { - pending: , - running: , - completed: , - failed: , - }; - return icons[status]; -} - -export function Commands() { - const { data: commands = [], isLoading, refetch } = useQuery({ - queryKey: ["commands"], - queryFn: () => commandsApi.list().then((res) => res.data), - refetchInterval: 10000, - }); - - return ( -
-
-
-

Commands

-

- View command history and results -

-
- -
- - - - Command History - - - {isLoading ? ( -

Loading commands...

- ) : commands.length === 0 ? ( -

- No commands have been executed yet. -

- ) : ( -
- {commands.map((cmd: Command) => ( -
-
-
- -
-

{cmd.command_text}

-

- {cmd.command_type} • Agent: {cmd.agent_id.slice(0, 8)}... •{" "} - {new Date(cmd.created_at).toLocaleString()} -

-
-
- - {cmd.status} - {cmd.exit_code !== null && ` (${cmd.exit_code})`} - -
- - {(cmd.stdout || cmd.stderr) && ( -
- {cmd.stdout && ( -
-

- Output: -

-
-                            {cmd.stdout}
-                          
-
- )} - {cmd.stderr && ( -
-

Error:

-
-                            {cmd.stderr}
-                          
-
- )} -
- )} -
- ))} -
- )} -
-
-
- ); -} diff --git a/projects/msp-tools/guru-rmm/dashboard/src/pages/Dashboard.tsx b/projects/msp-tools/guru-rmm/dashboard/src/pages/Dashboard.tsx index 82cbc9c..503d51d 100644 --- a/projects/msp-tools/guru-rmm/dashboard/src/pages/Dashboard.tsx +++ b/projects/msp-tools/guru-rmm/dashboard/src/pages/Dashboard.tsx @@ -1,4 +1,6 @@ +import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; import { Activity, Server, @@ -9,6 +11,7 @@ import { RefreshCw, Shield, Zap, + ArrowRight, } from "lucide-react"; import { agentsApi, Agent } from "../api/client"; @@ -60,7 +63,7 @@ function StatCardSkeleton({ delay }: { delay: number }) { } /** - * Stat card component with Mission Control styling + * Stat card component with Mission Control styling - navigates to filtered agents page on click */ function StatCard({ title, @@ -69,6 +72,7 @@ function StatCard({ description, accentColor, delay, + linkTo, }: { title: string; value: string | number; @@ -76,47 +80,59 @@ function StatCard({ description?: string; accentColor: "cyan" | "green" | "amber" | "rose"; delay: number; + linkTo: string; }) { + const navigate = useNavigate(); + 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]; + const handleClick = () => { + navigate(linkTo); + }; + return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + }} > {/* Subtle gradient overlay on hover */}
-
-
-

- {title} -

-

- {value} -

- {description && ( -

{description}

- )} +
+
+
+

+ {title} +

+

+ {value} +

+ {description && ( +

{description}

+ )} +
+ + {/* Icon - smaller padding */} +
+ +
- {/* Icon with glow effect */} -
- + {/* Simplified view all - no border */} +
+ View all +
@@ -168,12 +194,15 @@ function StatusDot({ status }: { status: string }) { } /** - * Activity list item with hover effects + * Activity list item with hover effects - clickable with Link */ function ActivityItem({ agent }: { agent: Agent }) { return ( -
-
+ +

@@ -184,10 +213,13 @@ function ActivityItem({ agent }: { agent: Agent }) {

- - {formatRelativeTime(agent.last_seen)} - -
+
+ + {formatRelativeTime(agent.last_seen)} + + +
+ ); } @@ -243,6 +275,10 @@ function ActivityListSkeleton() { /** * Main Dashboard component with Mission Control aesthetic + * + * Stat cards navigate to the Agents page with status filters. + * This approach scales better for large agent counts (1000+) since + * the Agents page handles pagination, search, and sorting. */ export function Dashboard() { const { data: agents = [], isLoading } = useQuery({ @@ -270,12 +306,12 @@ export function Dashboard() { : "status-online"; return ( -
+
{/* Page Header */} -
+
-

+

DASHBOARD

@@ -294,7 +330,7 @@ export function Dashboard() {

{/* Stat Cards Grid */} -
+
{isLoading ? ( <> @@ -311,6 +347,7 @@ export function Dashboard() { description="Registered endpoints" accentColor="cyan" delay={0.1} + linkTo="/agents" /> )}
{/* Bottom Grid: Activity + Quick Actions */} -
+
{/* Recent Activity Card */}
-
+

Recent Activity @@ -374,7 +414,7 @@ export function Dashboard() {

) : ( -
+
{agents.slice(0, 5).map((agent: Agent) => ( ))} @@ -389,12 +429,12 @@ export function Dashboard() { animation: "fadeInUp 0.4s ease-out 0.6s forwards", }} > -
+

Quick Actions

-
+
{/* Terminal-style hint */} -
+
$ diff --git a/projects/msp-tools/guru-rmm/dashboard/src/pages/History.tsx b/projects/msp-tools/guru-rmm/dashboard/src/pages/History.tsx new file mode 100644 index 0000000..a69dc44 --- /dev/null +++ b/projects/msp-tools/guru-rmm/dashboard/src/pages/History.tsx @@ -0,0 +1,281 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams, useNavigate } from "react-router-dom"; +import { RefreshCw, CheckCircle, XCircle, Clock, Loader2, ArrowLeft, Terminal } from "lucide-react"; +import { commandsApi, Command } from "../api/client"; +import { Card, CardContent } from "../components/Card"; +import { Button } from "../components/Button"; + +function StatusBadge({ status }: { status: Command["status"] }) { + const config = { + pending: { + icon: Clock, + label: "Pending", + className: "bg-amber-500/10 text-amber-400 border-amber-500/30", + }, + running: { + icon: Loader2, + label: "Running", + className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/30", + spin: true, + }, + completed: { + icon: CheckCircle, + label: "Completed", + className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/30", + }, + failed: { + icon: XCircle, + label: "Failed", + className: "bg-rose-500/10 text-rose-400 border-rose-500/30", + }, + }; + + const { icon: Icon, label, className, spin } = config[status] as { + icon: typeof Clock; + label: string; + className: string; + spin?: boolean; + }; + + return ( + + + {label} + + ); +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString(); +} + +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return "Just now"; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; + return `${Math.floor(diffInSeconds / 86400)}d ago`; +} + +export function History() { + const { data: commands = [], isLoading, refetch } = useQuery({ + queryKey: ["commands"], + queryFn: () => commandsApi.list().then((res) => res.data), + refetchInterval: 10000, + }); + + return ( +
+ {/* Header */} +
+
+

History

+

Command execution log

+
+ +
+ + {/* History List */} + + + {isLoading ? ( +
+ +

Loading history...

+
+ ) : commands.length === 0 ? ( +
+ +

No commands executed yet

+
+ ) : ( +
+ {commands.map((cmd: Command) => ( + +
+ +
+

+ {cmd.command_text} +

+

+ {cmd.command_type} | Agent: {cmd.agent_id.slice(0, 8)}... +

+
+
+
+

+ {formatRelativeTime(cmd.created_at)} +

+ {cmd.exit_code !== null && ( +

+ exit: {cmd.exit_code} +

+ )} +
+ + ))} +
+ )} +
+
+
+ ); +} + +export function HistoryDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: commands = [], isLoading } = useQuery({ + queryKey: ["commands"], + queryFn: () => commandsApi.list().then((res) => res.data), + }); + + const command = commands.find((cmd: Command) => cmd.id === id); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!command) { + return ( +
+ + + +

Command not found

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+
+

Command Detail

+ +
+

+ {formatDate(command.created_at)} +

+
+
+ + {/* Command Info */} + + + {/* Command */} +
+ +
+ {command.command_text} +
+
+ + {/* Meta info */} +
+
+ +

{command.command_type}

+
+
+ + + {command.agent_id.slice(0, 12)}... + +
+
+ +

+ {command.exit_code !== null ? command.exit_code : "-"} +

+
+
+ +

+ {formatRelativeTime(command.created_at)} +

+
+
+ + {/* Output */} + {command.stdout && ( +
+ +
+                {command.stdout}
+              
+
+ )} + + {/* Error */} + {command.stderr && ( +
+ +
+                {command.stderr}
+              
+
+ )} +
+
+
+ ); +}