Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions frontend/src/app/dashboard/components/AgentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,47 +162,47 @@ export function AgentsTab({
{agents.map((agent) => {
const sc = statusConfig[agent.status] || statusConfig.idle;
return (
<div key={agent.id} className="rounded-xl border border-border/50 bg-card p-4 flex items-center gap-4 hover:border-border transition-colors">
<div className="flex items-center gap-3 min-w-[200px]">
<span className={`size-2.5 rounded-full ${sc.dot}`} />
<div>
<div className="font-medium">{agent.name}</div>
<div className="text-xs text-muted-foreground">{agent.host}</div>
<div key={agent.id} className="rounded-xl border border-border/50 bg-card p-4 flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 hover:border-border transition-colors">
<div className="flex items-center gap-3 min-w-0 sm:min-w-[200px]">
<span className={`size-2.5 rounded-full shrink-0 ${sc.dot}`} />
<div className="min-w-0">
<div className="font-medium truncate">{agent.name}</div>
<div className="text-xs text-muted-foreground truncate">{agent.host}</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className={`${sc.color} border text-xs`}>{sc.label}</Badge>
{agent.overLimit && agent.limit != null && (() => {
const spend = agent.limitType === "daily" ? (agent.todaySpend ?? 0) : (agent.mtdSpend ?? 0);
const over = spend - agent.limit;
return <Badge variant="outline" className="bg-red-500/10 text-red-400 border-red-500/20 border text-xs">⚠️ ${over.toFixed(2)} over</Badge>;
})()}
</div>
<div className="flex items-center gap-6 ml-auto text-sm text-muted-foreground">
<div className="text-right min-w-[80px]">
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center sm:gap-6 sm:ml-auto text-sm text-muted-foreground">
<div className="text-left sm:text-right">
<div className="text-foreground font-medium">${agent.costUsd.toFixed(2)}</div>
<div className="text-xs">cost</div>
</div>
<div className="text-right min-w-[80px]">
<div className="text-left sm:text-right">
<div className="text-foreground font-medium">{formatTokens(agent.tokenCount)}</div>
<div className="text-xs">tokens</div>
</div>
<div className="text-right min-w-[60px]">
<div className="text-left sm:text-right">
<div className={`font-medium ${agent.errorCount > 0 ? "text-red-400" : "text-foreground"}`}>{agent.errorCount}</div>
<div className="text-xs">errors</div>
</div>
<div className="text-right min-w-[80px]">
<div className="text-left sm:text-right">
<div className="text-foreground font-medium">{formatRelativeTime(agent.lastHeartbeat)}</div>
<div className="text-xs">heartbeat</div>
</div>
<div className="min-w-[90px]">
{(agent.status === "running" || agent.status === "paused") && (
<Button variant="outline" size="sm" onClick={() => onPauseResume(agent)} className="w-full text-xs">
{agent.status === "running" ? "Pause" : "Resume"}
</Button>
)}
</div>
</div>
{(agent.status === "running" || agent.status === "paused") && (
<div className="sm:ml-2">
<Button variant="outline" size="sm" onClick={() => onPauseResume(agent)} className="w-full sm:w-auto text-xs">
{agent.status === "running" ? "Pause" : "Resume"}
</Button>
</div>
)}
</div>
);
})}
Expand Down
80 changes: 49 additions & 31 deletions frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Badge } from "@/components/ui/badge";
import { Agent, Alert, AlertDetails, CostData, AgentStatus, AlertSeverity, Session, Project, Profile, AnalyticsData, SpendData, CostLimits } from "@/lib/types";
import { getAgents, getAlerts, getAlertDetails, getCosts, pauseAgent, resumeAgent, acknowledgeAlert, acknowledgeAllAlerts, getSessions, getProjects, getProfiles, getVersion, getAnalytics, getSpend, setCostLimits, isUsingMockData } from "@/lib/api";
import { ClaWatchLogo, ClaWatchIcon } from "@/components/clawatch-logo";
import { ErrorBoundary } from "@/components/ui/error-boundary";
import { StatsOverviewSkeleton, AgentsTabSkeleton, SessionsTabSkeleton, AnalyticsTabSkeleton } from "@/components/ui/dashboard-skeletons";
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imports: SessionsTabSkeleton and AnalyticsTabSkeleton are imported here but never referenced in this file. This will trip Next/ESLint no-unused-vars during CI. Remove them or use them in the loading UI.

Suggested change
import { StatsOverviewSkeleton, AgentsTabSkeleton, SessionsTabSkeleton, AnalyticsTabSkeleton } from "@/components/ui/dashboard-skeletons";
import { StatsOverviewSkeleton, AgentsTabSkeleton } from "@/components/ui/dashboard-skeletons";

Copilot uses AI. Check for mistakes.
import { AgentsTab } from "./components/AgentsTab";
import { SessionsTab } from "./components/SessionsTab";
import { AnalyticsTab } from "./components/AnalyticsTab";
Expand Down Expand Up @@ -477,10 +479,18 @@ function DashboardContent() {

if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<div className="size-5 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
Loading dashboard...
<div className="min-h-screen bg-background text-foreground">
<nav className="border-b border-border/50 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-6">
<Link href="/" className="flex items-center gap-2"><ClaWatchIcon /><ClaWatchLogo size="md" /></Link>
<span className="text-sm text-muted-foreground">Dashboard</span>
</div>
</div>
</nav>
<div className="max-w-7xl mx-auto px-6 py-6 space-y-6">
<StatsOverviewSkeleton />
<AgentsTabSkeleton />
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When loading is true, the page always renders AgentsTabSkeleton() regardless of the active tab (e.g. ?tab=sessions / ?tab=analytics). This causes a mismatched skeleton for deep links. Render the skeleton corresponding to the current tab (and consider adding a projects skeleton or a generic one).

Suggested change
<AgentsTabSkeleton />
{tab === "sessions"
? <SessionsTabSkeleton />
: tab === "analytics"
? <AnalyticsTabSkeleton />
: <AgentsTabSkeleton />}

Copilot uses AI. Check for mistakes.
</div>
</div>
);
Expand Down Expand Up @@ -618,39 +628,47 @@ function DashboardContent() {

{/* Tab Content */}
{tab === "agents" && (
<AgentsTab
agents={agents} alerts={alerts} alertsTotal={alertsTotal} costs={costs} spendData={spendData}
showIdleAgents={showIdleAgents} setShowIdleAgents={setShowIdleAgents}
alertFilter={alertFilter} setAlertFilter={setAlertFilter} alertPage={alertPage} setAlertPage={setAlertPage}
onPauseResume={handlePauseResume} onAcknowledge={handleAcknowledge} onAcknowledgeAll={handleAcknowledgeAll} ackAllLoading={ackAllLoading}
onToggleAlertDetails={handleToggleAlertDetails} expandedAlerts={expandedAlerts} prefetchedDetails={prefetchedDetails}
showStackTrace={showStackTrace} setShowStackTrace={setShowStackTrace}
/>
<ErrorBoundary>
<AgentsTab
agents={agents} alerts={alerts} alertsTotal={alertsTotal} costs={costs} spendData={spendData}
showIdleAgents={showIdleAgents} setShowIdleAgents={setShowIdleAgents}
alertFilter={alertFilter} setAlertFilter={setAlertFilter} alertPage={alertPage} setAlertPage={setAlertPage}
onPauseResume={handlePauseResume} onAcknowledge={handleAcknowledge} onAcknowledgeAll={handleAcknowledgeAll} ackAllLoading={ackAllLoading}
onToggleAlertDetails={handleToggleAlertDetails} expandedAlerts={expandedAlerts} prefetchedDetails={prefetchedDetails}
showStackTrace={showStackTrace} setShowStackTrace={setShowStackTrace}
/>
</ErrorBoundary>
)}
{tab === "sessions" && (
<SessionsTab
sessions={sessions} sessionsTotal={sessionsTotal} projects={projects}
sessionFilter={sessionFilter} setSessionFilter={setSessionFilter}
sessionSort={sessionSort} setSessionSort={setSessionSort}
sessionPage={sessionPage} setSessionPage={setSessionPage}
setSessions={setSessions}
/>
<ErrorBoundary>
<SessionsTab
sessions={sessions} sessionsTotal={sessionsTotal} projects={projects}
sessionFilter={sessionFilter} setSessionFilter={setSessionFilter}
sessionSort={sessionSort} setSessionSort={setSessionSort}
sessionPage={sessionPage} setSessionPage={setSessionPage}
setSessions={setSessions}
/>
</ErrorBoundary>
)}
{tab === "analytics" && (
<AnalyticsTab
analyticsData={analyticsData} analyticsLoading={analyticsLoading} showingDemoData={showingDemoData} spendData={spendData}
timeWindow={timeWindow} setTimeWindowParam={setTimeWindowParam} customFrom={customFrom} customTo={customTo} setCustomDates={setCustomDates}
zoomRange={zoomRange} zoomLeft={zoomLeft} zoomRight={zoomRight} zoomFetching={zoomFetching} isDragging={isDragging}
handleZoomMouseDown={handleZoomMouseDown} handleZoomMouseMove={handleZoomMouseMove} handleZoomMouseUp={handleZoomMouseUp} resetZoom={resetZoom}
zoomedBuckets={zoomedBuckets} zoomedByProject={zoomedByProject} zoomedByAgent={zoomedByAgent}
effectiveGroupBy={effectiveGroupBy} activeLabel={activeLabel}
hiddenAgentSeries={hiddenAgentSeries} setHiddenAgentSeries={setHiddenAgentSeries}
hiddenProjectSeries={hiddenProjectSeries} setHiddenProjectSeries={setHiddenProjectSeries}
clearZoomState={clearZoomState}
/>
<ErrorBoundary>
<AnalyticsTab
analyticsData={analyticsData} analyticsLoading={analyticsLoading} showingDemoData={showingDemoData} spendData={spendData}
timeWindow={timeWindow} setTimeWindowParam={setTimeWindowParam} customFrom={customFrom} customTo={customTo} setCustomDates={setCustomDates}
zoomRange={zoomRange} zoomLeft={zoomLeft} zoomRight={zoomRight} zoomFetching={zoomFetching} isDragging={isDragging}
handleZoomMouseDown={handleZoomMouseDown} handleZoomMouseMove={handleZoomMouseMove} handleZoomMouseUp={handleZoomMouseUp} resetZoom={resetZoom}
zoomedBuckets={zoomedBuckets} zoomedByProject={zoomedByProject} zoomedByAgent={zoomedByAgent}
effectiveGroupBy={effectiveGroupBy} activeLabel={activeLabel}
hiddenAgentSeries={hiddenAgentSeries} setHiddenAgentSeries={setHiddenAgentSeries}
hiddenProjectSeries={hiddenProjectSeries} setHiddenProjectSeries={setHiddenProjectSeries}
clearZoomState={clearZoomState}
/>
</ErrorBoundary>
)}
{tab === "projects" && (
<ProjectsTab projects={projects} setProjects={setProjects} />
<ErrorBoundary>
<ProjectsTab projects={projects} setProjects={setProjects} />
</ErrorBoundary>
)}
</div>
</div>
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/components/ui/dashboard-skeletons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"

export function StatsOverviewSkeleton() {
return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-9 w-20 mb-2" />
<Skeleton className="h-3 w-16" />
</CardContent>
</Card>
))}
</div>
)
}

