feat: upgrade local-mode virtual office and flight deck integration
|
|
@ -0,0 +1,4 @@
|
|||
Source: OpenGameArt - "Hero character sprite sheet" by Fry
|
||||
URL: https://opengameart.org/content/hero-character-sprite-sheet
|
||||
License: CC0 1.0 Universal
|
||||
Notes: Used for Virtual Office worker character rendering.
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="40" viewBox="0 0 96 40" shape-rendering="crispEdges">
|
||||
<rect x="0" y="8" width="96" height="24" fill="#2f4464"/>
|
||||
<rect x="0" y="8" width="96" height="2" fill="#8bb2ea"/>
|
||||
<rect x="0" y="30" width="96" height="2" fill="#17263b"/>
|
||||
<rect x="6" y="12" width="18" height="14" fill="#64b9ff"/>
|
||||
<rect x="8" y="14" width="14" height="8" fill="#214a71"/>
|
||||
<rect x="42" y="12" width="18" height="14" fill="#64b9ff"/>
|
||||
<rect x="44" y="14" width="14" height="8" fill="#214a71"/>
|
||||
<rect x="78" y="12" width="12" height="14" fill="#64b9ff"/>
|
||||
<rect x="80" y="14" width="8" height="8" fill="#214a71"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 665 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" shape-rendering="crispEdges">
|
||||
<rect width="32" height="32" fill="#101a2d"/>
|
||||
<rect x="0" y="0" width="16" height="16" fill="#13213a"/>
|
||||
<rect x="16" y="16" width="16" height="16" fill="#13213a"/>
|
||||
<rect x="0" y="0" width="32" height="1" fill="#2a3f66" opacity="0.5"/>
|
||||
<rect x="0" y="0" width="1" height="32" fill="#2a3f66" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
|
||||
Furniture Pack (1.0) Exclusive
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
Support us by crediting (Kenney or www.kenney.nl), this is not mandatory.
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Request: http://request.kenney.nl
|
||||
Patreon: http://patreon.com/kenney/
|
||||
|
||||
Follow on Twitter for updates:
|
||||
@KenneyNL
|
||||
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 560 B |
|
After Width: | Height: | Size: 795 B |
|
After Width: | Height: | Size: 257 B |
|
After Width: | Height: | Size: 248 B |
|
After Width: | Height: | Size: 418 B |
|
After Width: | Height: | Size: 512 B |
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96" shape-rendering="crispEdges">
|
||||
<rect width="160" height="96" fill="#2a3d33"/>
|
||||
<rect x="8" y="8" width="144" height="80" fill="#3f5d4f"/>
|
||||
<rect x="14" y="14" width="132" height="68" fill="#304a3f"/>
|
||||
<rect x="20" y="20" width="120" height="56" fill="#243930"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="8" y="34" width="16" height="10" fill="#6e4f3b"/>
|
||||
<rect x="10" y="36" width="12" height="6" fill="#8a6548"/>
|
||||
<rect x="6" y="20" width="20" height="14" fill="#2f9e66"/>
|
||||
<rect x="4" y="24" width="10" height="10" fill="#3bc27d"/>
|
||||
<rect x="18" y="22" width="10" height="10" fill="#3bc27d"/>
|
||||
<rect x="14" y="12" width="4" height="10" fill="#3bc27d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#8b95ad"/>
|
||||
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
|
||||
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="8" y="14" width="1" height="12" fill="#1b1e2a"/>
|
||||
<rect x="23" y="14" width="1" height="12" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
|
||||
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
|
||||
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="8" y="14" width="1" height="12" fill="#1b1e2a"/>
|
||||
<rect x="23" y="14" width="1" height="12" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
|
||||
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
|
||||
<rect x="9" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="17" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="8" y="14" width="1" height="12" fill="#1b1e2a"/>
|
||||
<rect x="23" y="14" width="1" height="12" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
|
||||
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
|
||||
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="7" y="18" width="4" height="8" fill="#1b1e2a"/>
|
||||
<rect x="21" y="18" width="4" height="8" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
|
||||
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
|
||||
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="8" y="18" width="4" height="8" fill="#1b1e2a"/>
|
||||
<rect x="20" y="18" width="4" height="8" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
|
||||
<rect x="10" y="26" width="12" height="12" fill="#2f4f88"/>
|
||||
<rect x="7" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="19" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="7" y="16" width="2" height="10" fill="#1b1e2a"/>
|
||||
<rect x="23" y="15" width="2" height="10" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
|
||||
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
|
||||
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
|
||||
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
|
||||
<rect x="10" y="26" width="12" height="12" fill="#2f4f88"/>
|
||||
<rect x="9" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="17" y="38" width="6" height="8" fill="#1b2944"/>
|
||||
<rect x="7" y="15" width="2" height="10" fill="#1b1e2a"/>
|
||||
<rect x="23" y="16" width="2" height="10" fill="#1b1e2a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
|
|
@ -95,18 +95,6 @@ async function saveCronFile(data: OpenClawCronFile): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Deduplicate jobs by name — keep the latest (by createdAtMs) per unique name */
|
||||
function deduplicateJobs(jobs: OpenClawCronJob[]): OpenClawCronJob[] {
|
||||
const latest = new Map<string, OpenClawCronJob>()
|
||||
for (const job of jobs) {
|
||||
const existing = latest.get(job.name)
|
||||
if (!existing || (job.createdAtMs ?? 0) > (existing.createdAtMs ?? 0)) {
|
||||
latest.set(job.name, job)
|
||||
}
|
||||
}
|
||||
return [...latest.values()]
|
||||
}
|
||||
|
||||
function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined {
|
||||
if (!status) return undefined
|
||||
const s = status.toLowerCase()
|
||||
|
|
@ -157,7 +145,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ jobs: [] })
|
||||
}
|
||||
|
||||
const jobs = deduplicateJobs(cronFile.jobs).map(mapOpenClawJob)
|
||||
const jobs = cronFile.jobs.map(mapOpenClawJob)
|
||||
return NextResponse.json({ jobs })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { requireRole } from '@/lib/auth'
|
||||
import { runCommand } from '@/lib/command'
|
||||
|
||||
const DEFAULT_DOWNLOAD_URL = 'https://flightdeck.example.com/download'
|
||||
const DEFAULT_INSTALL_PATHS = [
|
||||
'/Applications/Flight Deck.app',
|
||||
'/Applications/Flight Desk.app',
|
||||
]
|
||||
|
||||
function getConfiguredFlightDeckPath(): string | null {
|
||||
const fromEnv = String(process.env.FLIGHT_DECK_PATH || '').trim()
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
function getFlightDeckBaseUrl(): string {
|
||||
const fromEnv = String(process.env.FLIGHT_DECK_URL || '').trim()
|
||||
if (fromEnv) return fromEnv
|
||||
return 'http://127.0.0.1:4177'
|
||||
}
|
||||
|
||||
function getFlightDeckLaunchUrl(): string {
|
||||
const fromEnv = String(process.env.FLIGHT_DECK_LAUNCH_URL || '').trim()
|
||||
if (fromEnv) return fromEnv
|
||||
return 'flightdeck://open'
|
||||
}
|
||||
|
||||
function isInstalled(targetPath: string): boolean {
|
||||
try {
|
||||
return existsSync(targetPath) && statSync(targetPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFlightDeckInstallPath(): string | null {
|
||||
const configured = getConfiguredFlightDeckPath()
|
||||
if (configured && isInstalled(configured)) return configured
|
||||
for (const candidate of DEFAULT_INSTALL_PATHS) {
|
||||
if (isInstalled(candidate)) return candidate
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/local/flight-deck
|
||||
* Check Flight Deck local installation status.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = requireRole(request, 'viewer')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
const installPath = resolveFlightDeckInstallPath()
|
||||
const installed = installPath ? isInstalled(installPath) : false
|
||||
|
||||
return NextResponse.json({
|
||||
installed,
|
||||
installPath: installPath || null,
|
||||
appUrl: getFlightDeckBaseUrl(),
|
||||
downloadUrl: DEFAULT_DOWNLOAD_URL,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/local/flight-deck
|
||||
* Build a Flight Deck URL for the selected agent/session.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = requireRole(request, 'operator')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
const installPath = resolveFlightDeckInstallPath()
|
||||
const installed = installPath ? isInstalled(installPath) : false
|
||||
if (!installed) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
error: 'Flight Deck is not installed locally.',
|
||||
installPath: installPath || null,
|
||||
downloadUrl: DEFAULT_DOWNLOAD_URL,
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const agent = typeof body?.agent === 'string' ? body.agent : ''
|
||||
const session = typeof body?.session === 'string' ? body.session : ''
|
||||
|
||||
const webUrl = new URL(getFlightDeckBaseUrl())
|
||||
webUrl.searchParams.set('source', 'mission-control')
|
||||
if (agent) webUrl.searchParams.set('agent', agent)
|
||||
if (session) webUrl.searchParams.set('session', session)
|
||||
|
||||
const launchUrl = new URL(getFlightDeckLaunchUrl())
|
||||
launchUrl.searchParams.set('source', 'mission-control')
|
||||
if (agent) launchUrl.searchParams.set('agent', agent)
|
||||
if (session) launchUrl.searchParams.set('session', session)
|
||||
|
||||
try {
|
||||
// Launch the native app directly; pass deep-link as payload.
|
||||
await runCommand('open', ['-a', installPath!, launchUrl.toString()], { timeoutMs: 10_000 })
|
||||
} catch (error: any) {
|
||||
try {
|
||||
// Fallback for apps registered as URL handlers.
|
||||
await runCommand('open', [launchUrl.toString()], { timeoutMs: 10_000 })
|
||||
} catch (fallbackError: any) {
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
launched: false,
|
||||
error: fallbackError?.message || error?.message || 'Failed to launch Flight Deck app.',
|
||||
fallbackUrl: webUrl.toString(),
|
||||
downloadUrl: DEFAULT_DOWNLOAD_URL,
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
launched: true,
|
||||
url: webUrl.toString(),
|
||||
launchUrl: launchUrl.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { requireRole } from '@/lib/auth'
|
||||
import { runCommand } from '@/lib/command'
|
||||
|
||||
function isAllowedDirectory(input: string): boolean {
|
||||
const cwd = resolve(input)
|
||||
if (!cwd.startsWith('/')) return false
|
||||
if (!(cwd.startsWith('/Users/') || cwd.startsWith('/tmp/') || cwd.startsWith('/var/folders/'))) {
|
||||
return false
|
||||
}
|
||||
if (!existsSync(cwd)) return false
|
||||
try {
|
||||
return statSync(cwd).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/local/terminal
|
||||
* Body: { cwd: string }
|
||||
* Opens a new local Terminal window at the given working directory.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = requireRole(request, 'operator')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const cwd = typeof body?.cwd === 'string' ? body.cwd.trim() : ''
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: 'cwd is required' }, { status: 400 })
|
||||
}
|
||||
if (!isAllowedDirectory(cwd)) {
|
||||
return NextResponse.json({ error: 'cwd must be an existing safe local directory' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await runCommand('open', ['-a', 'Terminal', cwd], { timeoutMs: 10_000 })
|
||||
return NextResponse.json({ ok: true, message: `Opened Terminal at ${cwd}` })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error?.message || 'Failed to open Terminal' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
|
@ -21,10 +21,13 @@ export async function POST(request: NextRequest) {
|
|||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const taskId = body.task_id
|
||||
const taskId = typeof body?.task_id === 'string' ? body.task_id : ''
|
||||
const allowedTaskIds = new Set(getSchedulerStatus().map((task) => task.id))
|
||||
|
||||
if (!taskId || !['auto_backup', 'auto_cleanup', 'agent_heartbeat'].includes(taskId)) {
|
||||
return NextResponse.json({ error: 'task_id required: auto_backup, auto_cleanup, or agent_heartbeat' }, { status: 400 })
|
||||
if (!taskId || !allowedTaskIds.has(taskId)) {
|
||||
return NextResponse.json({
|
||||
error: `task_id required: ${Array.from(allowedTaskIds).join(', ')}`,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await triggerTask(taskId)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAllGatewaySessions } from '@/lib/sessions'
|
||||
import { syncClaudeSessions } from '@/lib/claude-sessions'
|
||||
import { scanCodexSessions } from '@/lib/codex-sessions'
|
||||
import { getDatabase } from '@/lib/db'
|
||||
import { requireRole } from '@/lib/auth'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
|
@ -49,10 +50,12 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ sessions })
|
||||
}
|
||||
|
||||
// Fallback: sync and read local Claude sessions from SQLite
|
||||
// Fallback: sync and read local Claude + Codex sessions from disk/SQLite
|
||||
await syncClaudeSessions()
|
||||
const claudeSessions = getLocalClaudeSessions()
|
||||
return NextResponse.json({ sessions: claudeSessions })
|
||||
const codexSessions = getLocalCodexSessions()
|
||||
const merged = mergeLocalSessions(claudeSessions, codexSessions)
|
||||
return NextResponse.json({ sessions: merged })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Sessions API error')
|
||||
return NextResponse.json({ sessions: [] })
|
||||
|
|
@ -89,6 +92,7 @@ function getLocalClaudeSessions() {
|
|||
toolUses: s.tool_uses || 0,
|
||||
estimatedCost: s.estimated_cost || 0,
|
||||
lastUserPrompt: s.last_user_prompt || null,
|
||||
workingDir: s.project_path || null,
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
@ -97,6 +101,64 @@ function getLocalClaudeSessions() {
|
|||
}
|
||||
}
|
||||
|
||||
function getLocalCodexSessions() {
|
||||
try {
|
||||
const rows = scanCodexSessions(100)
|
||||
|
||||
return rows.map((s) => {
|
||||
const total = s.totalTokens || (s.inputTokens + s.outputTokens)
|
||||
const lastMsg = s.lastMessageAt ? new Date(s.lastMessageAt).getTime() : 0
|
||||
const firstMsg = s.firstMessageAt ? new Date(s.firstMessageAt).getTime() : 0
|
||||
return {
|
||||
id: s.sessionId,
|
||||
key: s.projectSlug || s.sessionId,
|
||||
agent: s.projectSlug || 'codex-local',
|
||||
kind: 'codex-cli',
|
||||
age: formatAge(lastMsg),
|
||||
model: s.model || 'codex',
|
||||
tokens: `${formatTokens(s.inputTokens || 0)}/${formatTokens(s.outputTokens || 0)}`,
|
||||
channel: 'local',
|
||||
flags: [],
|
||||
active: s.isActive,
|
||||
startTime: firstMsg,
|
||||
lastActivity: lastMsg,
|
||||
source: 'local' as const,
|
||||
userMessages: s.userMessages || 0,
|
||||
assistantMessages: s.assistantMessages || 0,
|
||||
toolUses: 0,
|
||||
estimatedCost: 0,
|
||||
lastUserPrompt: null,
|
||||
totalTokens: total,
|
||||
workingDir: s.projectPath || null,
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to read local Codex sessions')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLocalSessions(
|
||||
claudeSessions: Array<Record<string, any>>,
|
||||
codexSessions: Array<Record<string, any>>,
|
||||
) {
|
||||
const merged = [...claudeSessions, ...codexSessions]
|
||||
const deduped = new Map<string, Record<string, any>>()
|
||||
|
||||
for (const session of merged) {
|
||||
const id = String(session?.id || '')
|
||||
if (!id) continue
|
||||
const existing = deduped.get(id)
|
||||
const currentActivity = Number(session?.lastActivity || 0)
|
||||
const existingActivity = Number(existing?.lastActivity || 0)
|
||||
if (!existing || currentActivity > existingActivity) deduped.set(id, session)
|
||||
}
|
||||
|
||||
return Array.from(deduped.values())
|
||||
.sort((a, b) => Number(b?.lastActivity || 0) - Number(a?.lastActivity || 0))
|
||||
.slice(0, 100)
|
||||
}
|
||||
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ function formatDateLabel(date: Date): string {
|
|||
}
|
||||
|
||||
export function CronManagementPanel() {
|
||||
const { cronJobs, setCronJobs } = useMissionControl()
|
||||
const { cronJobs, setCronJobs, dashboardMode } = useMissionControl()
|
||||
const isLocalMode = dashboardMode === 'local'
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
|
||||
|
|
@ -86,15 +87,40 @@ export function CronManagementPanel() {
|
|||
const loadCronJobs = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/cron?action=list')
|
||||
const data = await response.json()
|
||||
setCronJobs(data.jobs || [])
|
||||
const cronResponse = await fetch('/api/cron?action=list')
|
||||
const cronData = await cronResponse.json()
|
||||
const cronList = Array.isArray(cronData.jobs) ? cronData.jobs : []
|
||||
|
||||
if (!isLocalMode) {
|
||||
setCronJobs(cronList)
|
||||
return
|
||||
}
|
||||
|
||||
const schedulerResponse = await fetch('/api/scheduler')
|
||||
const schedulerData = await schedulerResponse.json()
|
||||
const schedulerTasks = Array.isArray(schedulerData.tasks) ? schedulerData.tasks : []
|
||||
const mappedSchedulerJobs: CronJob[] = schedulerTasks.map((task: any) => ({
|
||||
id: task.id,
|
||||
name: task.name || task.id || 'scheduler-task',
|
||||
schedule: 'system-managed automation',
|
||||
command: `Built-in local automation (${task.id || 'unknown'})`,
|
||||
agentId: 'mission-control-local',
|
||||
delivery: 'local',
|
||||
enabled: task.running ? true : !!task.enabled,
|
||||
lastRun: typeof task.lastRun === 'number' ? task.lastRun : undefined,
|
||||
nextRun: typeof task.nextRun === 'number' ? task.nextRun : undefined,
|
||||
lastStatus: task.running
|
||||
? 'running'
|
||||
: (task.lastResult?.ok === false ? 'error' : (task.lastResult?.ok === true ? 'success' : undefined)),
|
||||
}))
|
||||
|
||||
setCronJobs([...cronList, ...mappedSchedulerJobs])
|
||||
} catch (error) {
|
||||
console.error('Failed to load cron jobs:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [setCronJobs])
|
||||
}, [isLocalMode, setCronJobs])
|
||||
|
||||
useEffect(() => {
|
||||
loadCronJobs()
|
||||
|
|
@ -118,9 +144,44 @@ export function CronManagementPanel() {
|
|||
loadAvailableModels()
|
||||
}, [])
|
||||
|
||||
const loadJobLogs = async (jobName: string) => {
|
||||
const loadJobLogs = async (job: CronJob) => {
|
||||
const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
|
||||
if (isLocalAutomation) {
|
||||
const logs: Array<{ timestamp: number; message: string; level: string }> = []
|
||||
if (job.lastRun) {
|
||||
logs.push({
|
||||
timestamp: job.lastRun,
|
||||
message: `Last run recorded for ${job.name}`,
|
||||
level: job.lastStatus === 'error' ? 'error' : 'info',
|
||||
})
|
||||
}
|
||||
if (job.lastError) {
|
||||
logs.push({
|
||||
timestamp: job.lastRun || Date.now(),
|
||||
message: `Error: ${job.lastError}`,
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
if (job.nextRun) {
|
||||
logs.push({
|
||||
timestamp: Date.now(),
|
||||
message: `Next scheduled run: ${new Date(job.nextRun).toLocaleString()}`,
|
||||
level: 'info',
|
||||
})
|
||||
}
|
||||
if (logs.length === 0) {
|
||||
logs.push({
|
||||
timestamp: Date.now(),
|
||||
message: 'No scheduler telemetry available yet for this local automation task',
|
||||
level: 'info',
|
||||
})
|
||||
}
|
||||
setJobLogs(logs)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`)
|
||||
const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(job.name)}`)
|
||||
const data = await response.json()
|
||||
setJobLogs(data.logs || [])
|
||||
} catch (error) {
|
||||
|
|
@ -154,7 +215,24 @@ export function CronManagementPanel() {
|
|||
}
|
||||
|
||||
const triggerJob = async (job: CronJob) => {
|
||||
const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
|
||||
try {
|
||||
if (isLocalAutomation) {
|
||||
const response = await fetch('/api/scheduler', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: job.id }),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (response.ok && result.ok) {
|
||||
alert(`Local automation executed: ${result.message}`)
|
||||
} else {
|
||||
alert(`Local automation failed: ${result.error || result.message || 'Unknown error'}`)
|
||||
}
|
||||
await loadCronJobs()
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('/api/cron', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -249,7 +327,7 @@ export function CronManagementPanel() {
|
|||
|
||||
const handleJobSelect = (job: CronJob) => {
|
||||
setSelectedJob(job)
|
||||
loadJobLogs(job.name)
|
||||
loadJobLogs(job)
|
||||
}
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
|
|
@ -392,7 +470,11 @@ export function CronManagementPanel() {
|
|||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Calendar View</h2>
|
||||
<p className="text-sm text-muted-foreground">Read-only schedule visibility across all cron jobs</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLocalMode
|
||||
? 'Read-only schedule visibility across local cron jobs and automations'
|
||||
: 'Read-only schedule visibility across all cron jobs'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
|
@ -571,7 +653,9 @@ export function CronManagementPanel() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{cronJobs.map((job, index) => (
|
||||
{cronJobs.map((job, index) => {
|
||||
const isLocalAutomation = job.delivery === 'local' && job.agentId === 'mission-control-local'
|
||||
return (
|
||||
<div
|
||||
key={`${job.name}-${index}`}
|
||||
className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
|
|
@ -589,13 +673,15 @@ export function CronManagementPanel() {
|
|||
|
||||
{/* Job Type Tag */}
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${
|
||||
isLocalAutomation ? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30' :
|
||||
job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
|
||||
job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
|
||||
job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
|
||||
'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
|
||||
}`}>
|
||||
{job.name.includes('backup') ? 'BACKUP' :
|
||||
{isLocalAutomation ? 'LOCAL AUTO' :
|
||||
job.name.includes('backup') ? 'BACKUP' :
|
||||
job.name.includes('alert') ? 'ALERT' :
|
||||
job.name.includes('brief') ? 'BRIEF' :
|
||||
job.name.includes('scan') ? 'SCAN' :
|
||||
|
|
@ -636,11 +722,12 @@ export function CronManagementPanel() {
|
|||
e.stopPropagation()
|
||||
toggleJob(job)
|
||||
}}
|
||||
disabled={isLocalAutomation}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
job.enabled
|
||||
? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
|
||||
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
} transition-colors`}
|
||||
} transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{job.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
|
|
@ -658,6 +745,7 @@ export function CronManagementPanel() {
|
|||
e.stopPropagation()
|
||||
removeJob(job)
|
||||
}}
|
||||
disabled={isLocalAutomation}
|
||||
className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
|
|
@ -665,7 +753,7 @@ export function CronManagementPanel() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -687,6 +775,9 @@ export function CronManagementPanel() {
|
|||
<div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
|
||||
)}
|
||||
<div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
|
||||
{selectedJob.delivery === 'local' && selectedJob.agentId === 'mission-control-local' && (
|
||||
<div><span className="text-muted-foreground">Source:</span> Local scheduler automation</div>
|
||||
)}
|
||||
{selectedJob.nextRun && (
|
||||
<div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -63,16 +63,32 @@ interface GatewayOption {
|
|||
is_primary?: number
|
||||
}
|
||||
|
||||
interface SchedulerTask {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
lastRun: number | null
|
||||
nextRun: number
|
||||
running: boolean
|
||||
lastResult?: {
|
||||
ok: boolean
|
||||
message: string
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
const TENANT_PAGE_SIZE = 8
|
||||
const JOB_PAGE_SIZE = 8
|
||||
|
||||
export function SuperAdminPanel() {
|
||||
const { currentUser } = useMissionControl()
|
||||
const { currentUser, dashboardMode } = useMissionControl()
|
||||
const isLocal = dashboardMode === 'local'
|
||||
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([])
|
||||
const [jobs, setJobs] = useState<ProvisionJob[]>([])
|
||||
const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
|
||||
const [selectedJobEvents, setSelectedJobEvents] = useState<ProvisionEvent[]>([])
|
||||
const [localJobEvents, setLocalJobEvents] = useState<Record<number, ProvisionEvent[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
|
@ -123,25 +139,96 @@ export function SuperAdminPanel() {
|
|||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [tenantsRes, jobsRes, gatewaysRes] = await Promise.all([
|
||||
const [tenantsRes, jobsRes, gatewaysRes, schedulerRes] = await Promise.all([
|
||||
fetch('/api/super/tenants', { cache: 'no-store' }),
|
||||
fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }),
|
||||
fetch('/api/gateways', { cache: 'no-store' }),
|
||||
isLocal ? fetch('/api/scheduler', { cache: 'no-store' }) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const tenantsJson = await tenantsRes.json().catch(() => ({}))
|
||||
const jobsJson = await jobsRes.json().catch(() => ({}))
|
||||
const gatewaysJson = await gatewaysRes.json().catch(() => ({}))
|
||||
const schedulerJson = schedulerRes ? await schedulerRes.json().catch(() => ({})) : {}
|
||||
|
||||
if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants')
|
||||
if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs')
|
||||
|
||||
const tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : []
|
||||
const jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : []
|
||||
let tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : []
|
||||
let jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : []
|
||||
const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : []
|
||||
const schedulerTasks: SchedulerTask[] = Array.isArray(schedulerJson?.tasks) ? schedulerJson.tasks : []
|
||||
const localEvents: Record<number, ProvisionEvent[]> = {}
|
||||
|
||||
if (isLocal) {
|
||||
if (tenantRows.length === 0) {
|
||||
const primaryGateway = gatewayRows.find((gw: any) => Number(gw?.is_primary) === 1)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
tenantRows = [{
|
||||
id: -1,
|
||||
slug: 'local-system',
|
||||
display_name: 'Local Mission Control',
|
||||
linux_user: currentUser?.username || 'local',
|
||||
created_by: 'local',
|
||||
owner_gateway: primaryGateway?.name || 'local',
|
||||
status: 'active',
|
||||
plan_tier: 'local',
|
||||
gateway_port: Number(primaryGateway?.port || 0) || null,
|
||||
dashboard_port: null,
|
||||
created_at: now,
|
||||
latest_job_id: null,
|
||||
latest_job_status: null,
|
||||
}]
|
||||
}
|
||||
|
||||
if (jobRows.length === 0 && schedulerTasks.length > 0) {
|
||||
jobRows = schedulerTasks.map((task, index) => {
|
||||
const id = -1000 - index
|
||||
const status = task.running
|
||||
? 'running'
|
||||
: (!task.enabled ? 'cancelled' : (task.lastResult?.ok === false ? 'failed' : (task.lastRun ? 'completed' : 'queued')))
|
||||
const eventRows: ProvisionEvent[] = []
|
||||
if (task.lastResult) {
|
||||
eventRows.push({
|
||||
id: id * -10,
|
||||
level: task.lastResult.ok ? 'info' : 'error',
|
||||
step_key: task.id,
|
||||
message: task.lastResult.message,
|
||||
created_at: Math.floor(task.lastResult.timestamp / 1000),
|
||||
})
|
||||
}
|
||||
eventRows.push({
|
||||
id: id * -10 + 1,
|
||||
level: 'info',
|
||||
step_key: task.id,
|
||||
message: `Next run: ${new Date(task.nextRun).toLocaleString()}`,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
localEvents[id] = eventRows
|
||||
|
||||
const lastRunSec = task.lastRun ? Math.floor(task.lastRun / 1000) : null
|
||||
return {
|
||||
id,
|
||||
tenant_id: -1,
|
||||
tenant_slug: 'local-system',
|
||||
tenant_display_name: 'Local Mission Control',
|
||||
job_type: 'automation',
|
||||
status,
|
||||
dry_run: 1,
|
||||
requested_by: 'scheduler',
|
||||
approved_by: null,
|
||||
started_at: lastRunSec,
|
||||
completed_at: status !== 'running' ? lastRunSec : null,
|
||||
error_text: task.lastResult?.ok === false ? task.lastResult.message : null,
|
||||
created_at: lastRunSec || Math.floor(task.nextRun / 1000),
|
||||
} as ProvisionJob
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setTenants(tenantRows)
|
||||
setJobs(jobRows)
|
||||
setLocalJobEvents(localEvents)
|
||||
setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary })))
|
||||
setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways'))
|
||||
setError(null)
|
||||
|
|
@ -150,9 +237,16 @@ export function SuperAdminPanel() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [currentUser?.username, isLocal])
|
||||
|
||||
const loadJobDetail = useCallback(async (jobId: number) => {
|
||||
if (isLocal && jobId < 0) {
|
||||
setSelectedJobId(jobId)
|
||||
setSelectedJobEvents(localJobEvents[jobId] || [])
|
||||
setActiveTab('events')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => ({}))
|
||||
|
|
@ -163,7 +257,7 @@ export function SuperAdminPanel() {
|
|||
} catch (e: any) {
|
||||
showFeedback(false, e?.message || 'Failed to load job details')
|
||||
}
|
||||
}, [])
|
||||
}, [isLocal, localJobEvents])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
|
|
@ -406,7 +500,9 @@ export function SuperAdminPanel() {
|
|||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Super Mission Control</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Multi-tenant provisioning control plane with approval gates and safer destructive actions.
|
||||
{isLocal
|
||||
? 'Local control plane view over scheduler automations and runtime state.'
|
||||
: 'Multi-tenant provisioning control plane with approval gates and safer destructive actions.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -630,6 +726,10 @@ export function SuperAdminPanel() {
|
|||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right relative">
|
||||
{isLocal && tenant.id < 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground">Local read-only</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
|
||||
className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
|
||||
|
|
@ -646,6 +746,8 @@ export function SuperAdminPanel() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
|
@ -743,6 +845,15 @@ export function SuperAdminPanel() {
|
|||
<div>Appr: {job.approved_by || '-'}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right relative">
|
||||
{isLocal && job.id < 0 ? (
|
||||
<button
|
||||
onClick={() => loadJobDetail(job.id)}
|
||||
className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
|
||||
className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
|
||||
|
|
@ -780,6 +891,8 @@ export function SuperAdminPanel() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||
import { useMissionControl } from '@/store'
|
||||
|
||||
interface Webhook {
|
||||
id: number
|
||||
|
|
@ -33,6 +34,16 @@ interface Delivery {
|
|||
created_at: number
|
||||
}
|
||||
|
||||
interface SchedulerTask {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
lastRun: number | null
|
||||
nextRun: number | null
|
||||
running: boolean
|
||||
lastResult?: { ok: boolean; message: string; timestamp: number }
|
||||
}
|
||||
|
||||
const AVAILABLE_EVENTS = [
|
||||
{ value: '*', label: 'All events', description: 'Receive all event types' },
|
||||
{ value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' },
|
||||
|
|
@ -48,7 +59,10 @@ const AVAILABLE_EVENTS = [
|
|||
]
|
||||
|
||||
export function WebhookPanel() {
|
||||
const { dashboardMode } = useMissionControl()
|
||||
const isLocalMode = dashboardMode === 'local'
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
||||
const [webhookAutomations, setWebhookAutomations] = useState<SchedulerTask[]>([])
|
||||
const [deliveries, setDeliveries] = useState<Delivery[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
|
@ -57,6 +71,7 @@ export function WebhookPanel() {
|
|||
const [testingId, setTestingId] = useState<number | null>(null)
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
const [newSecret, setNewSecret] = useState<string | null>(null)
|
||||
const [runningAutomationId, setRunningAutomationId] = useState<string | null>(null)
|
||||
|
||||
const fetchWebhooks = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -88,9 +103,30 @@ export function WebhookPanel() {
|
|||
} catch { /* silent */ }
|
||||
}, [selectedWebhook])
|
||||
|
||||
const fetchWebhookAutomations = useCallback(async () => {
|
||||
if (!isLocalMode) {
|
||||
setWebhookAutomations([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/scheduler')
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : []
|
||||
const webhookTasks = tasks.filter((task: SchedulerTask) =>
|
||||
typeof task.id === 'string' && task.id.includes('webhook')
|
||||
)
|
||||
setWebhookAutomations(webhookTasks)
|
||||
} catch {
|
||||
// Keep UI usable if scheduler endpoint is unavailable.
|
||||
}
|
||||
}, [isLocalMode])
|
||||
|
||||
useEffect(() => { fetchWebhooks() }, [fetchWebhooks])
|
||||
useEffect(() => { fetchDeliveries() }, [fetchDeliveries])
|
||||
useEffect(() => { fetchWebhookAutomations() }, [fetchWebhookAutomations])
|
||||
useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true })
|
||||
useSmartPoll(fetchWebhookAutomations, 60000, { pauseWhenDisconnected: true })
|
||||
|
||||
async function handleCreate(form: { name: string; url: string; events: string[] }) {
|
||||
try {
|
||||
|
|
@ -142,6 +178,29 @@ export function WebhookPanel() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleRunAutomation(taskId: string) {
|
||||
setRunningAutomationId(taskId)
|
||||
try {
|
||||
const res = await fetch('/api/scheduler', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setTestResult({
|
||||
success: !!data.ok && res.ok,
|
||||
error: data.error || (!data.ok ? data.message : null),
|
||||
duration_ms: undefined,
|
||||
status_code: res.status,
|
||||
})
|
||||
await fetchWebhookAutomations()
|
||||
} catch {
|
||||
setTestResult({ success: false, error: 'Failed to run local automation' })
|
||||
} finally {
|
||||
setRunningAutomationId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
|
|
@ -223,6 +282,41 @@ export function WebhookPanel() {
|
|||
|
||||
{/* Webhook list */}
|
||||
<div className="space-y-2">
|
||||
{isLocalMode && webhookAutomations.length > 0 && (
|
||||
<div className="rounded-lg border border-cyan-500/30 bg-cyan-500/5 p-3">
|
||||
<h3 className="text-sm font-semibold text-cyan-200">Local Webhook Automations</h3>
|
||||
<p className="text-2xs text-cyan-300/80 mt-0.5 mb-2">
|
||||
Local scheduler tasks that support webhook delivery and retries
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{webhookAutomations.map((task) => (
|
||||
<div key={task.id} className="rounded border border-cyan-500/20 bg-background/30 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${task.running ? 'bg-blue-400' : task.enabled ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
|
||||
<span className="text-xs font-medium text-foreground truncate">{task.name}</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-cyan-500/15 text-cyan-300 font-mono">{task.id}</span>
|
||||
</div>
|
||||
<div className="text-2xs text-muted-foreground mt-1">
|
||||
{task.nextRun ? `Next run ${formatTime(task.nextRun / 1000)}` : 'No next run scheduled'}
|
||||
{task.lastResult?.message ? ` · ${task.lastResult.message}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRunAutomation(task.id)}
|
||||
disabled={runningAutomationId === task.id}
|
||||
className="h-7 px-2.5 text-2xs font-medium text-cyan-300 hover:text-cyan-200 hover:bg-cyan-500/10 rounded transition-smooth disabled:opacity-50"
|
||||
>
|
||||
{runningAutomationId === task.id ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && webhooks.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <div key={i} className="h-16 rounded-lg shimmer" />)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { basename, join } from 'path'
|
||||
import { config } from './config'
|
||||
import { logger } from './logger'
|
||||
|
||||
const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000
|
||||
const DEFAULT_FILE_SCAN_LIMIT = 120
|
||||
|
||||
export interface CodexSessionStats {
|
||||
sessionId: string
|
||||
projectSlug: string
|
||||
projectPath: string | null
|
||||
model: string | null
|
||||
userMessages: number
|
||||
assistantMessages: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
totalTokens: number
|
||||
firstMessageAt: string | null
|
||||
lastMessageAt: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface ParsedFile {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function deriveSessionId(filePath: string): string {
|
||||
const name = basename(filePath, '.jsonl')
|
||||
const match = name.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i)
|
||||
return match?.[1] || name
|
||||
}
|
||||
|
||||
function listRecentCodexSessionFiles(limit: number): ParsedFile[] {
|
||||
const root = join(config.homeDir, '.codex', 'sessions')
|
||||
const files: ParsedFile[] = []
|
||||
const stack = [root]
|
||||
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
if (!dir) continue
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
stack.push(fullPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!stat.isFile() || !fullPath.endsWith('.jsonl')) continue
|
||||
files.push({ path: fullPath, mtimeMs: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
return files.slice(0, Math.max(1, limit))
|
||||
}
|
||||
|
||||
function parseCodexSessionFile(filePath: string): CodexSessionStats | null {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(filePath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const lines = content.split('\n').filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
let sessionId = deriveSessionId(filePath)
|
||||
let projectPath: string | null = null
|
||||
let model: string | null = null
|
||||
let userMessages = 0
|
||||
let assistantMessages = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let totalTokens = 0
|
||||
let firstMessageAt: string | null = null
|
||||
let lastMessageAt: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const entry = asObject(parsed)
|
||||
if (!entry) continue
|
||||
|
||||
const timestamp = asString(entry.timestamp)
|
||||
if (timestamp) {
|
||||
if (!firstMessageAt) firstMessageAt = timestamp
|
||||
lastMessageAt = timestamp
|
||||
}
|
||||
|
||||
const entryType = asString(entry.type)
|
||||
const payload = asObject(entry.payload)
|
||||
|
||||
if (entryType === 'session_meta' && payload) {
|
||||
const metaId = asString(payload.id)
|
||||
if (metaId) sessionId = metaId
|
||||
|
||||
const cwd = asString(payload.cwd)
|
||||
if (cwd) projectPath = cwd
|
||||
|
||||
const metaModel = asString(payload.model)
|
||||
if (metaModel) model = metaModel
|
||||
|
||||
const startedAt = asString(payload.timestamp)
|
||||
if (startedAt && !firstMessageAt) firstMessageAt = startedAt
|
||||
continue
|
||||
}
|
||||
|
||||
if (entryType === 'response_item' && payload) {
|
||||
const payloadType = asString(payload.type)
|
||||
const role = asString(payload.role)
|
||||
if (payloadType === 'message' && role === 'user') userMessages++
|
||||
if (payloadType === 'message' && role === 'assistant') assistantMessages++
|
||||
continue
|
||||
}
|
||||
|
||||
if (entryType === 'event_msg' && payload) {
|
||||
const msgType = asString(payload.type)
|
||||
if (msgType !== 'token_count') continue
|
||||
|
||||
const info = asObject(payload.info)
|
||||
const totals = info ? asObject(info.total_token_usage) : null
|
||||
if (totals) {
|
||||
const inTokens = asNumber(totals.input_tokens) || 0
|
||||
const cached = asNumber(totals.cached_input_tokens) || 0
|
||||
const outTokens = asNumber(totals.output_tokens) || 0
|
||||
const allTokens = asNumber(totals.total_tokens) || (inTokens + cached + outTokens)
|
||||
inputTokens = Math.max(inputTokens, inTokens + cached)
|
||||
outputTokens = Math.max(outputTokens, outTokens)
|
||||
totalTokens = Math.max(totalTokens, allTokens)
|
||||
}
|
||||
|
||||
const limits = asObject(payload.rate_limits)
|
||||
const limitName = limits ? asString(limits.limit_name) : null
|
||||
if (!model && limitName) model = limitName
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastMessageAt && !firstMessageAt) return null
|
||||
|
||||
const projectSlug = projectPath
|
||||
? basename(projectPath)
|
||||
: 'codex-local'
|
||||
const lastMessageMs = lastMessageAt ? new Date(lastMessageAt).getTime() : 0
|
||||
const isActive = lastMessageMs > 0 && (Date.now() - lastMessageMs) < ACTIVE_THRESHOLD_MS
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectSlug,
|
||||
projectPath,
|
||||
model,
|
||||
userMessages,
|
||||
assistantMessages,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
firstMessageAt,
|
||||
lastMessageAt: lastMessageAt || firstMessageAt,
|
||||
isActive,
|
||||
}
|
||||
}
|
||||
|
||||
export function scanCodexSessions(limit = DEFAULT_FILE_SCAN_LIMIT): CodexSessionStats[] {
|
||||
try {
|
||||
const files = listRecentCodexSessionFiles(limit)
|
||||
const sessions: CodexSessionStats[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const parsed = parseCodexSessionFile(file.path)
|
||||
if (parsed) sessions.push(parsed)
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => {
|
||||
const aTs = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0
|
||||
const bTs = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0
|
||||
return bTs - aTs
|
||||
})
|
||||
|
||||
return sessions
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to scan Codex sessions')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import type { Agent } from '@/store'
|
||||
|
||||
export type OfficeZoneType = 'engineering' | 'operations' | 'research' | 'product' | 'quality' | 'general'
|
||||
|
||||
export interface OfficeZoneDefinition {
|
||||
id: OfficeZoneType
|
||||
label: string
|
||||
icon: string
|
||||
accentClass: string
|
||||
roleKeywords: string[]
|
||||
}
|
||||
|
||||
export interface WorkstationAnchor {
|
||||
deskId: string
|
||||
seatLabel: string
|
||||
row: number
|
||||
col: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ZonedAgent {
|
||||
agent: Agent
|
||||
anchor: WorkstationAnchor
|
||||
}
|
||||
|
||||
export interface OfficeZoneLayout {
|
||||
zone: OfficeZoneDefinition
|
||||
workers: ZonedAgent[]
|
||||
}
|
||||
|
||||
export const OFFICE_ZONES: OfficeZoneDefinition[] = [
|
||||
{
|
||||
id: 'engineering',
|
||||
label: 'Engineering Bay',
|
||||
icon: '🧑💻',
|
||||
accentClass: 'border-cyan-500/30 bg-cyan-500/10',
|
||||
roleKeywords: ['engineer', 'dev', 'frontend', 'backend', 'fullstack', 'software'],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations Pod',
|
||||
icon: '🛠️',
|
||||
accentClass: 'border-amber-500/30 bg-amber-500/10',
|
||||
roleKeywords: ['ops', 'sre', 'infra', 'platform', 'reliability'],
|
||||
},
|
||||
{
|
||||
id: 'research',
|
||||
label: 'Research Corner',
|
||||
icon: '🔬',
|
||||
accentClass: 'border-violet-500/30 bg-violet-500/10',
|
||||
roleKeywords: ['research', 'science', 'analyst', 'ai'],
|
||||
},
|
||||
{
|
||||
id: 'product',
|
||||
label: 'Product Studio',
|
||||
icon: '📐',
|
||||
accentClass: 'border-emerald-500/30 bg-emerald-500/10',
|
||||
roleKeywords: ['product', 'pm', 'design', 'ux', 'ui'],
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
label: 'Quality Lab',
|
||||
icon: '🧪',
|
||||
accentClass: 'border-rose-500/30 bg-rose-500/10',
|
||||
roleKeywords: ['qa', 'test', 'quality'],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General Workspace',
|
||||
icon: '🏢',
|
||||
accentClass: 'border-slate-500/30 bg-slate-500/10',
|
||||
roleKeywords: [],
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeRole(role: string | undefined): string {
|
||||
return String(role || '').toLowerCase()
|
||||
}
|
||||
|
||||
export function getZoneByRole(role: string | undefined): OfficeZoneDefinition {
|
||||
const normalized = normalizeRole(role)
|
||||
for (const zone of OFFICE_ZONES) {
|
||||
if (zone.id === 'general') continue
|
||||
if (zone.roleKeywords.some((keyword) => normalized.includes(keyword))) {
|
||||
return zone
|
||||
}
|
||||
}
|
||||
return OFFICE_ZONES.find((zone) => zone.id === 'general')!
|
||||
}
|
||||
|
||||
function buildAnchor(index: number, columnCount: number): WorkstationAnchor {
|
||||
const row = Math.floor(index / columnCount)
|
||||
const col = index % columnCount
|
||||
const rowLabel = String.fromCharCode(65 + row)
|
||||
const seatLabel = `${rowLabel}${col + 1}`
|
||||
return {
|
||||
deskId: `desk-${seatLabel.toLowerCase()}`,
|
||||
seatLabel,
|
||||
row,
|
||||
col,
|
||||
// Useful for future absolute-position movement/collision mechanics.
|
||||
x: col * 220 + 110,
|
||||
y: row * 160 + 80,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOfficeLayout(agents: Agent[]): OfficeZoneLayout[] {
|
||||
const zoneMap = new Map<OfficeZoneType, Agent[]>()
|
||||
for (const zone of OFFICE_ZONES) zoneMap.set(zone.id, [])
|
||||
|
||||
for (const agent of agents) {
|
||||
const zone = getZoneByRole(agent.role)
|
||||
zoneMap.get(zone.id)!.push(agent)
|
||||
}
|
||||
|
||||
const result: OfficeZoneLayout[] = []
|
||||
for (const zone of OFFICE_ZONES) {
|
||||
const workers = zoneMap.get(zone.id) || []
|
||||
if (workers.length === 0) continue
|
||||
|
||||
const columns = workers.length >= 8 ? 4 : workers.length >= 4 ? 3 : 2
|
||||
const zoned = workers.map((agent, i) => ({
|
||||
agent,
|
||||
anchor: buildAnchor(i, columns),
|
||||
}))
|
||||
|
||||
result.push({ zone, workers: zoned })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.workers.length - a.workers.length)
|
||||
}
|
||||