chore: resolve merge conflict with main for PR #177
This commit is contained in:
commit
a63ec39d3b
|
|
@ -1,3 +1,7 @@
|
|||
# === Server Port ===
|
||||
# Port the Next.js server listens on (dev and production)
|
||||
# PORT=3000
|
||||
|
||||
# === Authentication ===
|
||||
# Admin user seeded on first run (only if no users exist in DB)
|
||||
AUTH_USER=admin
|
||||
|
|
@ -65,7 +69,7 @@ NEXT_PUBLIC_GATEWAY_PROTOCOL=
|
|||
NEXT_PUBLIC_GATEWAY_URL=
|
||||
# NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
|
||||
# Gateway client id used in websocket handshake (role=operator UI client).
|
||||
NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui
|
||||
NEXT_PUBLIC_GATEWAY_CLIENT_ID=openclaw-control-ui
|
||||
|
||||
# === Data Paths (all optional, defaults to .data/ in project root) ===
|
||||
# MISSION_CONTROL_DATA_DIR=.data
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
|
|||
RUN mkdir -p .data && chown nextjs:nodejs .data
|
||||
RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/login || exit 1
|
||||
CMD curl -f http://localhost:${PORT:-3000}/login || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -113,6 +113,14 @@ Inter-agent communication via the comms API. Agents can send messages to each ot
|
|||
### Integrations
|
||||
Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management.
|
||||
|
||||
### Workspace Management
|
||||
Workspaces (tenant instances) are created and managed through the **Super Admin** panel, accessible from the sidebar under **Admin > Super Admin**. From there, admins can:
|
||||
- **Create** new client instances (slug, display name, Linux user, gateway port, plan tier)
|
||||
- **Monitor** provisioning jobs and their step-by-step progress
|
||||
- **Decommission** tenants with optional cleanup of state directories and Linux users
|
||||
|
||||
Each workspace gets its own isolated environment with a dedicated OpenClaw gateway, state directory, and workspace root. See the [Super Admin API](#api-overview) endpoints under `/api/super/*` for programmatic access.
|
||||
|
||||
### Update Checker
|
||||
Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard.
|
||||
|
||||
|
|
@ -277,6 +285,20 @@ All endpoints require authentication unless noted. Full reference below.
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Super Admin (Workspace/Tenant Management)</strong></summary>
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/super/tenants` | admin | List all tenants with latest provisioning status |
|
||||
| `POST` | `/api/super/tenants` | admin | Create tenant and queue bootstrap job |
|
||||
| `POST` | `/api/super/tenants/[id]/decommission` | admin | Queue tenant decommission job |
|
||||
| `GET` | `/api/super/provision-jobs` | admin | List provisioning jobs (filter: `?tenant_id=`, `?status=`) |
|
||||
| `POST` | `/api/super/provision-jobs` | admin | Queue additional job for existing tenant |
|
||||
| `POST` | `/api/super/provision-jobs/[id]/action` | admin | Approve, reject, or cancel a provisioning job |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Direct CLI</strong></summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ services:
|
|||
build: .
|
||||
container_name: mission-control
|
||||
ports:
|
||||
- "${MC_PORT:-3000}:3000"
|
||||
- "${MC_PORT:-3000}:${PORT:-3000}"
|
||||
environment:
|
||||
- PORT=${PORT:-3000}
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
|
|
|||
8857
openapi.json
8857
openapi.json
File diff suppressed because it is too large
Load Diff
|
|
@ -3,9 +3,9 @@
|
|||
"version": "1.3.0",
|
||||
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
|
||||
"scripts": {
|
||||
"dev": "next dev --hostname 127.0.0.1",
|
||||
"dev": "next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname 0.0.0.0 --port 3005",
|
||||
"start": "next start --hostname 0.0.0.0 --port ${PORT:-3000}",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
|
|
|
|||
|
|
@ -18,9 +18,18 @@ export default defineConfig({
|
|||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm start',
|
||||
command: 'node .next/standalone/server.js',
|
||||
url: 'http://127.0.0.1:3005',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
...process.env,
|
||||
HOSTNAME: process.env.HOSTNAME || '127.0.0.1',
|
||||
PORT: process.env.PORT || '3005',
|
||||
MC_DISABLE_RATE_LIMIT: process.env.MC_DISABLE_RATE_LIMIT || '1',
|
||||
API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
|
||||
AUTH_USER: process.env.AUTH_USER || 'testadmin',
|
||||
AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { NavRail } from '@/components/layout/nav-rail'
|
||||
import { HeaderBar } from '@/components/layout/header-bar'
|
||||
import { LiveFeed } from '@/components/layout/live-feed'
|
||||
|
|
@ -42,6 +42,7 @@ import { useServerEvents } from '@/lib/use-server-events'
|
|||
import { useMissionControl } from '@/store'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
const { connect } = useWebSocket()
|
||||
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
|
||||
|
||||
|
|
@ -62,7 +63,13 @@ export default function Home() {
|
|||
|
||||
// Fetch current user
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(async (res) => {
|
||||
if (res.ok) return res.json()
|
||||
if (res.status === 401) {
|
||||
router.replace(`/login?next=${encodeURIComponent(pathname)}`)
|
||||
}
|
||||
return null
|
||||
})
|
||||
.then(data => { if (data?.user) setCurrentUser(data.user) })
|
||||
.catch(() => {})
|
||||
|
||||
|
|
@ -120,7 +127,7 @@ export default function Home() {
|
|||
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
|
||||
connect(wsUrl, wsToken)
|
||||
})
|
||||
}, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
|
||||
}, [connect, pathname, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
|
||||
|
||||
if (!isClient) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth';
|
|||
import { mutationLimiter } from '@/lib/rate-limit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { validateBody, createAgentSchema } from '@/lib/validation';
|
||||
import { runOpenClaw } from '@/lib/command';
|
||||
import { config as appConfig } from '@/lib/config';
|
||||
import { resolveWithin } from '@/lib/paths';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* GET /api/agents - List all agents with optional filtering
|
||||
|
|
@ -123,6 +127,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const {
|
||||
name,
|
||||
openclaw_id,
|
||||
role,
|
||||
session_key,
|
||||
soul_content,
|
||||
|
|
@ -130,9 +135,16 @@ export async function POST(request: NextRequest) {
|
|||
config = {},
|
||||
template,
|
||||
gateway_config,
|
||||
write_to_gateway
|
||||
write_to_gateway,
|
||||
provision_openclaw_workspace,
|
||||
openclaw_workspace_path
|
||||
} = body;
|
||||
|
||||
const openclawId = (openclaw_id || name || 'agent')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
// Resolve template if specified
|
||||
let finalRole = role;
|
||||
let finalConfig: Record<string, any> = { ...config };
|
||||
|
|
@ -158,6 +170,32 @@ export async function POST(request: NextRequest) {
|
|||
if (existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
if (provision_openclaw_workspace) {
|
||||
if (!appConfig.openclawStateDir) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspacePath = openclaw_workspace_path
|
||||
? path.resolve(openclaw_workspace_path)
|
||||
: resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId));
|
||||
|
||||
try {
|
||||
await runOpenClaw(
|
||||
['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'],
|
||||
{ timeoutMs: 20000 }
|
||||
);
|
||||
} catch (provisionError: any) {
|
||||
logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed');
|
||||
return NextResponse.json(
|
||||
{ error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
|
@ -215,7 +253,6 @@ export async function POST(request: NextRequest) {
|
|||
// Write to gateway config if requested
|
||||
if (write_to_gateway && finalConfig) {
|
||||
try {
|
||||
const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-');
|
||||
await writeAgentToConfig({
|
||||
id: openclawId,
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
|
|||
import { getDatabase, logAuditEvent } from '@/lib/db'
|
||||
import { config } from '@/lib/config'
|
||||
import { heavyLimiter } from '@/lib/rate-limit'
|
||||
import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
|
||||
|
||||
interface CleanupResult {
|
||||
table: string
|
||||
|
|
@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
|
|||
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
||||
}
|
||||
|
||||
if (ret.gatewaySessions > 0) {
|
||||
preview.push({
|
||||
table: 'Gateway Session Store',
|
||||
retention_days: ret.gatewaySessions,
|
||||
stale_count: countStaleGatewaySessions(ret.gatewaySessions),
|
||||
note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
|
||||
})
|
||||
} else {
|
||||
preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
|
||||
}
|
||||
|
||||
return NextResponse.json({ retention: config.retention, preview })
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
if (ret.gatewaySessions > 0) {
|
||||
const sessionPrune = dryRun
|
||||
? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
|
||||
: pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
||||
results.push({
|
||||
table: 'Gateway Session Store',
|
||||
deleted: sessionPrune.deleted,
|
||||
cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
|
||||
retention_days: ret.gatewaySessions,
|
||||
})
|
||||
totalDeleted += sessionPrune.deleted
|
||||
}
|
||||
|
||||
if (!dryRun && totalDeleted > 0) {
|
||||
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
||||
logAuditEvent({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
|
|||
import { logger } from '@/lib/logger'
|
||||
|
||||
const MEMORY_PATH = config.memoryDir
|
||||
const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
|
||||
|
||||
// Ensure memory directory exists on startup
|
||||
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
||||
|
|
@ -24,6 +25,16 @@ interface MemoryFile {
|
|||
children?: MemoryFile[]
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value: string): string {
|
||||
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function isPathAllowed(relativePath: string): boolean {
|
||||
if (!MEMORY_ALLOWED_PREFIXES.length) return true
|
||||
const normalized = normalizeRelativePath(relativePath)
|
||||
return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
|
||||
}
|
||||
|
||||
function isWithinBase(base: string, candidate: string): boolean {
|
||||
if (candidate === base) return true
|
||||
return candidate.startsWith(base + sep)
|
||||
|
|
@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
|
|||
if (!MEMORY_PATH) {
|
||||
return NextResponse.json({ tree: [] })
|
||||
}
|
||||
if (MEMORY_ALLOWED_PREFIXES.length) {
|
||||
const tree: MemoryFile[] = []
|
||||
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
||||
const folder = prefix.replace(/\/$/, '')
|
||||
const fullPath = join(MEMORY_PATH, folder)
|
||||
if (!existsSync(fullPath)) continue
|
||||
try {
|
||||
const stats = await stat(fullPath)
|
||||
if (!stats.isDirectory()) continue
|
||||
tree.push({
|
||||
path: folder,
|
||||
name: folder,
|
||||
type: 'directory',
|
||||
modified: stats.mtime.getTime(),
|
||||
children: await buildFileTree(fullPath, folder),
|
||||
})
|
||||
} catch {
|
||||
// Skip unreadable roots
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ tree })
|
||||
}
|
||||
const tree = await buildFileTree(MEMORY_PATH)
|
||||
return NextResponse.json({ tree })
|
||||
}
|
||||
|
||||
if (action === 'content' && path) {
|
||||
// Return file content
|
||||
if (!isPathAllowed(path)) {
|
||||
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||
}
|
||||
if (!MEMORY_PATH) {
|
||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||
}
|
||||
|
|
@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
await searchDirectory(MEMORY_PATH)
|
||||
if (MEMORY_ALLOWED_PREFIXES.length) {
|
||||
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
||||
const folder = prefix.replace(/\/$/, '')
|
||||
const fullPath = join(MEMORY_PATH, folder)
|
||||
if (!existsSync(fullPath)) continue
|
||||
await searchDirectory(fullPath, folder)
|
||||
}
|
||||
} else {
|
||||
await searchDirectory(MEMORY_PATH)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
query,
|
||||
|
|
@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
|
|||
if (!path) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
if (!isPathAllowed(path)) {
|
||||
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!MEMORY_PATH) {
|
||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||
|
|
@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
|
|||
if (!path) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
if (!isPathAllowed(path)) {
|
||||
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!MEMORY_PATH) {
|
||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
|
|||
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
||||
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
||||
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
||||
'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
|
||||
|
||||
// Gateway
|
||||
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
|
||||
|
|
@ -195,29 +196,48 @@ async function getSystemStatus(workspaceId: number) {
|
|||
}
|
||||
|
||||
try {
|
||||
// System uptime
|
||||
const { stdout: uptimeOutput } = await runCommand('uptime', ['-s'], {
|
||||
timeoutMs: 3000
|
||||
})
|
||||
const bootTime = new Date(uptimeOutput.trim())
|
||||
status.uptime = Date.now() - bootTime.getTime()
|
||||
// System uptime (cross-platform)
|
||||
if (process.platform === 'darwin') {
|
||||
const { stdout } = await runCommand('sysctl', ['-n', 'kern.boottime'], {
|
||||
timeoutMs: 3000
|
||||
})
|
||||
// Output format: { sec = 1234567890, usec = 0 } ...
|
||||
const match = stdout.match(/sec\s*=\s*(\d+)/)
|
||||
if (match) {
|
||||
status.uptime = Date.now() - parseInt(match[1]) * 1000
|
||||
}
|
||||
} else {
|
||||
const { stdout } = await runCommand('uptime', ['-s'], {
|
||||
timeoutMs: 3000
|
||||
})
|
||||
const bootTime = new Date(stdout.trim())
|
||||
status.uptime = Date.now() - bootTime.getTime()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error getting uptime')
|
||||
}
|
||||
|
||||
try {
|
||||
// Memory info
|
||||
const { stdout: memOutput } = await runCommand('free', ['-m'], {
|
||||
timeoutMs: 3000
|
||||
})
|
||||
const memLines = memOutput.split('\n')
|
||||
const memLine = memLines.find(line => line.startsWith('Mem:'))
|
||||
if (memLine) {
|
||||
const parts = memLine.split(/\s+/)
|
||||
status.memory = {
|
||||
total: parseInt(parts[1]) || 0,
|
||||
used: parseInt(parts[2]) || 0,
|
||||
available: parseInt(parts[6]) || 0
|
||||
// Memory info (cross-platform)
|
||||
if (process.platform === 'darwin') {
|
||||
const totalBytes = os.totalmem()
|
||||
const freeBytes = os.freemem()
|
||||
const totalMB = Math.round(totalBytes / (1024 * 1024))
|
||||
const usedMB = Math.round((totalBytes - freeBytes) / (1024 * 1024))
|
||||
const availableMB = Math.round(freeBytes / (1024 * 1024))
|
||||
status.memory = { total: totalMB, used: usedMB, available: availableMB }
|
||||
} else {
|
||||
const { stdout: memOutput } = await runCommand('free', ['-m'], {
|
||||
timeoutMs: 3000
|
||||
})
|
||||
const memLine = memOutput.split('\n').find(line => line.startsWith('Mem:'))
|
||||
if (memLine) {
|
||||
const parts = memLine.split(/\s+/)
|
||||
status.memory = {
|
||||
total: parseInt(parts[1]) || 0,
|
||||
used: parseInt(parts[2]) || 0,
|
||||
available: parseInt(parts[6]) || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -414,14 +434,17 @@ async function performHealthCheck() {
|
|||
})
|
||||
}
|
||||
|
||||
// Check disk space
|
||||
// Check disk space (cross-platform: use df -h / and parse capacity column)
|
||||
try {
|
||||
const { stdout } = await runCommand('df', ['/', '--output=pcent'], {
|
||||
const { stdout } = await runCommand('df', ['-h', '/'], {
|
||||
timeoutMs: 3000
|
||||
})
|
||||
const lines = stdout.trim().split('\n')
|
||||
const last = lines[lines.length - 1] || ''
|
||||
const usagePercent = parseInt(last.replace('%', '').trim() || '0')
|
||||
const parts = last.split(/\s+/)
|
||||
// On macOS capacity is col 4 ("85%"), on Linux use% is col 4 as well
|
||||
const pctField = parts.find(p => p.endsWith('%')) || '0%'
|
||||
const usagePercent = parseInt(pctField.replace('%', '') || '0')
|
||||
|
||||
health.checks.push({
|
||||
name: 'Disk Space',
|
||||
|
|
@ -436,15 +459,21 @@ async function performHealthCheck() {
|
|||
})
|
||||
}
|
||||
|
||||
// Check memory usage
|
||||
// Check memory usage (cross-platform)
|
||||
try {
|
||||
const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 })
|
||||
const lines = stdout.split('\n')
|
||||
const memLine = lines.find((line) => line.startsWith('Mem:'))
|
||||
const parts = (memLine || '').split(/\s+/)
|
||||
const total = parseInt(parts[1] || '0')
|
||||
const available = parseInt(parts[6] || '0')
|
||||
const usagePercent = Math.round(((total - available) / total) * 100)
|
||||
let usagePercent: number
|
||||
if (process.platform === 'darwin') {
|
||||
const totalBytes = os.totalmem()
|
||||
const freeBytes = os.freemem()
|
||||
usagePercent = Math.round(((totalBytes - freeBytes) / totalBytes) * 100)
|
||||
} else {
|
||||
const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 })
|
||||
const memLine = stdout.split('\n').find((line) => line.startsWith('Mem:'))
|
||||
const parts = (memLine || '').split(/\s+/)
|
||||
const total = parseInt(parts[1] || '0')
|
||||
const available = parseInt(parts[6] || '0')
|
||||
usagePercent = Math.round(((total - available) / total) * 100)
|
||||
}
|
||||
|
||||
health.checks.push({
|
||||
name: 'Memory Usage',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
|
|||
import { logger } from '@/lib/logger';
|
||||
import { validateBody, updateTaskSchema } from '@/lib/validation';
|
||||
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||
import { normalizeTaskUpdateStatus } from '@/lib/task-status';
|
||||
|
||||
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
||||
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
||||
|
|
@ -115,7 +116,7 @@ export async function PUT(
|
|||
const {
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
status: requestedStatus,
|
||||
priority,
|
||||
project_id,
|
||||
assigned_to,
|
||||
|
|
@ -125,6 +126,12 @@ export async function PUT(
|
|||
tags,
|
||||
metadata
|
||||
} = body;
|
||||
const normalizedStatus = normalizeTaskUpdateStatus({
|
||||
currentStatus: currentTask.status,
|
||||
requestedStatus,
|
||||
assignedTo: assigned_to,
|
||||
assignedToProvided: assigned_to !== undefined,
|
||||
})
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const descriptionMentionResolution = description !== undefined
|
||||
|
|
@ -152,15 +159,15 @@ export async function PUT(
|
|||
fieldsToUpdate.push('description = ?');
|
||||
updateParams.push(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
|
||||
if (normalizedStatus !== undefined) {
|
||||
if (normalizedStatus === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Aegis approval is required to move task to done.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
fieldsToUpdate.push('status = ?');
|
||||
updateParams.push(status);
|
||||
updateParams.push(normalizedStatus);
|
||||
}
|
||||
if (priority !== undefined) {
|
||||
fieldsToUpdate.push('priority = ?');
|
||||
|
|
@ -240,8 +247,8 @@ export async function PUT(
|
|||
// Track changes and log activities
|
||||
const changes: string[] = [];
|
||||
|
||||
if (status && status !== currentTask.status) {
|
||||
changes.push(`status: ${currentTask.status} → ${status}`);
|
||||
if (normalizedStatus !== undefined && normalizedStatus !== currentTask.status) {
|
||||
changes.push(`status: ${currentTask.status} → ${normalizedStatus}`);
|
||||
|
||||
// Create notification for status change if assigned
|
||||
if (currentTask.assigned_to) {
|
||||
|
|
@ -249,7 +256,7 @@ export async function PUT(
|
|||
currentTask.assigned_to,
|
||||
'status_change',
|
||||
'Task Status Updated',
|
||||
`Task "${currentTask.title}" status changed to ${status}`,
|
||||
`Task "${currentTask.title}" status changed to ${normalizedStatus}`,
|
||||
'task',
|
||||
taskId,
|
||||
workspaceId
|
||||
|
|
@ -322,7 +329,7 @@ export async function PUT(
|
|||
priority: currentTask.priority,
|
||||
assigned_to: currentTask.assigned_to
|
||||
},
|
||||
newValues: { title, status, priority, assigned_to }
|
||||
newValues: { title, status: normalizedStatus ?? currentTask.status, priority, assigned_to }
|
||||
},
|
||||
workspaceId
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
|
|||
import { logger } from '@/lib/logger';
|
||||
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
|
||||
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||
import { normalizeTaskCreateStatus } from '@/lib/task-status';
|
||||
|
||||
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
||||
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
||||
|
|
@ -163,7 +164,7 @@ export async function POST(request: NextRequest) {
|
|||
const {
|
||||
title,
|
||||
description,
|
||||
status = 'inbox',
|
||||
status,
|
||||
priority = 'medium',
|
||||
project_id,
|
||||
assigned_to,
|
||||
|
|
@ -173,6 +174,7 @@ export async function POST(request: NextRequest) {
|
|||
tags = [],
|
||||
metadata = {}
|
||||
} = body;
|
||||
const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
|
||||
|
||||
// Check for duplicate title
|
||||
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
|
||||
|
|
@ -212,7 +214,7 @@ export async function POST(request: NextRequest) {
|
|||
const dbResult = insertStmt.run(
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
normalizedStatus,
|
||||
priority,
|
||||
resolvedProjectId,
|
||||
row.ticket_counter,
|
||||
|
|
@ -234,7 +236,7 @@ export async function POST(request: NextRequest) {
|
|||
// Log activity
|
||||
db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
|
||||
title,
|
||||
status,
|
||||
status: normalizedStatus,
|
||||
priority,
|
||||
assigned_to
|
||||
}, workspaceId);
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ export function Dashboard() {
|
|||
{isLocal ? (
|
||||
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
||||
) : (
|
||||
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
|
||||
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export function LiveFeed() {
|
|||
const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
|
||||
const isLocal = dashboardMode === 'local'
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [hasCollapsed, setHasCollapsed] = useState(false)
|
||||
|
||||
// Combine logs, activities, and (in local mode) session events into a unified feed
|
||||
const sessionItems = isLocal
|
||||
|
|
@ -70,7 +71,7 @@ export function LiveFeed() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right">
|
||||
<div className={`w-72 h-full bg-card border-l border-border flex flex-col shrink-0${hasCollapsed ? ' slide-in-right' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -80,7 +81,7 @@ export function LiveFeed() {
|
|||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
onClick={() => { setExpanded(false); setHasCollapsed(true) }}
|
||||
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
||||
title="Collapse feed"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -517,7 +517,7 @@ export function MemoryTab({
|
|||
<div>
|
||||
<h4 className="text-lg font-medium text-foreground">Working Memory</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files.
|
||||
This is <strong className="text-foreground">agent-level</strong> scratchpad memory (stored as WORKING.md in the database), not the workspace memory folder.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -543,6 +543,14 @@ export function MemoryTab({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
|
||||
<strong className="text-blue-200">Agent Memory vs Workspace Memory:</strong>{' '}
|
||||
This tab edits only this agent's private working memory (a scratchpad stored in the database).
|
||||
To browse or edit all workspace memory files (daily logs, knowledge base, MEMORY.md, etc.), visit the{' '}
|
||||
<Link href="/memory" className="text-blue-400 underline hover:text-blue-300">Memory Browser</Link> page.
|
||||
</div>
|
||||
|
||||
{/* Memory Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
|
|
@ -852,6 +860,7 @@ export function CreateAgentModal({
|
|||
dockerNetwork: 'none' as 'none' | 'bridge',
|
||||
session_key: '',
|
||||
write_to_gateway: true,
|
||||
provision_openclaw_workspace: true,
|
||||
})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -916,10 +925,12 @@ export function CreateAgentModal({
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
openclaw_id: formData.id || undefined,
|
||||
role: formData.role,
|
||||
session_key: formData.session_key || undefined,
|
||||
template: selectedTemplate || undefined,
|
||||
write_to_gateway: formData.write_to_gateway,
|
||||
provision_openclaw_workspace: formData.provision_openclaw_workspace,
|
||||
gateway_config: {
|
||||
model: { primary: primaryModel },
|
||||
identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
|
||||
|
|
@ -1199,6 +1210,16 @@ export function CreateAgentModal({
|
|||
/>
|
||||
<span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.provision_openclaw_workspace}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, provision_openclaw_workspace: e.target.checked }))}
|
||||
className="w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Provision full OpenClaw workspace (`openclaw agents add`)</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,14 @@ export function AgentSquadPanelPhase3() {
|
|||
setSyncToast(null)
|
||||
try {
|
||||
const response = await fetch('/api/agents/sync', { method: 'POST' })
|
||||
if (response.status === 401) {
|
||||
window.location.assign('/login?next=%2Fagents')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (response.status === 403) {
|
||||
throw new Error('Admin access required for agent sync')
|
||||
}
|
||||
if (!response.ok) throw new Error(data.error || 'Sync failed')
|
||||
setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`)
|
||||
fetchAgents()
|
||||
|
|
@ -116,7 +123,17 @@ export function AgentSquadPanelPhase3() {
|
|||
if (agents.length === 0) setLoading(true)
|
||||
|
||||
const response = await fetch('/api/agents')
|
||||
if (!response.ok) throw new Error('Failed to fetch agents')
|
||||
if (response.status === 401) {
|
||||
window.location.assign('/login?next=%2Fagents')
|
||||
return
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new Error('Access denied')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to fetch agents')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setAgents(data.agents || [])
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
|
|||
setMemoryFiles(data.tree || [])
|
||||
|
||||
// Auto-expand some common directories
|
||||
setExpandedFolders(new Set(['daily', 'knowledge']))
|
||||
setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
|
||||
} catch (error) {
|
||||
log.error('Failed to load file tree:', error)
|
||||
} finally {
|
||||
|
|
@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
|
|||
|
||||
const getFilteredFiles = () => {
|
||||
if (activeTab === 'all') return memoryFiles
|
||||
|
||||
return memoryFiles.filter(file => {
|
||||
if (activeTab === 'daily') {
|
||||
return file.name === 'daily' || file.path.includes('daily/')
|
||||
}
|
||||
if (activeTab === 'knowledge') {
|
||||
return file.name === 'knowledge' || file.path.includes('knowledge/')
|
||||
}
|
||||
return true
|
||||
|
||||
const tabPrefixes = activeTab === 'daily'
|
||||
? ['daily/', 'memory/']
|
||||
: ['knowledge/', 'knowledge-base/']
|
||||
|
||||
return memoryFiles.filter((file) => {
|
||||
const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
|
||||
return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -731,6 +730,8 @@ function CreateFileModal({
|
|||
onChange={(e) => setFilePath(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
<option value="knowledge-base/">knowledge-base/</option>
|
||||
<option value="memory/">memory/</option>
|
||||
<option value="knowledge/">knowledge/</option>
|
||||
<option value="daily/">daily/</option>
|
||||
<option value="logs/">logs/</option>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
|||
import { useMissionControl, Agent } from '@/store'
|
||||
|
||||
type ViewMode = 'office' | 'org-chart'
|
||||
type OrgSegmentMode = 'category' | 'role' | 'status'
|
||||
|
||||
interface Desk {
|
||||
agent: Agent
|
||||
|
|
@ -75,6 +76,7 @@ export function OfficePanel() {
|
|||
const { agents } = useMissionControl()
|
||||
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
||||
const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
|
|
@ -123,6 +125,64 @@ export function OfficePanel() {
|
|||
return groups
|
||||
}, [displayAgents])
|
||||
|
||||
const categoryGroups = useMemo(() => {
|
||||
const groups = new Map<string, Agent[]>()
|
||||
const getCategory = (agent: Agent): string => {
|
||||
const name = (agent.name || '').toLowerCase()
|
||||
if (name.startsWith('habi-')) return 'Habi Lanes'
|
||||
if (name.startsWith('ops-')) return 'Ops Automation'
|
||||
if (name.includes('canary')) return 'Canary'
|
||||
if (name.startsWith('main')) return 'Core'
|
||||
if (name.startsWith('remote-')) return 'Remote'
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
for (const a of displayAgents) {
|
||||
const category = getCategory(a)
|
||||
if (!groups.has(category)) groups.set(category, [])
|
||||
groups.get(category)!.push(a)
|
||||
}
|
||||
|
||||
const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
|
||||
return new Map(
|
||||
[...groups.entries()].sort(([a], [b]) => {
|
||||
const ai = order.indexOf(a)
|
||||
const bi = order.indexOf(b)
|
||||
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
||||
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
||||
if (av !== bv) return av - bv
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
)
|
||||
}, [displayAgents])
|
||||
|
||||
const statusGroups = useMemo(() => {
|
||||
const groups = new Map<string, Agent[]>()
|
||||
for (const a of displayAgents) {
|
||||
const key = statusLabel[a.status] || a.status
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(a)
|
||||
}
|
||||
|
||||
const order = ['Working', 'Available', 'Error', 'Away']
|
||||
return new Map(
|
||||
[...groups.entries()].sort(([a], [b]) => {
|
||||
const ai = order.indexOf(a)
|
||||
const bi = order.indexOf(b)
|
||||
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
||||
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
||||
if (av !== bv) return av - bv
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
)
|
||||
}, [displayAgents])
|
||||
|
||||
const orgGroups = useMemo(() => {
|
||||
if (orgSegmentMode === 'role') return roleGroups
|
||||
if (orgSegmentMode === 'status') return statusGroups
|
||||
return categoryGroups
|
||||
}, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
|
||||
|
||||
if (loading && displayAgents.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -237,11 +297,40 @@ export function OfficePanel() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{[...roleGroups.entries()].map(([role, members]) => (
|
||||
<div key={role} className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Segmented by{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex rounded-md overflow-hidden border border-border">
|
||||
<button
|
||||
onClick={() => setOrgSegmentMode('category')}
|
||||
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'category' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
||||
>
|
||||
Category
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOrgSegmentMode('role')}
|
||||
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'role' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
||||
>
|
||||
Role
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOrgSegmentMode('status')}
|
||||
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'status' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{[...orgGroups.entries()].map(([segment, members]) => (
|
||||
<div key={segment} className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-1 h-6 bg-primary rounded-full" />
|
||||
<h3 className="font-semibold text-foreground">{role}</h3>
|
||||
<h3 className="font-semibold text-foreground">{segment}</h3>
|
||||
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useMissionControl } from '@/store'
|
||||
import { useNavigateToPanel } from '@/lib/navigation'
|
||||
|
||||
interface Setting {
|
||||
key: string
|
||||
|
|
@ -24,6 +25,7 @@ const categoryOrder = ['general', 'retention', 'gateway', 'custom']
|
|||
|
||||
export function SettingsPanel() {
|
||||
const { currentUser } = useMissionControl()
|
||||
const navigateToPanel = useNavigateToPanel()
|
||||
const [settings, setSettings] = useState<Setting[]>([])
|
||||
const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -43,12 +45,17 @@ export function SettingsPanel() {
|
|||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.status === 401) {
|
||||
window.location.assign('/login?next=%2Fsettings')
|
||||
return
|
||||
}
|
||||
if (res.status === 403) {
|
||||
setError('Admin access required')
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError('Failed to load settings')
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || 'Failed to load settings')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
|
|
@ -180,6 +187,21 @@ export function SettingsPanel() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace Info */}
|
||||
{currentUser?.role === 'admin' && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
|
||||
<strong className="text-blue-200">Workspace Management:</strong>{' '}
|
||||
To create or manage workspaces (tenant instances), go to the{' '}
|
||||
<button
|
||||
onClick={() => navigateToPanel('super-admin')}
|
||||
className="text-blue-400 underline hover:text-blue-300 cursor-pointer"
|
||||
>
|
||||
Super Admin
|
||||
</button>{' '}
|
||||
panel under Admin > Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
{feedback && (
|
||||
<div className={`rounded-lg p-3 text-xs font-medium ${
|
||||
|
|
|
|||
|
|
@ -409,12 +409,20 @@ export function SuperAdminPanel() {
|
|||
Multi-tenant provisioning control plane with approval gates and safer destructive actions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCreateExpanded(true)}
|
||||
className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-smooth"
|
||||
>
|
||||
+ Add Workspace
|
||||
</button>
|
||||
<button
|
||||
onClick={load}
|
||||
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
|
|
@ -452,17 +460,21 @@ export function SuperAdminPanel() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setCreateExpanded((v) => !v)}
|
||||
className="w-full px-4 py-3 border-b border-border text-left text-sm font-medium text-foreground hover:bg-secondary/20"
|
||||
>
|
||||
{createExpanded ? 'Hide' : 'Show'} Create Client Instance
|
||||
</button>
|
||||
{createExpanded && (
|
||||
{createExpanded && (
|
||||
<div className="rounded-lg border border-primary/30 bg-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Create New Workspace</h3>
|
||||
<button
|
||||
onClick={() => setCreateExpanded(false)}
|
||||
className="text-muted-foreground hover:text-foreground text-lg leading-none transition-smooth"
|
||||
aria-label="Close create form"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Add a new workspace/client instance here. Fill the form below and click <span className="text-foreground font-medium">Create + Queue</span>.
|
||||
Fill in the workspace details below and click <span className="text-foreground font-medium">Create + Queue</span> to provision a new client instance.
|
||||
</div>
|
||||
{gatewayLoadError && (
|
||||
<div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
|
||||
|
|
@ -540,8 +552,8 @@ export function SuperAdminPanel() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ function MentionTextarea({
|
|||
className={className}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
|
||||
<div className="absolute z-[60] mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
|
||||
{filtered.map((option, index) => (
|
||||
<button
|
||||
key={`${option.type}-${option.handle}-${option.recipient}`}
|
||||
|
|
@ -770,13 +770,14 @@ function TaskDetailModal({
|
|||
onUpdate: () => void
|
||||
onEdit: (task: Task) => void
|
||||
}) {
|
||||
const { currentUser } = useMissionControl()
|
||||
const commentAuthor = currentUser?.username || 'system'
|
||||
const resolvedProjectName =
|
||||
task.project_name ||
|
||||
projects.find((project) => project.id === task.project_id)?.name
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [loadingComments, setLoadingComments] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [commentAuthor, setCommentAuthor] = useState('system')
|
||||
const [commentError, setCommentError] = useState<string | null>(null)
|
||||
const [broadcastMessage, setBroadcastMessage] = useState('')
|
||||
const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
|
||||
|
|
@ -1026,14 +1027,9 @@ function TaskDetailModal({
|
|||
)}
|
||||
|
||||
<form onSubmit={handleAddComment} className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commentAuthor}
|
||||
onChange={(e) => setCommentAuthor(e.target.value)}
|
||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Posting as</span>
|
||||
<span className="font-medium text-foreground">{commentAuthor}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
|
||||
|
|
@ -1056,18 +1052,25 @@ function TaskDetailModal({
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 bg-blue-500/5 border border-blue-500/15 rounded-lg p-3 text-xs text-muted-foreground space-y-1">
|
||||
<div className="font-medium text-blue-300">How notifications work</div>
|
||||
<div><strong className="text-foreground">Comments</strong> are persisted on the task and notify all subscribers. Subscribers are auto-added when they: create the task, are assigned to it, comment on it, or are @mentioned.</div>
|
||||
<div><strong className="text-foreground">Broadcasts</strong> send a one-time notification to all current subscribers without creating a comment record.</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
<h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
|
||||
{broadcastStatus && (
|
||||
<div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
|
||||
)}
|
||||
<form onSubmit={handleBroadcast} className="space-y-2">
|
||||
<textarea
|
||||
<MentionTextarea
|
||||
value={broadcastMessage}
|
||||
onChange={(e) => setBroadcastMessage(e.target.value)}
|
||||
onChange={setBroadcastMessage}
|
||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
rows={2}
|
||||
placeholder="Send a message to all task subscribers..."
|
||||
placeholder="Send a message to all task subscribers... (use @ to mention)"
|
||||
mentionTargets={mentionTargets}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeTaskCreateStatus, normalizeTaskUpdateStatus } from '../task-status'
|
||||
|
||||
describe('task status normalization', () => {
|
||||
it('sets assigned status on create when assignee is present', () => {
|
||||
expect(normalizeTaskCreateStatus(undefined, 'main')).toBe('assigned')
|
||||
expect(normalizeTaskCreateStatus('inbox', 'main')).toBe('assigned')
|
||||
})
|
||||
|
||||
it('keeps explicit non-inbox status on create', () => {
|
||||
expect(normalizeTaskCreateStatus('in_progress', 'main')).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('auto-promotes inbox to assigned when assignment is added via update', () => {
|
||||
expect(
|
||||
normalizeTaskUpdateStatus({
|
||||
currentStatus: 'inbox',
|
||||
requestedStatus: undefined,
|
||||
assignedTo: 'main',
|
||||
assignedToProvided: true,
|
||||
})
|
||||
).toBe('assigned')
|
||||
})
|
||||
|
||||
it('auto-demotes assigned to inbox when assignment is removed via update', () => {
|
||||
expect(
|
||||
normalizeTaskUpdateStatus({
|
||||
currentStatus: 'assigned',
|
||||
requestedStatus: undefined,
|
||||
assignedTo: '',
|
||||
assignedToProvided: true,
|
||||
})
|
||||
).toBe('inbox')
|
||||
})
|
||||
|
||||
it('does not override explicit status changes on update', () => {
|
||||
expect(
|
||||
normalizeTaskUpdateStatus({
|
||||
currentStatus: 'inbox',
|
||||
requestedStatus: 'in_progress',
|
||||
assignedTo: 'main',
|
||||
assignedToProvided: true,
|
||||
})
|
||||
).toBe('in_progress')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -21,6 +21,23 @@ const openclawStateDir =
|
|||
const openclawConfigPath =
|
||||
explicitOpenClawConfigPath ||
|
||||
path.join(openclawStateDir, 'openclaw.json')
|
||||
const openclawWorkspaceDir =
|
||||
process.env.OPENCLAW_WORKSPACE_DIR ||
|
||||
process.env.MISSION_CONTROL_WORKSPACE_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
|
||||
const defaultMemoryDir = (() => {
|
||||
if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
|
||||
// Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
|
||||
// when available; fallback to legacy sqlite memory path.
|
||||
if (
|
||||
openclawWorkspaceDir &&
|
||||
(fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
|
||||
fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
|
||||
) {
|
||||
return openclawWorkspaceDir
|
||||
}
|
||||
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
|
||||
})()
|
||||
|
||||
export const config = {
|
||||
claudeHome:
|
||||
|
|
@ -45,10 +62,11 @@ export const config = {
|
|||
process.env.OPENCLAW_LOG_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
||||
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
||||
memoryDir:
|
||||
process.env.OPENCLAW_MEMORY_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
|
||||
path.join(defaultDataDir, 'memory'),
|
||||
memoryDir: defaultMemoryDir,
|
||||
memoryAllowedPrefixes:
|
||||
defaultMemoryDir === openclawWorkspaceDir
|
||||
? ['memory/', 'knowledge-base/']
|
||||
: [],
|
||||
soulTemplatesDir:
|
||||
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
||||
|
|
@ -61,6 +79,7 @@ export const config = {
|
|||
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
||||
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
||||
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
||||
gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
|
|||
import { logger } from './logger'
|
||||
import { processWebhookRetries } from './webhooks'
|
||||
import { syncClaudeSessions } from './claude-sessions'
|
||||
import { pruneGatewaySessionsOlderThan } from './sessions'
|
||||
|
||||
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
||||
|
||||
|
|
@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
|
|||
}
|
||||
}
|
||||
|
||||
if (ret.gatewaySessions > 0) {
|
||||
const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
||||
totalDeleted += sessionCleanup.deleted
|
||||
}
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
logAuditEvent({
|
||||
action: 'auto_cleanup',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,32 @@ export interface GatewaySession {
|
|||
active: boolean
|
||||
}
|
||||
|
||||
function getGatewaySessionStoreFiles(): string[] {
|
||||
const openclawStateDir = config.openclawStateDir
|
||||
if (!openclawStateDir) return []
|
||||
|
||||
const agentsDir = path.join(openclawStateDir, 'agents')
|
||||
if (!fs.existsSync(agentsDir)) return []
|
||||
|
||||
let agentDirs: string[]
|
||||
try {
|
||||
agentDirs = fs.readdirSync(agentsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
for (const agentName of agentDirs) {
|
||||
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
||||
try {
|
||||
if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
|
||||
} catch {
|
||||
// Skip missing or unreadable session stores.
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all sessions from OpenClaw agent session stores on disk.
|
||||
*
|
||||
|
|
@ -29,26 +55,11 @@ export interface GatewaySession {
|
|||
* with session metadata as values.
|
||||
*/
|
||||
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
||||
const openclawStateDir = config.openclawStateDir
|
||||
if (!openclawStateDir) return []
|
||||
|
||||
const agentsDir = path.join(openclawStateDir, 'agents')
|
||||
if (!fs.existsSync(agentsDir)) return []
|
||||
|
||||
const sessions: GatewaySession[] = []
|
||||
const now = Date.now()
|
||||
|
||||
let agentDirs: string[]
|
||||
try {
|
||||
agentDirs = fs.readdirSync(agentsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const agentName of agentDirs) {
|
||||
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
||||
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||
const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
|
||||
try {
|
||||
if (!fs.statSync(sessionsFile).isFile()) continue
|
||||
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
|
||||
|
|
@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|||
return sessions
|
||||
}
|
||||
|
||||
export function countStaleGatewaySessions(retentionDays: number): number {
|
||||
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
|
||||
const cutoff = Date.now() - retentionDays * 86400000
|
||||
let stale = 0
|
||||
|
||||
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||
try {
|
||||
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||
const data = JSON.parse(raw) as Record<string, any>
|
||||
for (const entry of Object.values(data)) {
|
||||
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
||||
if (updatedAt > 0 && updatedAt < cutoff) stale += 1
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed session stores.
|
||||
}
|
||||
}
|
||||
|
||||
return stale
|
||||
}
|
||||
|
||||
export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
|
||||
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
|
||||
const cutoff = Date.now() - retentionDays * 86400000
|
||||
let deleted = 0
|
||||
let filesTouched = 0
|
||||
|
||||
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||
try {
|
||||
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||
const data = JSON.parse(raw) as Record<string, any>
|
||||
const nextEntries: Record<string, any> = {}
|
||||
let fileDeleted = 0
|
||||
|
||||
for (const [key, entry] of Object.entries(data)) {
|
||||
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
||||
if (updatedAt > 0 && updatedAt < cutoff) {
|
||||
fileDeleted += 1
|
||||
continue
|
||||
}
|
||||
nextEntries[key] = entry
|
||||
}
|
||||
|
||||
if (fileDeleted > 0) {
|
||||
const tempPath = `${sessionsFile}.tmp`
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
|
||||
fs.renameSync(tempPath, sessionsFile)
|
||||
deleted += fileDeleted
|
||||
filesTouched += 1
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed/unwritable session stores.
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted, filesTouched }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive agent active/idle/offline status from their sessions.
|
||||
* Returns a map of agentName -> { status, lastActivity, channel }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import type { Task } from './db'
|
||||
|
||||
export type TaskStatus = Task['status']
|
||||
|
||||
function hasAssignee(assignedTo: string | null | undefined): boolean {
|
||||
return Boolean(assignedTo && assignedTo.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep task state coherent when a task is created with an assignee.
|
||||
* If caller asks for `inbox` but also sets `assigned_to`, normalize to `assigned`.
|
||||
*/
|
||||
export function normalizeTaskCreateStatus(
|
||||
requestedStatus: TaskStatus | undefined,
|
||||
assignedTo: string | undefined
|
||||
): TaskStatus {
|
||||
const status = requestedStatus ?? 'inbox'
|
||||
if (status === 'inbox' && hasAssignee(assignedTo)) return 'assigned'
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-adjust status for assignment-only updates when caller does not
|
||||
* explicitly request a status transition.
|
||||
*/
|
||||
export function normalizeTaskUpdateStatus(args: {
|
||||
currentStatus: TaskStatus
|
||||
requestedStatus: TaskStatus | undefined
|
||||
assignedTo: string | null | undefined
|
||||
assignedToProvided: boolean
|
||||
}): TaskStatus | undefined {
|
||||
const { currentStatus, requestedStatus, assignedTo, assignedToProvided } = args
|
||||
if (requestedStatus !== undefined) return requestedStatus
|
||||
if (!assignedToProvided) return undefined
|
||||
|
||||
if (hasAssignee(assignedTo) && currentStatus === 'inbox') return 'assigned'
|
||||
if (!hasAssignee(assignedTo) && currentStatus === 'assigned') return 'inbox'
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial()
|
|||
|
||||
export const createAgentSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(),
|
||||
role: z.string().min(1, 'Role is required').max(100).optional(),
|
||||
session_key: z.string().max(200).optional(),
|
||||
soul_content: z.string().max(50000).optional(),
|
||||
|
|
@ -53,6 +54,8 @@ export const createAgentSchema = z.object({
|
|||
template: z.string().max(100).optional(),
|
||||
gateway_config: z.record(z.string(), z.unknown()).optional(),
|
||||
write_to_gateway: z.boolean().optional(),
|
||||
provision_openclaw_workspace: z.boolean().optional(),
|
||||
openclaw_workspace_path: z.string().min(1).max(500).optional(),
|
||||
})
|
||||
|
||||
export const bulkUpdateTaskStatusSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const log = createClientLogger('WebSocket')
|
|||
|
||||
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
||||
const PROTOCOL_VERSION = 3
|
||||
const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui'
|
||||
const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'openclaw-control-ui'
|
||||
|
||||
// Heartbeat configuration
|
||||
const PING_INTERVAL_MS = 30_000
|
||||
|
|
@ -59,6 +59,8 @@ export function useWebSocket() {
|
|||
const pingCounterRef = useRef<number>(0)
|
||||
const pingSentTimestamps = useRef<Map<string, number>>(new Map())
|
||||
const missedPongsRef = useRef<number>(0)
|
||||
// Compat flag for gateway versions that may not implement ping RPC.
|
||||
const gatewaySupportsPingRef = useRef<boolean>(true)
|
||||
|
||||
const {
|
||||
connection,
|
||||
|
|
@ -116,6 +118,7 @@ export function useWebSocket() {
|
|||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return
|
||||
if (!gatewaySupportsPingRef.current) return
|
||||
|
||||
// Check missed pongs
|
||||
if (missedPongsRef.current >= MAX_MISSED_PONGS) {
|
||||
|
|
@ -358,6 +361,13 @@ export function useWebSocket() {
|
|||
|
||||
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
||||
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
||||
const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
|
||||
if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
|
||||
gatewaySupportsPingRef.current = false
|
||||
missedPongsRef.current = 0
|
||||
pingSentTimestamps.current.clear()
|
||||
log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
|
||||
}
|
||||
handlePong(frame.id)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useMissionControl } from '@/store'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function LiveFeed() {
|
||||
const { logs, sessions, activities, connection, toggleLiveFeed } = useMissionControl()
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
|
||||
// Combine logs and activities into a unified feed
|
||||
const feedItems = [
|
||||
...logs.slice(0, 30).map(log => ({
|
||||
id: log.id,
|
||||
type: 'log' as const,
|
||||
level: log.level,
|
||||
message: log.message,
|
||||
source: log.source,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
...activities.slice(0, 20).map(act => ({
|
||||
id: `act-${act.id}`,
|
||||
type: 'activity' as const,
|
||||
level: 'info' as const,
|
||||
message: act.description,
|
||||
source: act.actor,
|
||||
timestamp: act.created_at * 1000,
|
||||
})),
|
||||
].sort((a, b) => b.timestamp - a.timestamp).slice(0, 40)
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className="w-10 bg-card border-l border-border flex flex-col items-center py-3 shrink-0">
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
||||
title="Show live feed"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Mini indicators */}
|
||||
<div className="mt-4 flex flex-col gap-2 items-center">
|
||||
{feedItems.slice(0, 5).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
item.level === 'error' ? 'bg-red-500' :
|
||||
item.level === 'warn' ? 'bg-amber-500' :
|
||||
'bg-blue-500/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right">
|
||||
{/* Header */}
|
||||
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 pulse-dot" />
|
||||
<span className="text-xs font-semibold text-foreground">Live Feed</span>
|
||||
<span className="text-2xs text-muted-foreground font-mono-tight">{feedItems.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
||||
title="Collapse feed"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 3l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleLiveFeed}
|
||||
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
||||
title="Close feed"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{feedItems.map((item) => (
|
||||
<FeedItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active sessions mini-list */}
|
||||
<div className="border-t border-border px-3 py-2 shrink-0">
|
||||
<div className="text-2xs font-medium text-muted-foreground mb-1.5">Active Sessions</div>
|
||||
<div className="space-y-1">
|
||||
{sessions.filter(s => s.active).slice(0, 4).map(session => (
|
||||
<div key={session.id} className="flex items-center gap-1.5 text-2xs">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-foreground truncate flex-1 font-mono-tight">{session.key || session.id}</span>
|
||||
<span className="text-muted-foreground">{session.model?.split('/').pop()?.slice(0, 8)}</span>
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter(s => s.active).length === 0 && (
|
||||
<div className="text-2xs text-muted-foreground">No active sessions</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({ item }: { item: { id: string; type: string; level: string; message: string; source: string; timestamp: number } }) {
|
||||
const levelIndicator = item.level === 'error'
|
||||
? 'bg-red-500'
|
||||
: item.level === 'warn'
|
||||
? 'bg-amber-500'
|
||||
: item.level === 'debug'
|
||||
? 'bg-gray-500'
|
||||
: 'bg-blue-500/50'
|
||||
|
||||
const timeStr = formatRelativeTime(item.timestamp)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 hover:bg-secondary/50 transition-smooth group">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${levelIndicator}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-foreground/90 leading-relaxed break-words">
|
||||
{item.message.length > 120 ? item.message.slice(0, 120) + '...' : item.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-2xs text-muted-foreground font-mono-tight">{item.source}</span>
|
||||
<span className="text-2xs text-muted-foreground/50">·</span>
|
||||
<span className="text-2xs text-muted-foreground">{timeStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60_000) return 'now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
|
||||
return `${Math.floor(diff / 86_400_000)}d`
|
||||
}
|
||||
166
src/page.tsx
166
src/page.tsx
|
|
@ -1,166 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavRail } from '@/components/layout/nav-rail'
|
||||
import { HeaderBar } from '@/components/layout/header-bar'
|
||||
import { LiveFeed } from '@/components/layout/live-feed'
|
||||
import { Dashboard } from '@/components/dashboard/dashboard'
|
||||
import { AgentSpawnPanel } from '@/components/panels/agent-spawn-panel'
|
||||
import { LogViewerPanel } from '@/components/panels/log-viewer-panel'
|
||||
import { CronManagementPanel } from '@/components/panels/cron-management-panel'
|
||||
import { MemoryBrowserPanel } from '@/components/panels/memory-browser-panel'
|
||||
import { TokenDashboardPanel } from '@/components/panels/token-dashboard-panel'
|
||||
import { SessionDetailsPanel } from '@/components/panels/session-details-panel'
|
||||
import { TaskBoardPanel } from '@/components/panels/task-board-panel'
|
||||
import { ActivityFeedPanel } from '@/components/panels/activity-feed-panel'
|
||||
import { AgentSquadPanelPhase3 } from '@/components/panels/agent-squad-panel-phase3'
|
||||
import { StandupPanel } from '@/components/panels/standup-panel'
|
||||
import { OrchestrationBar } from '@/components/panels/orchestration-bar'
|
||||
import { NotificationsPanel } from '@/components/panels/notifications-panel'
|
||||
import { UserManagementPanel } from '@/components/panels/user-management-panel'
|
||||
import { AuditTrailPanel } from '@/components/panels/audit-trail-panel'
|
||||
import { AgentHistoryPanel } from '@/components/panels/agent-history-panel'
|
||||
import { WebhookPanel } from '@/components/panels/webhook-panel'
|
||||
import { SettingsPanel } from '@/components/panels/settings-panel'
|
||||
import { GatewayConfigPanel } from '@/components/panels/gateway-config-panel'
|
||||
import { IntegrationsPanel } from '@/components/panels/integrations-panel'
|
||||
import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
|
||||
import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
|
||||
import { ChatPanel } from '@/components/chat/chat-panel'
|
||||
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()
|
||||
|
||||
// Connect to SSE for real-time local DB events (tasks, agents, chat, etc.)
|
||||
useServerEvents()
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
|
||||
// Fetch current user
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.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 gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
|
||||
const gatewayHost = window.location.hostname
|
||||
const wsUrl = `ws://${gatewayHost}:${gatewayPort}`
|
||||
connect(wsUrl, wsToken)
|
||||
}, [connect, setCurrentUser])
|
||||
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-sm">MC</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-sm text-muted-foreground">Loading Mission Control...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
|
||||
<NavRail />
|
||||
|
||||
{/* Center: Header + Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<HeaderBar />
|
||||
<main className="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
<ContentRouter tab={activeTab} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Right: Live feed (hidden on mobile) */}
|
||||
{liveFeedOpen && (
|
||||
<div className="hidden lg:flex h-full">
|
||||
<LiveFeed />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating button to reopen LiveFeed when closed */}
|
||||
{!liveFeedOpen && (
|
||||
<button
|
||||
onClick={toggleLiveFeed}
|
||||
className="hidden lg:flex fixed right-0 top-1/2 -translate-y-1/2 z-30 w-6 h-12 items-center justify-center bg-card border border-r-0 border-border rounded-l-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-all duration-200"
|
||||
title="Show live feed"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Chat panel overlay */}
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentRouter({ tab }: { tab: string }) {
|
||||
switch (tab) {
|
||||
case 'overview':
|
||||
return <Dashboard />
|
||||
case 'tasks':
|
||||
return <TaskBoardPanel />
|
||||
case 'agents':
|
||||
return (
|
||||
<>
|
||||
<OrchestrationBar />
|
||||
<AgentSquadPanelPhase3 />
|
||||
</>
|
||||
)
|
||||
case 'activity':
|
||||
return <ActivityFeedPanel />
|
||||
case 'notifications':
|
||||
return <NotificationsPanel />
|
||||
case 'standup':
|
||||
return <StandupPanel />
|
||||
case 'spawn':
|
||||
return <AgentSpawnPanel />
|
||||
case 'sessions':
|
||||
return <SessionDetailsPanel />
|
||||
case 'logs':
|
||||
return <LogViewerPanel />
|
||||
case 'cron':
|
||||
return <CronManagementPanel />
|
||||
case 'memory':
|
||||
return <MemoryBrowserPanel />
|
||||
case 'tokens':
|
||||
return <TokenDashboardPanel />
|
||||
case 'users':
|
||||
return <UserManagementPanel />
|
||||
case 'history':
|
||||
return <AgentHistoryPanel />
|
||||
case 'audit':
|
||||
return <AuditTrailPanel />
|
||||
case 'webhooks':
|
||||
return <WebhookPanel />
|
||||
case 'alerts':
|
||||
return <AlertRulesPanel />
|
||||
case 'gateways':
|
||||
return <MultiGatewayPanel />
|
||||
case 'gateway-config':
|
||||
return <GatewayConfigPanel />
|
||||
case 'integrations':
|
||||
return <IntegrationsPanel />
|
||||
case 'settings':
|
||||
return <SettingsPanel />
|
||||
default:
|
||||
return <Dashboard />
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue