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:
parent
8cb3a11baa
commit
d826435401
|
|
@ -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' {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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's knowledge files and memory structure
|
||||
{isLocal
|
||||
? 'Browse and manage local knowledge files and memory'
|
||||
: 'Explore knowledge files and memory structure'}
|
||||
</p>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') : ''),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue