feat: add local mode auto-detection for no-gateway users (#83)

When no OpenClaw gateway is detected, Mission Control now automatically
switches to Local Mode — showing a clear info banner, greying out
gateway-dependent panels, and surfacing Claude Code session stats,
GitHub profile data, and subscription-aware cost display.

Changes:
- Add capabilities endpoint to detect gateway, Claude home, subscription
- Add dashboardMode/gatewayAvailable/subscription state to Zustand store
- Add dismissible LocalModeBanner component
- Grey out Agents/Spawn/Config nav items when no gateway
- Show blue "Local Mode" indicator instead of red "Disconnected"
- Dashboard shows local metric cards (sessions, projects, tokens, cost)
- Claude Code Stats panel with session/token/cost breakdown
- GitHub panel with repo stats, languages, star/fork counts
- Subscription detection from ~/.claude/.credentials.json
- Show "Included (Max plan)" instead of dollar cost for subscribers
- Fix token cost estimation (cache reads at 10%, not 100%)
- Sessions API falls back to local Claude session scanner
- Live feed injects session items in local mode
- Memory browser auto-creates data dir with fallback path
This commit is contained in:
nyk 2026-03-03 13:41:55 +07:00 committed by GitHub
parent 8cb3a11baa
commit d826435401
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 786 additions and 204 deletions

View File

@ -7,6 +7,7 @@ import { logger } from '@/lib/logger'
import { validateBody, githubSyncSchema } from '@/lib/validation'
import {
getGitHubToken,
githubFetch,
fetchIssues,
fetchIssue,
createIssueComment,
@ -26,8 +27,12 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
if (action === 'stats') {
return await handleGitHubStats()
}
if (action !== 'issues') {
return NextResponse.json({ error: 'Unknown action. Use ?action=issues' }, { status: 400 })
return NextResponse.json({ error: 'Unknown action. Use ?action=issues or ?action=stats' }, { status: 400 })
}
const repo = searchParams.get('repo') || process.env.GITHUB_DEFAULT_REPO
@ -301,6 +306,87 @@ function handleStatus() {
return NextResponse.json({ syncs })
}
// ── Stats: GitHub user profile + repo overview ──────────────────
async function handleGitHubStats() {
const token = getGitHubToken()
if (!token) {
return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 })
}
// Fetch user profile
const userRes = await githubFetch('/user')
if (!userRes.ok) {
return NextResponse.json({ error: 'Failed to fetch GitHub user' }, { status: 500 })
}
const user = await userRes.json() as Record<string, any>
// Fetch repos (up to 100, sorted by recent push)
const reposRes = await githubFetch('/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator')
if (!reposRes.ok) {
return NextResponse.json({ error: 'Failed to fetch repos' }, { status: 500 })
}
const allRepos = await reposRes.json() as Array<Record<string, any>>
// Filter: exclude repos that are forks AND where user has never pushed
// A fork the user actively commits to will have pushed_at > created_at (by more than a few seconds)
const activeRepos = allRepos.filter(r => {
if (!r.fork) return true
// For forks, include only if pushed_at is meaningfully after created_at
// (GitHub sets pushed_at = parent's pushed_at on fork creation)
const created = new Date(r.created_at).getTime()
const pushed = new Date(r.pushed_at).getTime()
return (pushed - created) > 60_000 // pushed > 1min after fork creation
})
// Aggregate languages
const langCounts: Record<string, number> = {}
for (const r of activeRepos) {
if (r.language) {
langCounts[r.language] = (langCounts[r.language] || 0) + 1
}
}
const topLanguages = Object.entries(langCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name, count]) => ({ name, count }))
// Recent repos (last 10 with actual pushes)
const recentRepos = activeRepos.slice(0, 10).map(r => ({
name: r.full_name,
description: r.description,
language: r.language,
stars: r.stargazers_count,
forks: r.forks_count,
open_issues: r.open_issues_count,
pushed_at: r.pushed_at,
is_fork: r.fork,
is_private: r.private,
html_url: r.html_url,
}))
return NextResponse.json({
user: {
login: user.login,
name: user.name,
avatar_url: user.avatar_url,
public_repos: user.public_repos,
followers: user.followers,
following: user.following,
},
repos: {
total: activeRepos.length,
public: activeRepos.filter(r => !r.private).length,
private: activeRepos.filter(r => r.private).length,
total_stars: activeRepos.reduce((sum: number, r) => sum + (r.stargazers_count || 0), 0),
total_forks: activeRepos.reduce((sum: number, r) => sum + (r.forks_count || 0), 0),
total_open_issues: activeRepos.reduce((sum: number, r) => sum + (r.open_issues_count || 0), 0),
},
topLanguages,
recentRepos,
})
}
// ── Priority mapping helper ─────────────────────────────────────
function mapPriority(labels: string[]): 'critical' | 'high' | 'medium' | 'low' {

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { readdir, readFile, stat, lstat, realpath, writeFile, mkdir, unlink } from 'fs/promises'
import { existsSync, mkdirSync } from 'fs'
import { join, dirname, sep } from 'path'
import { config } from '@/lib/config'
import { resolveWithin } from '@/lib/paths'
@ -9,6 +10,11 @@ import { logger } from '@/lib/logger'
const MEMORY_PATH = config.memoryDir
// Ensure memory directory exists on startup
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
try { mkdirSync(MEMORY_PATH, { recursive: true }) } catch { /* ignore */ }
}
interface MemoryFile {
path: string
name: string

View File

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAllGatewaySessions } from '@/lib/sessions'
import { syncClaudeSessions } from '@/lib/claude-sessions'
import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth'
import { logger } from '@/lib/logger'
@ -10,33 +12,79 @@ export async function GET(request: NextRequest) {
try {
const gatewaySessions = getAllGatewaySessions()
const sessions = gatewaySessions.map((s) => {
const total = s.totalTokens || 0
const context = s.contextTokens || 35000
const pct = context > 0 ? Math.round((total / context) * 100) : 0
return {
id: s.sessionId || s.key,
key: s.key,
agent: s.agent,
kind: s.chatType || 'unknown',
age: formatAge(s.updatedAt),
model: s.model,
tokens: `${formatTokens(total)}/${formatTokens(context)} (${pct}%)`,
channel: s.channel,
flags: [],
active: s.active,
startTime: s.updatedAt,
lastActivity: s.updatedAt,
}
})
// If gateway sessions exist, return those
if (gatewaySessions.length > 0) {
const sessions = gatewaySessions.map((s) => {
const total = s.totalTokens || 0
const context = s.contextTokens || 35000
const pct = context > 0 ? Math.round((total / context) * 100) : 0
return {
id: s.sessionId || s.key,
key: s.key,
agent: s.agent,
kind: s.chatType || 'unknown',
age: formatAge(s.updatedAt),
model: s.model,
tokens: `${formatTokens(total)}/${formatTokens(context)} (${pct}%)`,
channel: s.channel,
flags: [],
active: s.active,
startTime: s.updatedAt,
lastActivity: s.updatedAt,
source: 'gateway' as const,
}
})
return NextResponse.json({ sessions })
}
return NextResponse.json({ sessions })
// Fallback: sync and read local Claude sessions from SQLite
await syncClaudeSessions()
const claudeSessions = getLocalClaudeSessions()
return NextResponse.json({ sessions: claudeSessions })
} catch (error) {
logger.error({ err: error }, 'Sessions API error')
return NextResponse.json({ sessions: [] })
}
}
/** Read Claude Code sessions from the local SQLite database */
function getLocalClaudeSessions() {
try {
const db = getDatabase()
const rows = db.prepare(
'SELECT * FROM claude_sessions ORDER BY last_message_at DESC LIMIT 50'
).all() as Array<Record<string, any>>
return rows.map((s) => {
const total = (s.input_tokens || 0) + (s.output_tokens || 0)
const lastMsg = s.last_message_at ? new Date(s.last_message_at).getTime() : 0
return {
id: s.session_id,
key: s.project_slug || s.session_id,
agent: s.project_slug || 'local',
kind: 'claude-code',
age: formatAge(lastMsg),
model: s.model || 'unknown',
tokens: `${formatTokens(s.input_tokens || 0)}/${formatTokens(s.output_tokens || 0)}`,
channel: 'local',
flags: s.git_branch ? [s.git_branch] : [],
active: s.is_active === 1,
startTime: s.first_message_at ? new Date(s.first_message_at).getTime() : 0,
lastActivity: lastMsg,
source: 'local' as const,
userMessages: s.user_messages || 0,
assistantMessages: s.assistant_messages || 0,
toolUses: s.tool_uses || 0,
estimatedCost: s.estimated_cost || 0,
lastUserPrompt: s.last_user_prompt || null,
}
})
} catch (err) {
logger.warn({ err }, 'Failed to read local Claude sessions')
return []
}
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`
if (n >= 1000) return `${Math.round(n / 1000)}k`

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import net from 'node:net'
import { statSync } from 'node:fs'
import { existsSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
import { config } from '@/lib/config'
import { getDatabase } from '@/lib/db'
@ -42,6 +43,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json(health)
}
if (action === 'capabilities') {
const capabilities = await getCapabilities()
return NextResponse.json(capabilities)
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error({ err: error }, 'Status API error')
@ -460,6 +466,46 @@ async function performHealthCheck() {
return health
}
async function getCapabilities() {
const gateway = await isPortOpen(config.gatewayHost, config.gatewayPort)
const openclawHome = !!(config.openclawHome && existsSync(config.openclawHome))
const claudeProjectsPath = path.join(config.claudeHome, 'projects')
const claudeHome = existsSync(claudeProjectsPath)
let claudeSessions = 0
try {
const db = getDatabase()
const row = db.prepare(
"SELECT COUNT(*) as c FROM claude_sessions WHERE is_active = 1"
).get() as { c: number } | undefined
claudeSessions = row?.c ?? 0
} catch {
// claude_sessions table may not exist
}
// Detect Claude subscription type from credentials
let subscription: { type: string; rateLimitTier?: string } | null = null
try {
const credsPath = path.join(config.claudeHome, '.credentials.json')
if (existsSync(credsPath)) {
const creds = JSON.parse(readFileSync(credsPath, 'utf-8'))
const oauth = creds.claudeAiOauth
if (oauth?.subscriptionType) {
subscription = {
type: oauth.subscriptionType,
rateLimitTier: oauth.rateLimitTier || undefined,
}
}
}
} catch {
// credentials file may not exist or be unreadable
}
return { gateway, openclawHome, claudeHome, claudeSessions, subscription }
}
function isPortOpen(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket()

View File

@ -32,13 +32,14 @@ import { SuperAdminPanel } from '@/components/panels/super-admin-panel'
import { GitHubSyncPanel } from '@/components/panels/github-sync-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { LocalModeBanner } from '@/components/layout/local-mode-banner'
import { useWebSocket } from '@/lib/websocket'
import { useServerEvents } from '@/lib/use-server-events'
import { useMissionControl } from '@/store'
export default function Home() {
const { connect } = useWebSocket()
const { activeTab, setCurrentUser, liveFeedOpen, toggleLiveFeed } = useMissionControl()
const { activeTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, liveFeedOpen, toggleLiveFeed } = useMissionControl()
// Connect to SSE for real-time local DB events (tasks, agents, chat, etc.)
useServerEvents()
@ -53,17 +54,47 @@ export default function Home() {
.then(data => { if (data?.user) setCurrentUser(data.user) })
.catch(() => {})
// Auto-connect to gateway on mount
const wsToken = process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || ''
const explicitWsUrl = process.env.NEXT_PUBLIC_GATEWAY_URL || ''
const gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
const gatewayHost = process.env.NEXT_PUBLIC_GATEWAY_HOST || window.location.hostname
const gatewayProto =
process.env.NEXT_PUBLIC_GATEWAY_PROTOCOL ||
(window.location.protocol === 'https:' ? 'wss' : 'ws')
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken)
}, [connect, setCurrentUser])
// Check capabilities, then conditionally connect to gateway
fetch('/api/status?action=capabilities')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.subscription) {
setSubscription(data.subscription)
}
if (data && data.gateway === false) {
setDashboardMode('local')
setGatewayAvailable(false)
// Skip WebSocket connect — no gateway to talk to
return
}
if (data && data.gateway === true) {
setDashboardMode('full')
setGatewayAvailable(true)
}
// Connect to gateway WebSocket
const wsToken = process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || ''
const explicitWsUrl = process.env.NEXT_PUBLIC_GATEWAY_URL || ''
const gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
const gatewayHost = process.env.NEXT_PUBLIC_GATEWAY_HOST || window.location.hostname
const gatewayProto =
process.env.NEXT_PUBLIC_GATEWAY_PROTOCOL ||
(window.location.protocol === 'https:' ? 'wss' : 'ws')
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken)
})
.catch(() => {
// If capabilities check fails, still try to connect
const wsToken = process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || ''
const explicitWsUrl = process.env.NEXT_PUBLIC_GATEWAY_URL || ''
const gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
const gatewayHost = process.env.NEXT_PUBLIC_GATEWAY_HOST || window.location.hostname
const gatewayProto =
process.env.NEXT_PUBLIC_GATEWAY_PROTOCOL ||
(window.location.protocol === 'https:' ? 'wss' : 'ws')
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken)
})
}, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription])
if (!isClient) {
return (
@ -89,6 +120,7 @@ export default function Home() {
{/* Center: Header + Content */}
<div className="flex-1 flex flex-col min-w-0">
<HeaderBar />
<LocalModeBanner />
<main className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
<div aria-live="polite">
<ErrorBoundary key={activeTab}>
@ -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 (
<>
<Dashboard />
<div className="mt-4 mx-4 mb-4 rounded-xl border border-border bg-card overflow-hidden">
<AgentCommsPanel />
</div>
{!isLocal && (
<div className="mt-4 mx-4 mb-4 rounded-xl border border-border bg-card overflow-hidden">
<AgentCommsPanel />
</div>
)}
</>
)
case 'tasks':
@ -142,9 +179,11 @@ function ContentRouter({ tab }: { tab: string }) {
<>
<OrchestrationBar />
<AgentSquadPanelPhase3 />
<div className="mt-4 mx-4 mb-4 rounded-xl border border-border bg-card overflow-hidden">
<AgentCommsPanel />
</div>
{!isLocal && (
<div className="mt-4 mx-4 mb-4 rounded-xl border border-border bg-card overflow-hidden">
<AgentCommsPanel />
</div>
)}
</>
)
case 'activity':

View File

@ -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<any>(null)
const [dbStats, setDbStats] = useState<DbStats | null>(null)
const [claudeStats, setClaudeStats] = useState<ClaudeStats | null>(null)
const [githubStats, setGithubStats] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const loadDashboard = useCallback(async () => {
try {
const [dashRes, sessRes] = await Promise.all([
const fetches: Promise<Response>[] = [
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() {
<div className="p-5 space-y-5">
{/* Top Metric Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="cursor-pointer" onClick={() => setActiveTab('history')}>
<MetricCard
label="Active Sessions"
value={activeSessions}
total={sessions.length}
icon={<SessionIcon />}
color="blue"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('agents')}>
<MetricCard
label="Agents Online"
value={onlineAgents}
total={dbStats?.agents.total ?? agents.length}
icon={<AgentIcon />}
color="green"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tasks')}>
<MetricCard
label="Tasks Running"
value={runningTasks}
total={dbStats?.tasks.total ?? tasks.length}
icon={<TaskIcon />}
color="purple"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('logs')}>
<MetricCard
label="Errors (24h)"
value={errorCount}
icon={<ErrorIcon />}
color={errorCount > 0 ? 'red' : 'green'}
/>
</div>
{isLocal ? (
<>
<div className="cursor-pointer" onClick={() => setActiveTab('sessions')}>
<MetricCard
label="Active Sessions"
value={claudeStats?.active_sessions ?? activeSessions}
total={claudeStats?.total_sessions ?? sessions.length}
icon={<SessionIcon />}
color="blue"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('sessions')}>
<MetricCard
label="Projects"
value={claudeStats?.unique_projects ?? 0}
icon={<ProjectIcon />}
color="green"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tokens')}>
<MetricCard
label="Tokens Used"
value={formatTokensShort((claudeStats?.total_input_tokens ?? 0) + (claudeStats?.total_output_tokens ?? 0))}
subtitle={claudeStats ? `${formatTokensShort(claudeStats.total_input_tokens)} in / ${formatTokensShort(claudeStats.total_output_tokens)} out` : undefined}
icon={<TokenIcon />}
color="purple"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tokens')}>
<MetricCard
label="Est. Cost"
value={subscriptionLabel ? `Included` : `$${(claudeStats?.total_estimated_cost ?? 0).toFixed(2)}`}
subtitle={subscriptionLabel ? `${subscriptionLabel} plan` : undefined}
icon={<CostIcon />}
color={subscriptionLabel ? 'green' : (claudeStats && claudeStats.total_estimated_cost > 10 ? 'red' : 'green')}
/>
</div>
</>
) : (
<>
<div className="cursor-pointer" onClick={() => setActiveTab('history')}>
<MetricCard
label="Active Sessions"
value={activeSessions}
total={sessions.length}
icon={<SessionIcon />}
color="blue"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('agents')}>
<MetricCard
label="Agents Online"
value={onlineAgents}
total={dbStats?.agents.total ?? agents.length}
icon={<AgentIcon />}
color="green"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tasks')}>
<MetricCard
label="Tasks Running"
value={runningTasks}
total={dbStats?.tasks.total ?? tasks.length}
icon={<TaskIcon />}
color="purple"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('logs')}>
<MetricCard
label="Errors (24h)"
value={errorCount}
icon={<ErrorIcon />}
color={errorCount > 0 ? 'red' : 'green'}
/>
</div>
</>
)}
</div>
{/* Three-column layout */}
@ -132,14 +207,25 @@ export function Dashboard() {
<div className="panel">
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">System Health</h3>
<StatusBadge connected={connection.isConnected} />
{isLocal ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-2xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
Local
</span>
) : (
<StatusBadge connected={connection.isConnected} />
)}
</div>
<div className="panel-body space-y-3">
<HealthRow
label="Gateway"
value={connection.isConnected ? 'Connected' : 'Disconnected'}
status={connection.isConnected ? 'good' : 'bad'}
/>
{isLocal ? (
<HealthRow label="Mode" value="Local" status="good" />
) : (
<HealthRow
label="Gateway"
value={connection.isConnected ? 'Connected' : 'Disconnected'}
status={connection.isConnected ? 'good' : 'bad'}
/>
)}
{memPct != null && (
<HealthRow
label="Memory"
@ -173,95 +259,187 @@ export function Dashboard() {
</div>
</div>
{/* Security & Audit */}
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => setActiveTab('audit')}>
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Security & Audit</h3>
{dbStats && dbStats.audit.loginFailures > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-2xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
{dbStats.audit.loginFailures} failed login{dbStats.audit.loginFailures > 1 ? 's' : ''}
{/* Middle panel: Claude Stats (local) or Security & Audit (full) */}
{isLocal ? (
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => setActiveTab('sessions')}>
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Claude Code Stats</h3>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-2xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
{claudeStats?.total_sessions ?? 0} sessions
</span>
)}
</div>
<div className="panel-body space-y-3">
<StatRow label="Audit events (24h)" value={dbStats?.audit.day ?? 0} />
<StatRow label="Audit events (7d)" value={dbStats?.audit.week ?? 0} />
<StatRow
label="Login failures (24h)"
value={dbStats?.audit.loginFailures ?? 0}
alert={dbStats ? dbStats.audit.loginFailures > 0 : false}
/>
<StatRow label="Activities (24h)" value={dbStats?.activities.day ?? 0} />
<StatRow label="Webhooks configured" value={dbStats?.webhookCount ?? 0} />
<div className="pt-1 border-t border-border/50">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Unread notifications</span>
<span className={`text-xs font-medium font-mono-tight ${
(dbStats?.notifications.unread ?? 0) > 0 ? 'text-amber-400' : 'text-muted-foreground'
}`}>
{dbStats?.notifications.unread ?? 0}
</span>
</div>
</div>
</div>
</div>
{/* Backup & Data */}
<div className="panel">
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Backup & Pipelines</h3>
</div>
<div className="panel-body space-y-3">
{dbStats?.backup ? (
<>
<div className="panel-body space-y-3">
<StatRow label="Total sessions" value={claudeStats?.total_sessions ?? 0} />
<StatRow label="Active now" value={claudeStats?.active_sessions ?? 0} />
<StatRow label="Unique projects" value={claudeStats?.unique_projects ?? 0} />
<div className="pt-1 border-t border-border/50 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Latest backup</span>
<span className={`text-xs font-medium font-mono-tight ${
dbStats.backup.age_hours > 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`}
<span className="text-xs text-muted-foreground">Input tokens</span>
<span className="text-xs font-medium font-mono-tight text-muted-foreground">
{formatTokensShort(claudeStats?.total_input_tokens ?? 0)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Backup size</span>
<span className="text-xs font-mono-tight text-muted-foreground">
{formatBytes(dbStats.backup.size)}
<span className="text-xs text-muted-foreground">Output tokens</span>
<span className="text-xs font-medium font-mono-tight text-muted-foreground">
{formatTokensShort(claudeStats?.total_output_tokens ?? 0)}
</span>
</div>
</>
) : (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Latest backup</span>
<span className="text-xs font-medium text-amber-400">None</span>
</div>
)}
<div className="pt-1 border-t border-border/50 space-y-2">
<StatRow label="Active pipelines" value={dbStats?.pipelines.active ?? 0} />
<StatRow label="Pipeline runs (24h)" value={dbStats?.pipelines.recentDay ?? 0} />
</div>
<div className="pt-1 border-t border-border/50 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Tasks by status</span>
</div>
{dbStats?.tasks.total ? (
<div className="flex flex-wrap gap-1.5">
{Object.entries(dbStats.tasks.byStatus).map(([status, count]) => (
<span
key={status}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-mono-tight bg-secondary text-muted-foreground"
>
<span className={`w-1.5 h-1.5 rounded-full ${taskStatusColor(status)}`} />
{status}: {count}
<div className="pt-1 border-t border-border/50">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Estimated cost</span>
{subscriptionLabel ? (
<span className="text-xs font-medium font-mono-tight text-green-400">
Included ({subscriptionLabel})
</span>
))}
) : (
<span className={`text-xs font-medium font-mono-tight ${
(claudeStats?.total_estimated_cost ?? 0) > 10 ? 'text-amber-400' : 'text-green-400'
}`}>
${(claudeStats?.total_estimated_cost ?? 0).toFixed(2)}
</span>
)}
</div>
</div>
</div>
</div>
) : (
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => setActiveTab('audit')}>
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Security & Audit</h3>
{dbStats && dbStats.audit.loginFailures > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-2xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
{dbStats.audit.loginFailures} failed login{dbStats.audit.loginFailures > 1 ? 's' : ''}
</span>
)}
</div>
<div className="panel-body space-y-3">
<StatRow label="Audit events (24h)" value={dbStats?.audit.day ?? 0} />
<StatRow label="Audit events (7d)" value={dbStats?.audit.week ?? 0} />
<StatRow
label="Login failures (24h)"
value={dbStats?.audit.loginFailures ?? 0}
alert={dbStats ? dbStats.audit.loginFailures > 0 : false}
/>
<StatRow label="Activities (24h)" value={dbStats?.activities.day ?? 0} />
<StatRow label="Webhooks configured" value={dbStats?.webhookCount ?? 0} />
<div className="pt-1 border-t border-border/50">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Unread notifications</span>
<span className={`text-xs font-medium font-mono-tight ${
(dbStats?.notifications.unread ?? 0) > 0 ? 'text-amber-400' : 'text-muted-foreground'
}`}>
{dbStats?.notifications.unread ?? 0}
</span>
</div>
</div>
</div>
</div>
)}
{/* Third column: GitHub (local) or Backup & Pipelines (full) */}
{isLocal ? (
<div className="panel">
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">GitHub</h3>
{githubStats?.user && (
<span className="text-2xs text-muted-foreground font-mono-tight">@{githubStats.user.login}</span>
)}
</div>
<div className="panel-body space-y-3">
{githubStats ? (
<>
<StatRow label="Active repos" value={githubStats.repos.total} />
<StatRow label="Public" value={githubStats.repos.public} />
<StatRow label="Private" value={githubStats.repos.private} />
<div className="pt-1 border-t border-border/50 space-y-2">
<StatRow label="Total stars" value={githubStats.repos.total_stars} />
<StatRow label="Total forks" value={githubStats.repos.total_forks} />
<StatRow label="Open issues" value={githubStats.repos.total_open_issues} />
</div>
{githubStats.topLanguages.length > 0 && (
<div className="pt-1 border-t border-border/50">
<div className="text-xs text-muted-foreground mb-1.5">Top languages</div>
<div className="flex flex-wrap gap-1.5">
{githubStats.topLanguages.map((lang: { name: string; count: number }) => (
<span
key={lang.name}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-mono-tight bg-secondary text-muted-foreground"
>
<span className={`w-1.5 h-1.5 rounded-full ${langColor(lang.name)}`} />
{lang.name}
</span>
))}
</div>
</div>
)}
</>
) : (
<span className="text-2xs text-muted-foreground">No tasks</span>
<div className="text-center py-4">
<p className="text-xs text-muted-foreground">No GitHub token configured</p>
<p className="text-2xs text-muted-foreground/60 mt-1">Set GITHUB_TOKEN in .env.local</p>
</div>
)}
</div>
</div>
</div>
) : (
<div className="panel">
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Backup & Pipelines</h3>
</div>
<div className="panel-body space-y-3">
{dbStats?.backup ? (
<>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Latest backup</span>
<span className={`text-xs font-medium font-mono-tight ${
dbStats.backup.age_hours > 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`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Backup size</span>
<span className="text-xs font-mono-tight text-muted-foreground">
{formatBytes(dbStats.backup.size)}
</span>
</div>
</>
) : (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Latest backup</span>
<span className="text-xs font-medium text-amber-400">None</span>
</div>
)}
<div className="pt-1 border-t border-border/50 space-y-2">
<StatRow label="Active pipelines" value={dbStats?.pipelines.active ?? 0} />
<StatRow label="Pipeline runs (24h)" value={dbStats?.pipelines.recentDay ?? 0} />
</div>
<div className="pt-1 border-t border-border/50 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Tasks by status</span>
</div>
{dbStats?.tasks.total ? (
<div className="flex flex-wrap gap-1.5">
{Object.entries(dbStats.tasks.byStatus).map(([status, count]) => (
<span
key={status}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-mono-tight bg-secondary text-muted-foreground"
>
<span className={`w-1.5 h-1.5 rounded-full ${taskStatusColor(status)}`} />
{status}: {count}
</span>
))}
</div>
) : (
<span className="text-2xs text-muted-foreground">No tasks</span>
)}
</div>
</div>
</div>
)}
</div>
{/* Bottom two-column: Sessions + Logs */}
@ -274,7 +452,7 @@ export function Dashboard() {
</div>
<div className="divide-y divide-border/50 max-h-56 overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-4 py-8 text-center"><p className="text-xs text-muted-foreground">No active sessions</p><p className="text-2xs text-muted-foreground/60 mt-1">Sessions appear when agents connect via gateway</p></div>
<div className="px-4 py-8 text-center"><p className="text-xs text-muted-foreground">No active sessions</p><p className="text-2xs text-muted-foreground/60 mt-1">{isLocal ? 'Sessions appear when Claude Code or Codex CLI are running' : 'Sessions appear when agents connect via gateway'}</p></div>
) : (
sessions.slice(0, 8).map((session) => (
<div key={session.id} className="px-4 py-2.5 flex items-center gap-3 hover:bg-secondary/30 transition-smooth">
@ -334,11 +512,17 @@ export function Dashboard() {
{/* Quick Actions */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2">
<QuickAction label="Spawn Agent" desc="Launch sub-agent" tab="spawn" icon={<SpawnActionIcon />} setActiveTab={setActiveTab} />
{!isLocal && (
<QuickAction label="Spawn Agent" desc="Launch sub-agent" tab="spawn" icon={<SpawnActionIcon />} setActiveTab={setActiveTab} />
)}
<QuickAction label="View Logs" desc="Real-time viewer" tab="logs" icon={<LogActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Task Board" desc="Kanban view" tab="tasks" icon={<TaskActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Memory" desc="Knowledge base" tab="memory" icon={<MemoryActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} setActiveTab={setActiveTab} />
{isLocal ? (
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} setActiveTab={setActiveTab} />
) : (
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} setActiveTab={setActiveTab} />
)}
</div>
</div>
)
@ -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 }: {
<span className="text-xs opacity-50 font-mono-tight">/ {total}</span>
)}
</div>
{subtitle && (
<div className="text-2xs opacity-50 font-mono-tight mt-0.5">{subtitle}</div>
)}
</div>
)
}
@ -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<string, string> = {
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() {
</svg>
)
}
function ProjectIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M2 4l6-2 6 2v8l-6 2-6-2V4z" />
<path d="M8 6v8M2 4l6 2 6-2" />
</svg>
)
}
function TokenIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="8" cy="8" r="6" />
<path d="M8 4v8M5 6h6M5 10h6" />
</svg>
)
}
function CostIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="8" cy="8" r="6" />
<path d="M8 3.5V5M8 11v1.5M10.5 6.5C10.5 5.4 9.4 4.5 8 4.5S5.5 5.4 5.5 6.5c0 1.1 1.1 2 2.5 2s2.5.9 2.5 2c0 1.1-1.1 2-2.5 2s-2.5-.9-2.5-2" />
</svg>
)
}

View File

@ -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 (
<button
onClick={!connection.isConnected ? onReconnect : undefined}
onClick={!isLocal && !connection.isConnected ? onReconnect : undefined}
className={`md:hidden flex items-center justify-center h-8 w-8 rounded-md ${
connection.isConnected ? 'cursor-default' : 'hover:bg-secondary cursor-pointer'
isLocal || connection.isConnected ? 'cursor-default' : 'hover:bg-secondary cursor-pointer'
} transition-smooth`}
title={title}
>
@ -301,8 +306,20 @@ function ConnectionBadge({
connection: { isConnected: boolean; reconnectAttempts: number; latency?: number }
onReconnect: () => void
}) {
const { dashboardMode } = useMissionControl()
const isLocal = dashboardMode === 'local'
const isReconnecting = !connection.isConnected && connection.reconnectAttempts > 0
if (isLocal) {
return (
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md cursor-default">
<span className="text-muted-foreground">Gateway</span>
<span className="w-1.5 h-1.5 rounded-full bg-blue-500" />
<span className="font-medium font-mono-tight text-blue-400">Local</span>
</div>
)
}
let dotClass: string
let label: string

View File

@ -4,10 +4,22 @@ import { useMissionControl } from '@/store'
import { useEffect, useState } from 'react'
export function LiveFeed() {
const { logs, sessions, activities, connection, toggleLiveFeed } = useMissionControl()
const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
const isLocal = dashboardMode === 'local'
const [expanded, setExpanded] = useState(true)
// Combine logs and activities into a unified feed
// Combine logs, activities, and (in local mode) session events into a unified feed
const sessionItems = isLocal
? sessions.slice(0, 10).map(s => ({
id: `sess-${s.id}`,
type: 'session' as const,
level: 'info' as const,
message: `${s.active ? 'Active' : 'Idle'} session: ${s.key || s.id}`,
source: s.model?.split('/').pop()?.split('-').slice(0, 2).join('-') || 'claude',
timestamp: s.lastActivity || s.startTime || Date.now(),
}))
: []
const feedItems = [
...logs.slice(0, 30).map(log => ({
id: `log-${log.id}`,
@ -25,6 +37,7 @@ export function LiveFeed() {
source: act.actor,
timestamp: act.created_at * 1000,
})),
...sessionItems,
].sort((a, b) => b.timestamp - a.timestamp).slice(0, 40)
if (!expanded) {
@ -90,8 +103,13 @@ export function LiveFeed() {
{/* Feed items */}
<div className="flex-1 overflow-y-auto">
{feedItems.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
No activity yet
<div className="px-3 py-8 text-center">
<p className="text-xs text-muted-foreground">No activity yet</p>
<p className="text-2xs text-muted-foreground/60 mt-1">
{isLocal
? 'Events appear when you create tasks or agents update'
: 'Events stream here from the gateway and local DB'}
</p>
</div>
) : (
<div className="divide-y divide-border/50">

View File

@ -0,0 +1,34 @@
'use client'
import { useMissionControl } from '@/store'
export function LocalModeBanner() {
const { dashboardMode, bannerDismissed, dismissBanner, setActiveTab } = useMissionControl()
if (dashboardMode === 'full' || bannerDismissed) return null
return (
<div className="mx-4 mt-3 mb-0 flex items-center gap-3 px-4 py-2.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-sm">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />
<p className="flex-1 text-xs text-blue-300">
<span className="font-medium text-blue-200">No OpenClaw gateway detected</span>
{' — running in Local Mode. Monitoring Claude Code sessions, tasks, and local data.'}
</p>
<button
onClick={() => setActiveTab('gateways')}
className="shrink-0 text-2xs font-medium text-blue-400 hover:text-blue-300 px-2 py-1 rounded border border-blue-500/20 hover:border-blue-500/40 transition-colors"
>
Configure Gateway
</button>
<button
onClick={dismissBanner}
className="shrink-0 text-blue-400/60 hover:text-blue-300 transition-colors"
title="Dismiss"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
)
}

View File

@ -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: <OverviewIcon />, priority: true },
{ id: 'agents', label: 'Agents', icon: <AgentsIcon />, priority: true },
{ id: 'agents', label: 'Agents', icon: <AgentsIcon />, priority: true, requiresGateway: true },
{ id: 'tasks', label: 'Tasks', icon: <TasksIcon />, priority: true },
{ id: 'sessions', label: 'Sessions', icon: <SessionsIcon />, priority: false },
],
@ -42,7 +43,7 @@ const navGroups: NavGroup[] = [
label: 'AUTOMATE',
items: [
{ id: 'cron', label: 'Cron', icon: <CronIcon />, priority: false },
{ id: 'spawn', label: 'Spawn', icon: <SpawnIcon />, priority: false },
{ id: 'spawn', label: 'Spawn', icon: <SpawnIcon />, priority: false, requiresGateway: true },
{ id: 'webhooks', label: 'Webhooks', icon: <WebhookIcon />, priority: false },
{ id: 'alerts', label: 'Alerts', icon: <AlertIcon />, priority: false },
{ id: 'github', label: 'GitHub', icon: <GitHubIcon />, priority: false },
@ -56,7 +57,7 @@ const navGroups: NavGroup[] = [
{ id: 'audit', label: 'Audit', icon: <AuditIcon />, priority: false },
{ id: 'history', label: 'History', icon: <HistoryIcon />, priority: false },
{ id: 'gateways', label: 'Gateways', icon: <GatewaysIcon />, priority: false },
{ id: 'gateway-config', label: 'Config', icon: <GatewayConfigIcon />, priority: false },
{ id: 'gateway-config', label: 'Config', icon: <GatewayConfigIcon />, priority: false, requiresGateway: true },
{ id: 'integrations', label: 'Integrations', icon: <IntegrationsIcon />, priority: false },
{ id: 'super-admin', label: 'Super Admin', icon: <SuperAdminIcon />, priority: false },
{ id: 'settings', label: 'Settings', icon: <SettingsIcon />, 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() {
}`}
>
<div className={`flex flex-col ${sidebarExpanded ? 'gap-0.5 px-2' : 'items-center gap-1'}`}>
{group.items.map((item) => (
<NavButton
key={item.id}
item={item}
active={activeTab === item.id}
expanded={sidebarExpanded}
onClick={() => setActiveTab(item.id)}
/>
))}
{group.items.map((item) => {
const disabled = isLocal && item.requiresGateway
return (
<NavButton
key={item.id}
item={item}
active={activeTab === item.id}
expanded={sidebarExpanded}
disabled={disabled}
onClick={() => { if (!disabled) setActiveTab(item.id) }}
/>
)
})}
</div>
</div>
</div>
@ -175,13 +181,15 @@ export function NavRail() {
<div className={`shrink-0 py-3 flex ${sidebarExpanded ? 'px-3 items-center gap-2' : 'flex-col items-center'}`}>
<div
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
connection.isConnected ? 'bg-green-500 pulse-dot' : 'bg-red-500'
isLocal
? 'bg-blue-500'
: connection.isConnected ? 'bg-green-500 pulse-dot' : 'bg-red-500'
}`}
title={connection.isConnected ? 'Gateway connected' : 'Gateway disconnected'}
title={isLocal ? 'Local Mode' : connection.isConnected ? 'Gateway connected' : 'Gateway disconnected'}
/>
{sidebarExpanded && (
<span className="text-xs text-muted-foreground truncate">
{connection.isConnected ? 'Connected' : 'Disconnected'}
{isLocal ? 'Local Mode' : connection.isConnected ? 'Connected' : 'Disconnected'}
</span>
)}
</div>
@ -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 (
<button
onClick={onClick}
aria-current={active ? 'page' : undefined}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left transition-smooth relative ${
aria-disabled={disabled || undefined}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left transition-smooth relative ${disabledClass} ${
active
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
@ -222,9 +235,10 @@ function NavButton({ item, active, expanded, onClick }: {
return (
<button
onClick={onClick}
title={item.label}
title={tooltipLabel}
aria-current={active ? 'page' : undefined}
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-smooth group relative ${
aria-disabled={disabled || undefined}
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-smooth group relative ${disabledClass} ${
active
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
@ -233,7 +247,7 @@ function NavButton({ item, active, expanded, onClick }: {
<div className="w-5 h-5">{item.icon}</div>
{/* Tooltip */}
<span className="absolute left-full ml-2 px-2 py-1 text-xs font-medium bg-popover text-popover-foreground border border-border rounded-md opacity-0 group-hover:opacity-100 pointer-events-none whitespace-nowrap z-50 transition-opacity">
{item.label}
{tooltipLabel}
</span>
{/* Active indicator */}
{active && (

View File

@ -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<Set<string>>(new Set())
@ -347,7 +349,9 @@ export function MemoryBrowserPanel() {
<div className="border-b border-border pb-4">
<h1 className="text-3xl font-bold text-foreground">Memory Browser</h1>
<p className="text-muted-foreground mt-2">
Explore ClawdBot&apos;s knowledge files and memory structure
{isLocal
? 'Browse and manage local knowledge files and memory'
: 'Explore knowledge files and memory structure'}
</p>
{/* Tab Navigation */}

View File

@ -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,

View File

@ -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') : ''),

View File

@ -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<MissionControlStore>()(
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,