export function AgentsTabSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-5 w-36" />
</div>
<div className="grid gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-border/50 bg-card p-4 flex flex-col sm:flex-row items-start sm:items-center gap-4"
>
<div className="flex items-center gap-3 min-w-0">
<Skeleton className="size-2.5 rounded-full shrink-0" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<div className="flex items-center gap-6 sm:ml-auto">
<div className="space-y-1">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3 w-8" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3 w-8" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
))}
</div>
</div>
)
}

export function SessionsTabSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-16 rounded-md" />
))}
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/50 bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
)
}

export function AnalyticsTabSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-40" />
<div className="flex gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-14 rounded-md" />
))}
</div>
</div>
<Card>
<CardContent className="pt-4">
<Skeleton className="h-64 w-full rounded-lg" />
</CardContent>
</Card>
<div className="grid lg:grid-cols-2 gap-6">
<Card>
<CardContent className="pt-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-2 w-32 rounded-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-2 w-32 rounded-full" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
)
}
56 changes: 56 additions & 0 deletions frontend/src/components/ui/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client"

import { Component, type ReactNode } from "react"

interface ErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
onReset?: () => void
}

interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}

reset = () => {
this.setState({ hasError: false, error: null })
this.props.onReset?.()
}

render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback

return (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-6 text-center">
<div className="text-2xl mb-2">💥</div>
<div className="text-sm font-medium text-red-400 mb-1">
Something went wrong
</div>
<div className="text-xs text-muted-foreground mb-4 max-w-md mx-auto break-words">
{this.state.error?.message || "An unexpected error occurred"}
</div>
<button
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry control is a plain <button> without an explicit type. If this boundary is ever rendered inside a <form>, the default type="submit" can cause unintended form submissions. Set type="button" on this button.

Suggested change
<button
<button
type="button"

Copilot uses AI. Check for mistakes.
onClick={this.reset}
className="px-4 py-1.5 text-xs font-medium rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Try again
</button>
</div>
)
}

return this.props.children
}
}
12 changes: 12 additions & 0 deletions frontend/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils"

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
Comment on lines +1 to +3
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skeleton uses React.ComponentProps but the file doesn’t import React (or ComponentProps) anywhere. With the repo’s TS config this will fail to typecheck (Cannot find namespace 'React'). Import * as React or switch to import type { ComponentProps } from "react" and use ComponentProps<"div">.

Suggested change
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: ComponentProps<"div">) {

Copilot uses AI. Check for mistakes.
return (
<div
className={cn("animate-pulse rounded-md bg-muted/50", className)}
{...props}
/>
)
}

export { Skeleton }
Loading