@@ -125,14 +157,19 @@ export default function Home() { } function ContentRouter({ tab }: { tab: string }) { + const { dashboardMode } = useMissionControl() + const isLocal = dashboardMode === 'local' + switch (tab) { case 'overview': return ( <> -
- -
+ {!isLocal && ( +
+ +
+ )} ) case 'tasks': @@ -142,9 +179,11 @@ function ContentRouter({ tab }: { tab: string }) { <> -
- -
+ {!isLocal && ( +
+ +
+ )} ) case 'activity': diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index 0745384..1f76760 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -16,27 +16,50 @@ interface DbStats { webhookCount: number } +interface ClaudeStats { + total_sessions: number + active_sessions: number + total_input_tokens: number + total_output_tokens: number + total_estimated_cost: number + unique_projects: number +} + export function Dashboard() { const { sessions, setSessions, connection, + dashboardMode, + subscription, logs, agents, tasks, setActiveTab, } = useMissionControl() + const isLocal = dashboardMode === 'local' + const subscriptionLabel = subscription?.type + ? subscription.type.charAt(0).toUpperCase() + subscription.type.slice(1) + : null const [systemStats, setSystemStats] = useState(null) const [dbStats, setDbStats] = useState(null) + const [claudeStats, setClaudeStats] = useState(null) + const [githubStats, setGithubStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const loadDashboard = useCallback(async () => { try { - const [dashRes, sessRes] = await Promise.all([ + const fetches: Promise[] = [ fetch('/api/status?action=dashboard'), fetch('/api/sessions'), - ]) + ] + if (isLocal) { + fetches.push(fetch('/api/claude/sessions')) + fetches.push(fetch('/api/github?action=stats')) + } + + const [dashRes, sessRes, claudeRes, ghRes] = await Promise.all(fetches) if (dashRes.ok) { const data = await dashRes.json() @@ -50,12 +73,22 @@ export function Dashboard() { const data = await sessRes.json() if (data && !data.error) setSessions(data.sessions || data) } + + if (claudeRes?.ok) { + const data = await claudeRes.json() + if (data?.stats) setClaudeStats(data.stats) + } + + if (ghRes?.ok) { + const data = await ghRes.json() + if (data && !data.error) setGithubStats(data) + } } catch { // silent } finally { setIsLoading(false) } - }, [setSessions]) + }, [setSessions, isLocal]) useSmartPoll(loadDashboard, 60000, { pauseWhenConnected: true }) @@ -89,41 +122,83 @@ export function Dashboard() {
{/* Top Metric Cards */}
-
setActiveTab('history')}> - } - color="blue" - /> -
-
setActiveTab('agents')}> - } - color="green" - /> -
-
setActiveTab('tasks')}> - } - color="purple" - /> -
-
setActiveTab('logs')}> - } - color={errorCount > 0 ? 'red' : 'green'} - /> -
+ {isLocal ? ( + <> +
setActiveTab('sessions')}> + } + color="blue" + /> +
+
setActiveTab('sessions')}> + } + color="green" + /> +
+
setActiveTab('tokens')}> + } + color="purple" + /> +
+
setActiveTab('tokens')}> + } + color={subscriptionLabel ? 'green' : (claudeStats && claudeStats.total_estimated_cost > 10 ? 'red' : 'green')} + /> +
+ + ) : ( + <> +
setActiveTab('history')}> + } + color="blue" + /> +
+
setActiveTab('agents')}> + } + color="green" + /> +
+
setActiveTab('tasks')}> + } + color="purple" + /> +
+
setActiveTab('logs')}> + } + color={errorCount > 0 ? 'red' : 'green'} + /> +
+ + )}
{/* Three-column layout */} @@ -132,14 +207,25 @@ export function Dashboard() {

System Health

- + {isLocal ? ( + + + Local + + ) : ( + + )}
- + {isLocal ? ( + + ) : ( + + )} {memPct != null && (
- {/* Security & Audit */} -
setActiveTab('audit')}> -
-

Security & Audit

- {dbStats && dbStats.audit.loginFailures > 0 && ( - - {dbStats.audit.loginFailures} failed login{dbStats.audit.loginFailures > 1 ? 's' : ''} + {/* Middle panel: Claude Stats (local) or Security & Audit (full) */} + {isLocal ? ( +
setActiveTab('sessions')}> +
+

Claude Code Stats

+ + {claudeStats?.total_sessions ?? 0} sessions - )} -
-
- - - 0 : false} - /> - - -
-
- Unread notifications - 0 ? 'text-amber-400' : 'text-muted-foreground' - }`}> - {dbStats?.notifications.unread ?? 0} - -
-
-
- - {/* Backup & Data */} -
-
-

Backup & Pipelines

-
-
- {dbStats?.backup ? ( - <> +
+ + + +
- Latest backup - 48 ? 'text-red-400' : - dbStats.backup.age_hours > 24 ? 'text-amber-400' : 'text-green-400' - }`}> - {dbStats.backup.age_hours < 1 ? '<1h ago' : `${dbStats.backup.age_hours}h ago`} + Input tokens + + {formatTokensShort(claudeStats?.total_input_tokens ?? 0)}
- Backup size - - {formatBytes(dbStats.backup.size)} + Output tokens + + {formatTokensShort(claudeStats?.total_output_tokens ?? 0)}
- - ) : ( -
- Latest backup - None
- )} -
- - -
-
-
- Tasks by status -
- {dbStats?.tasks.total ? ( -
- {Object.entries(dbStats.tasks.byStatus).map(([status, count]) => ( - - - {status}: {count} +
+
+ Estimated cost + {subscriptionLabel ? ( + + Included ({subscriptionLabel}) - ))} + ) : ( + 10 ? 'text-amber-400' : 'text-green-400' + }`}> + ${(claudeStats?.total_estimated_cost ?? 0).toFixed(2)} + + )}
+
+
+
+ ) : ( +
setActiveTab('audit')}> +
+

Security & Audit

+ {dbStats && dbStats.audit.loginFailures > 0 && ( + + {dbStats.audit.loginFailures} failed login{dbStats.audit.loginFailures > 1 ? 's' : ''} + + )} +
+
+ + + 0 : false} + /> + + +
+
+ Unread notifications + 0 ? 'text-amber-400' : 'text-muted-foreground' + }`}> + {dbStats?.notifications.unread ?? 0} + +
+
+
+
+ )} + + {/* Third column: GitHub (local) or Backup & Pipelines (full) */} + {isLocal ? ( +
+
+

GitHub

+ {githubStats?.user && ( + @{githubStats.user.login} + )} +
+
+ {githubStats ? ( + <> + + + +
+ + + +
+ {githubStats.topLanguages.length > 0 && ( +
+
Top languages
+
+ {githubStats.topLanguages.map((lang: { name: string; count: number }) => ( + + + {lang.name} + + ))} +
+
+ )} + ) : ( - No tasks +
+

No GitHub token configured

+

Set GITHUB_TOKEN in .env.local

+
)}
-
+ ) : ( +
+
+

Backup & Pipelines

+
+
+ {dbStats?.backup ? ( + <> +
+ Latest backup + 48 ? 'text-red-400' : + dbStats.backup.age_hours > 24 ? 'text-amber-400' : 'text-green-400' + }`}> + {dbStats.backup.age_hours < 1 ? '<1h ago' : `${dbStats.backup.age_hours}h ago`} + +
+
+ Backup size + + {formatBytes(dbStats.backup.size)} + +
+ + ) : ( +
+ Latest backup + None +
+ )} +
+ + +
+
+
+ Tasks by status +
+ {dbStats?.tasks.total ? ( +
+ {Object.entries(dbStats.tasks.byStatus).map(([status, count]) => ( + + + {status}: {count} + + ))} +
+ ) : ( + No tasks + )} +
+
+
+ )}
{/* Bottom two-column: Sessions + Logs */} @@ -274,7 +452,7 @@ export function Dashboard() {
{sessions.length === 0 ? ( -

No active sessions

Sessions appear when agents connect via gateway

+

No active sessions

{isLocal ? 'Sessions appear when Claude Code or Codex CLI are running' : 'Sessions appear when agents connect via gateway'}

) : ( sessions.slice(0, 8).map((session) => (
@@ -334,11 +512,17 @@ export function Dashboard() { {/* Quick Actions */}
- } setActiveTab={setActiveTab} /> + {!isLocal && ( + } setActiveTab={setActiveTab} /> + )} } setActiveTab={setActiveTab} /> } setActiveTab={setActiveTab} /> } setActiveTab={setActiveTab} /> - } setActiveTab={setActiveTab} /> + {isLocal ? ( + } setActiveTab={setActiveTab} /> + ) : ( + } setActiveTab={setActiveTab} /> + )}
) @@ -346,10 +530,11 @@ export function Dashboard() { // --- Sub-components --- -function MetricCard({ label, value, total, icon, color }: { +function MetricCard({ label, value, total, subtitle, icon, color }: { label: string - value: number + value: number | string total?: number + subtitle?: string icon: React.ReactNode color: 'blue' | 'green' | 'purple' | 'red' }) { @@ -372,6 +557,9 @@ function MetricCard({ label, value, total, icon, color }: { / {total} )}
+ {subtitle && ( +
{subtitle}
+ )}
) } @@ -457,6 +645,12 @@ function formatUptime(ms: number): string { return `${hours}h` } +function formatTokensShort(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${Math.round(n / 1_000)}K` + return String(n) +} + function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 @@ -465,6 +659,28 @@ function formatBytes(bytes: number): string { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` } +function langColor(lang: string): string { + const colors: Record = { + TypeScript: 'bg-blue-500', + JavaScript: 'bg-yellow-500', + Python: 'bg-green-500', + Rust: 'bg-orange-500', + Go: 'bg-cyan-500', + Ruby: 'bg-red-500', + Java: 'bg-red-400', + 'C++': 'bg-pink-500', + C: 'bg-gray-500', + Shell: 'bg-emerald-500', + Solidity: 'bg-purple-500', + HTML: 'bg-orange-400', + CSS: 'bg-indigo-500', + Dart: 'bg-teal-500', + Swift: 'bg-orange-600', + Kotlin: 'bg-violet-500', + } + return colors[lang] || 'bg-muted-foreground/40' +} + function taskStatusColor(status: string): string { switch (status) { case 'done': return 'bg-green-500' @@ -550,3 +766,27 @@ function PipelineActionIcon() { ) } +function ProjectIcon() { + return ( + + + + + ) +} +function TokenIcon() { + return ( + + + + + ) +} +function CostIcon() { + return ( + + + + + ) +} diff --git a/src/components/layout/header-bar.tsx b/src/components/layout/header-bar.tsx index 9706bc2..2ae7e21 100644 --- a/src/components/layout/header-bar.tsx +++ b/src/components/layout/header-bar.tsx @@ -265,12 +265,17 @@ function MobileConnectionDot({ connection: { isConnected: boolean; reconnectAttempts: number } onReconnect: () => void }) { + const { dashboardMode } = useMissionControl() + const isLocal = dashboardMode === 'local' const isReconnecting = !connection.isConnected && connection.reconnectAttempts > 0 let dotClass: string let title: string - if (connection.isConnected) { + if (isLocal) { + dotClass = 'bg-blue-500' + title = 'Local Mode' + } else if (connection.isConnected) { dotClass = 'bg-green-500' title = 'Gateway connected' } else if (isReconnecting) { @@ -283,9 +288,9 @@ function MobileConnectionDot({ return ( + +
+ ) +} diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index ed8e343..5435fad 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -8,6 +8,7 @@ interface NavItem { label: string icon: React.ReactNode priority: boolean // Show in mobile bottom bar + requiresGateway?: boolean } interface NavGroup { @@ -21,7 +22,7 @@ const navGroups: NavGroup[] = [ id: 'core', items: [ { id: 'overview', label: 'Overview', icon: , priority: true }, - { id: 'agents', label: 'Agents', icon: , priority: true }, + { id: 'agents', label: 'Agents', icon: , priority: true, requiresGateway: true }, { id: 'tasks', label: 'Tasks', icon: , priority: true }, { id: 'sessions', label: 'Sessions', icon: , priority: false }, ], @@ -42,7 +43,7 @@ const navGroups: NavGroup[] = [ label: 'AUTOMATE', items: [ { id: 'cron', label: 'Cron', icon: , priority: false }, - { id: 'spawn', label: 'Spawn', icon: , priority: false }, + { id: 'spawn', label: 'Spawn', icon: , priority: false, requiresGateway: true }, { id: 'webhooks', label: 'Webhooks', icon: , priority: false }, { id: 'alerts', label: 'Alerts', icon: , priority: false }, { id: 'github', label: 'GitHub', icon: , priority: false }, @@ -56,7 +57,7 @@ const navGroups: NavGroup[] = [ { id: 'audit', label: 'Audit', icon: , priority: false }, { id: 'history', label: 'History', icon: , priority: false }, { id: 'gateways', label: 'Gateways', icon: , priority: false }, - { id: 'gateway-config', label: 'Config', icon: , priority: false }, + { id: 'gateway-config', label: 'Config', icon: , priority: false, requiresGateway: true }, { id: 'integrations', label: 'Integrations', icon: , priority: false }, { id: 'super-admin', label: 'Super Admin', icon: , priority: false }, { id: 'settings', label: 'Settings', icon: , priority: false }, @@ -68,7 +69,8 @@ const navGroups: NavGroup[] = [ const allNavItems = navGroups.flatMap(g => g.items) export function NavRail() { - const { activeTab, setActiveTab, connection, sidebarExpanded, collapsedGroups, toggleSidebar, toggleGroup } = useMissionControl() + const { activeTab, setActiveTab, connection, dashboardMode, sidebarExpanded, collapsedGroups, toggleSidebar, toggleGroup } = useMissionControl() + const isLocal = dashboardMode === 'local' // Keyboard shortcut: [ to toggle sidebar useEffect(() => { @@ -156,15 +158,19 @@ export function NavRail() { }`} >
- {group.items.map((item) => ( - setActiveTab(item.id)} - /> - ))} + {group.items.map((item) => { + const disabled = isLocal && item.requiresGateway + return ( + { if (!disabled) setActiveTab(item.id) }} + /> + ) + })}
@@ -175,13 +181,15 @@ export function NavRail() {
{sidebarExpanded && ( - {connection.isConnected ? 'Connected' : 'Disconnected'} + {isLocal ? 'Local Mode' : connection.isConnected ? 'Connected' : 'Disconnected'} )}
@@ -193,18 +201,23 @@ export function NavRail() { ) } -function NavButton({ item, active, expanded, onClick }: { +function NavButton({ item, active, expanded, disabled, onClick }: { item: NavItem active: boolean expanded: boolean + disabled?: boolean onClick: () => void }) { + const disabledClass = disabled ? 'opacity-40 pointer-events-none' : '' + const tooltipLabel = disabled ? `${item.label} (Requires gateway)` : item.label + if (expanded) { return (
{/* Tooltip */} - {item.label} + {tooltipLabel} {/* Active indicator */} {active && ( diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index 5722b81..7eeba6e 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -13,14 +13,16 @@ interface MemoryFile { } export function MemoryBrowserPanel() { - const { - memoryFiles, - selectedMemoryFile, + const { + memoryFiles, + selectedMemoryFile, memoryContent, - setMemoryFiles, - setSelectedMemoryFile, - setMemoryContent + dashboardMode, + setMemoryFiles, + setSelectedMemoryFile, + setMemoryContent } = useMissionControl() + const isLocal = dashboardMode === 'local' const [isLoading, setIsLoading] = useState(false) const [expandedFolders, setExpandedFolders] = useState>(new Set()) @@ -347,7 +349,9 @@ export function MemoryBrowserPanel() {

Memory Browser

- Explore ClawdBot's knowledge files and memory structure + {isLocal + ? 'Browse and manage local knowledge files and memory' + : 'Explore knowledge files and memory structure'}

{/* Tab Navigation */} diff --git a/src/lib/claude-sessions.ts b/src/lib/claude-sessions.ts index b8f3d50..d89c237 100644 --- a/src/lib/claude-sessions.ts +++ b/src/lib/claude-sessions.ts @@ -85,6 +85,8 @@ function parseSessionFile(filePath: string, projectSlug: string): SessionStats | let toolUses = 0 let inputTokens = 0 let outputTokens = 0 + let cacheReadTokens = 0 + let cacheCreationTokens = 0 let firstMessageAt: string | null = null let lastMessageAt: string | null = null let lastUserPrompt: string | null = null @@ -142,8 +144,8 @@ function parseSessionFile(filePath: string, projectSlug: string): SessionStats | const usage = entry.message.usage if (usage) { inputTokens += (usage.input_tokens || 0) - + (usage.cache_read_input_tokens || 0) - + (usage.cache_creation_input_tokens || 0) + cacheReadTokens += (usage.cache_read_input_tokens || 0) + cacheCreationTokens += (usage.cache_creation_input_tokens || 0) outputTokens += (usage.output_tokens || 0) } @@ -158,15 +160,22 @@ function parseSessionFile(filePath: string, projectSlug: string): SessionStats | if (!sessionId) return null - // Estimate cost + // Estimate cost (cache reads = 10% of input, cache creation = 125% of input) const pricing = (model && MODEL_PRICING[model]) || DEFAULT_PRICING - const estimatedCost = inputTokens * pricing.input + outputTokens * pricing.output + const estimatedCost = + inputTokens * pricing.input + + cacheReadTokens * pricing.input * 0.1 + + cacheCreationTokens * pricing.input * 1.25 + + outputTokens * pricing.output // Determine if active const isActive = lastMessageAt ? (Date.now() - new Date(lastMessageAt).getTime()) < ACTIVE_THRESHOLD_MS : false + // Store total input tokens (including cache) for display + const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens + return { sessionId, projectSlug, @@ -176,7 +185,7 @@ function parseSessionFile(filePath: string, projectSlug: string): SessionStats | userMessages, assistantMessages, toolUses, - inputTokens, + inputTokens: totalInputTokens, outputTokens, estimatedCost: Math.round(estimatedCost * 10000) / 10000, firstMessageAt, diff --git a/src/lib/config.ts b/src/lib/config.ts index 2da7969..b6a4ca2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -31,7 +31,8 @@ export const config = { tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '', memoryDir: process.env.OPENCLAW_MEMORY_DIR || - (openclawHome ? path.join(openclawHome, 'memory') : ''), + (openclawHome ? path.join(openclawHome, 'memory') : '') || + path.join(defaultDataDir, 'memory'), soulTemplatesDir: process.env.OPENCLAW_SOUL_TEMPLATES_DIR || (openclawHome ? path.join(openclawHome, 'templates', 'souls') : ''), diff --git a/src/store/index.ts b/src/store/index.ts index 68c6dce..a0e697c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -251,6 +251,16 @@ export interface ConnectionStatus { } interface MissionControlStore { + // Dashboard Mode (local vs full gateway) + dashboardMode: 'full' | 'local' + gatewayAvailable: boolean + bannerDismissed: boolean + subscription: { type: string; rateLimitTier?: string } | null + setDashboardMode: (mode: 'full' | 'local') => void + setGatewayAvailable: (available: boolean) => void + dismissBanner: () => void + setSubscription: (sub: { type: string; rateLimitTier?: string } | null) => void + // WebSocket & Connection connection: ConnectionStatus lastMessage: any @@ -388,6 +398,16 @@ interface MissionControlStore { export const useMissionControl = create()( subscribeWithSelector((set, get) => ({ + // Dashboard Mode + dashboardMode: 'full' as const, + gatewayAvailable: true, + bannerDismissed: false, + subscription: null, + setDashboardMode: (mode) => set({ dashboardMode: mode }), + setGatewayAvailable: (available) => set({ gatewayAvailable: available }), + dismissBanner: () => set({ bannerDismissed: true }), + setSubscription: (sub) => set({ subscription: sub }), + // Connection state connection: { isConnected: false,