Skip to content
Closed
1 change: 1 addition & 0 deletions messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "قيد التنفيذ",
"colReview": "المراجعة",
"colQualityReview": "مراجعة الجودة",
"colAwaitingOwner": "بانتظار المالك",
"colDone": "مكتمل",
"recurring": "متكرر",
"spawned": "مُنشأ",
Expand Down
1 change: 1 addition & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "In Bearbeitung",
"colReview": "Überprüfung",
"colQualityReview": "Qualitätsprüfung",
"colAwaitingOwner": "Warten auf Eigentümer",
"colDone": "Erledigt",
"recurring": "WIEDERKEHREND",
"spawned": "ERSTELLT",
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "In Progress",
"colReview": "Review",
"colQualityReview": "Quality Review",
"colAwaitingOwner": "Awaiting Owner",
"colDone": "Done",
"recurring": "RECURRING",
"spawned": "SPAWNED",
Expand Down
1 change: 1 addition & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "En progreso",
"colReview": "Revisión",
"colQualityReview": "Revisión de calidad",
"colAwaitingOwner": "Esperando al propietario",
"colDone": "Hecho",
"recurring": "RECURRENTE",
"spawned": "GENERADO",
Expand Down
1 change: 1 addition & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "En cours",
"colReview": "Révision",
"colQualityReview": "Révision qualité",
"colAwaitingOwner": "En attente du propriétaire",
"colDone": "Terminé",
"recurring": "RÉCURRENT",
"spawned": "GÉNÉRÉ",
Expand Down
1 change: 1 addition & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "進行中",
"colReview": "レビュー",
"colQualityReview": "品質レビュー",
"colAwaitingOwner": "オーナー対応待ち",
"colDone": "完了",
"recurring": "定期",
"spawned": "生成済み",
Expand Down
1 change: 1 addition & 0 deletions messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "진행 중",
"colReview": "검토",
"colQualityReview": "품질 검토",
"colAwaitingOwner": "소유자 대기 중",
"colDone": "완료",
"recurring": "반복",
"spawned": "생성됨",
Expand Down
1 change: 1 addition & 0 deletions messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "Em andamento",
"colReview": "Revisão",
"colQualityReview": "Revisão de qualidade",
"colAwaitingOwner": "Aguardando proprietário",
"colDone": "Concluído",
"recurring": "RECORRENTE",
"spawned": "GERADO",
Expand Down
1 change: 1 addition & 0 deletions messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@
"colInProgress": "В работе",
"colReview": "На проверке",
"colQualityReview": "Контроль качества",
"colAwaitingOwner": "Ожидает владельца",
"colDone": "Готово",
"recurring": "ПОВТОРЯЮЩАЯСЯ",
"spawned": "СОЗДАНА",
Expand Down
1 change: 1 addition & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,7 @@
"colInProgress": "进行中",
"colReview": "审查",
"colQualityReview": "质量审查",
"colAwaitingOwner": "等待负责人",
"colDone": "完成",
"recurring": "循环",
"spawned": "已生成",
Expand Down
7 changes: 7 additions & 0 deletions src/app/[[...panel]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { DebugPanel } from '@/components/panels/debug-panel'
import { SecurityAuditPanel } from '@/components/panels/security-audit-panel'
import { NodesPanel } from '@/components/panels/nodes-panel'
import { ExecApprovalPanel } from '@/components/panels/exec-approval-panel'
import { ActiveRunsPanel } from '@/components/panels/active-runs-panel'
import { AgentPerformancePanel } from '@/components/panels/agent-performance-panel'
import { ChatPagePanel } from '@/components/panels/chat-page-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { getPluginPanel } from '@/lib/plugins'
Expand Down Expand Up @@ -562,8 +564,13 @@ function ContentRouter({ tab }: { tab: string }) {
return <NodesPanel />
case 'security':
return <SecurityAuditPanel />
case 'agent-performance':
case 'performance':
return <AgentPerformancePanel />
case 'debug':
return <DebugPanel />
case 'active-runs':
return <ActiveRunsPanel />
case 'exec-approvals':
if (isLocal) return <LocalModeUnavailable panel={tab} />
return <ExecApprovalPanel />
Expand Down
294 changes: 294 additions & 0 deletions src/app/api/active-runs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import { NextRequest, NextResponse } from 'next/server'
import { runCommand } from '@/lib/command'
import { requireRole } from '@/lib/auth'
import { logger } from '@/lib/logger'

export interface ActiveRun {
id: string
name: string
type: 'tmux' | 'systemd' | 'cron' | 'process'
owner: string
status: 'running' | 'stopped' | 'failed' | 'stale'
pid?: string
startedAt?: string
lastProgress?: string
outputSnippet?: string
unit?: string
logPath?: string
}

const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes

export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })

try {
const [tmux, systemd, cron, processes] = await Promise.allSettled([
scanTmuxSessions(),
scanSystemdUnits(),
scanCronJobs(),
scanKnownProcesses(),
])

const runs: ActiveRun[] = [
...(tmux.status === 'fulfilled' ? tmux.value : []),
...(systemd.status === 'fulfilled' ? systemd.value : []),
...(cron.status === 'fulfilled' ? cron.value : []),
...(processes.status === 'fulfilled' ? processes.value : []),
]

const activeCount = runs.filter(r => r.status === 'running').length
const staleCount = runs.filter(r => r.status === 'stale').length
const stoppedCount = runs.filter(r => r.status === 'stopped' || r.status === 'failed').length

return NextResponse.json({
runs,
summary: { total: runs.length, active: activeCount, stale: staleCount, stopped: stoppedCount },
scannedAt: new Date().toISOString(),
})
} catch (error) {
logger.error({ err: error }, 'Active runs scan error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

async function scanTmuxSessions(): Promise<ActiveRun[]> {
const runs: ActiveRun[] = []

// Try custom socket first (OpenClaw convention), then default
const sockets = ['~/.tmux/sock', '']
for (const sock of sockets) {
try {
const args = sock
? ['-S', sock.replace('~', process.env.HOME || ''), 'list-sessions', '-F', '#{session_name}|#{session_created}|#{session_activity}|#{session_windows}']
: ['list-sessions', '-F', '#{session_name}|#{session_created}|#{session_activity}|#{session_windows}']

const { stdout, code } = await runCommand('tmux', args, { timeoutMs: 5000 })
if (code !== 0 || !stdout.trim()) continue

for (const line of stdout.trim().split('\n')) {
const [name, created, activity, windows] = line.split('|')
if (!name) continue

const createdMs = parseInt(created) * 1000
const activityMs = parseInt(activity) * 1000
const isStale = Date.now() - activityMs > STALE_THRESHOLD_MS

// Grab last few lines from the active pane
let snippet = ''
try {
const captureArgs = sock
? ['-S', sock.replace('~', process.env.HOME || ''), 'capture-pane', '-t', name, '-p']
: ['capture-pane', '-t', name, '-p']
const { stdout: paneOutput } = await runCommand('tmux', captureArgs, { timeoutMs: 3000 })
const lines = paneOutput.trim().split('\n').filter(l => l.trim())
snippet = lines.slice(-5).join('\n')
} catch { /* ignore */ }

runs.push({
id: `tmux-${name}`,
name,
type: 'tmux',
owner: inferOwner(name),
status: isStale ? 'stale' : 'running',
startedAt: new Date(createdMs).toISOString(),
lastProgress: new Date(activityMs).toISOString(),
outputSnippet: snippet || undefined,
})
}
} catch {
// tmux not running or socket unavailable
}
}

// Deduplicate by name (custom socket sessions may overlap with default)
const seen = new Set<string>()
return runs.filter(r => {
if (seen.has(r.name)) return false
seen.add(r.name)
return true
})
}

async function scanSystemdUnits(): Promise<ActiveRun[]> {
const runs: ActiveRun[] = []

try {
// List user units matching known patterns
const { stdout, code } = await runCommand('systemctl', [
'--user', 'list-units', '--type=service', '--all', '--no-pager', '--plain',
'--no-legend',
], { timeoutMs: 5000 })

if (code !== 0) return runs

for (const line of stdout.trim().split('\n')) {
if (!line.trim()) continue
const parts = line.trim().split(/\s+/)
const unit = parts[0] || ''
const load = parts[1] || ''
const active = parts[2] || ''
const sub = parts[3] || ''

// Only show relevant units (btc5m, openclaw, collector, etc.)
if (!isRelevantUnit(unit)) continue

let status: ActiveRun['status'] = 'stopped'
if (active === 'active' && sub === 'running') status = 'running'
else if (active === 'failed') status = 'failed'

// Get unit details
let startedAt: string | undefined
let snippet: string | undefined
try {
const { stdout: showOut } = await runCommand('systemctl', [
'--user', 'show', unit,
'--property=ActiveEnterTimestamp,MainPID',
], { timeoutMs: 3000 })

const tsMatch = showOut.match(/ActiveEnterTimestamp=(.+)/)
if (tsMatch?.[1] && tsMatch[1] !== 'n/a') {
startedAt = new Date(tsMatch[1]).toISOString()
}
} catch { /* ignore */ }

// Get recent journal output
try {
const { stdout: journalOut } = await runCommand('journalctl', [
'--user', '-u', unit, '--no-pager', '-n', '5', '--output=short',
], { timeoutMs: 3000 })
const lines = journalOut.trim().split('\n').filter(l => l.trim())
snippet = lines.slice(-5).join('\n')
} catch { /* ignore */ }

runs.push({
id: `systemd-${unit}`,
name: unit.replace('.service', ''),
type: 'systemd',
owner: inferOwner(unit),
status,
unit,
startedAt,
outputSnippet: snippet || undefined,
})
}
} catch {
// systemd --user not available
}

return runs
}

async function scanCronJobs(): Promise<ActiveRun[]> {
const runs: ActiveRun[] = []

try {
const { stdout, code } = await runCommand('crontab', ['-l'], { timeoutMs: 5000 })
if (code !== 0) return runs

let idx = 0
for (const line of stdout.trim().split('\n')) {
if (!line.trim() || line.trim().startsWith('#')) continue

// Parse cron line: schedule + command
const match = line.match(/^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/)
if (!match) continue

const schedule = match[1]
const command = match[2]
idx++

// Extract a readable name from the command
const name = extractCronName(command)

runs.push({
id: `cron-${idx}`,
name,
type: 'cron',
owner: inferOwner(command),
status: 'running', // cron jobs are always "scheduled"
outputSnippet: `Schedule: ${schedule}\nCommand: ${command.substring(0, 200)}`,
})
}
} catch {
// crontab not available
}

return runs
}

async function scanKnownProcesses(): Promise<ActiveRun[]> {
const runs: ActiveRun[] = []

try {
const { stdout } = await runCommand('ps', [
'-eo', 'pid,etimes,args', '--no-headers',
], { timeoutMs: 5000 })

const patterns = [
{ pattern: /btc_5m_latency/, owner: 'ralph', label: 'btc-5m-latency collector' },
{ pattern: /scanner_cron|profit_exit/, owner: 'ralph', label: 'polymarket scanner' },
{ pattern: /collect\.py.*dashboard/, owner: 'obsidian', label: 'dashboard collector' },
{ pattern: /xpost|xqueue/, owner: 'ralph', label: 'X post queue' },
{ pattern: /mc-report/, owner: 'system', label: 'MC reporter' },
]

for (const line of stdout.trim().split('\n')) {
if (!line.trim()) continue
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)
if (!match) continue

const [, pid, etimeStr, command] = match
const elapsed = parseInt(etimeStr) * 1000

for (const p of patterns) {
if (p.pattern.test(command)) {
const startedAt = new Date(Date.now() - elapsed).toISOString()
const isStale = elapsed > STALE_THRESHOLD_MS && !p.pattern.test('cron')

runs.push({
id: `proc-${pid}`,
name: p.label,
type: 'process',
owner: p.owner,
status: isStale ? 'stale' : 'running',
pid,
startedAt,
lastProgress: startedAt,
outputSnippet: command.substring(0, 300),
})
break
}
}
}
} catch {
// ps not available
}

return runs
}

function inferOwner(name: string): string {
const lower = name.toLowerCase()
if (/ralph|btc|paper|trade|scanner|xpost|xmeme|meme/.test(lower)) return 'ralph'
if (/obsidian|collect|dashboard|mc-/.test(lower)) return 'obsidian'
if (/sentinel|metar|weather|monitor/.test(lower)) return 'sentinel'
return 'system'
}

function isRelevantUnit(unit: string): boolean {
const lower = unit.toLowerCase()
return /btc5m|openclaw|collector|paper|scanner|sentinel|obsidian|ralph|metar|xpost/.test(lower)
}

function extractCronName(command: string): string {
// Try to get a meaningful name from the command
const scriptMatch = command.match(/([\/\w-]+\.(sh|py|js))\b/)
if (scriptMatch) {
const parts = scriptMatch[1].split('/')
return parts[parts.length - 1]
}
// Fallback: first 40 chars
return command.substring(0, 40).trim()
}
Loading