diff --git a/.env.example b/.env.example
index 9da5f96..23e8941 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/Dockerfile b/Dockerfile
index b8915af..792ef4a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index 7e5dfc8..3da8b94 100644
--- a/README.md
+++ b/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.
+
+Super Admin (Workspace/Tenant Management)
+
+| 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 |
+
+
+
Direct CLI
@@ -444,6 +466,24 @@ Runtime-tunable thresholds:
- `MC_WORKLOAD_ERROR_RATE_SHED`
- `MC_WORKLOAD_RECENT_WINDOW_SECONDS`
+## Agent Diagnostics Contract
+
+`GET /api/agents/{id}/diagnostics` is self-scoped by default.
+
+- Self access:
+ - Session user where `username === agent.name`, or
+ - API-key request with `x-agent-name` matching `{id}` agent name
+- Cross-agent access:
+ - Allowed only with explicit `?privileged=1` and admin auth
+- Query validation:
+ - `hours` must be an integer between `1` and `720`
+ - `section` must be a comma-separated subset of `summary,tasks,errors,activity,trends,tokens`
+
+Trend alerts in the `trends.alerts` response are derived from current-vs-previous window comparisons:
+
+- `warning`: error spikes or severe activity drop
+- `info`: throughput drops or potential stall patterns
+
## Roadmap
See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped.
diff --git a/docker-compose.yml b/docker-compose.yml
index 9a16156..7ae529a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/package.json b/package.json
index 25e8136..91ec7a0 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx
index 0b0dcaa..e7d05dd 100644
--- a/src/app/[[...panel]]/page.tsx
+++ b/src/app/[[...panel]]/page.tsx
@@ -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 (
diff --git a/src/app/api/agents/[id]/diagnostics/route.ts b/src/app/api/agents/[id]/diagnostics/route.ts
new file mode 100644
index 0000000..4810843
--- /dev/null
+++ b/src/app/api/agents/[id]/diagnostics/route.ts
@@ -0,0 +1,343 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getDatabase } from '@/lib/db';
+import { requireRole } from '@/lib/auth';
+import { logger } from '@/lib/logger';
+
+const ALLOWED_SECTIONS = ['summary', 'tasks', 'errors', 'activity', 'trends', 'tokens'] as const;
+type DiagnosticsSection = (typeof ALLOWED_SECTIONS)[number];
+
+function parseHoursParam(raw: string | null): { value?: number; error?: string } {
+ if (raw === null) return { value: 24 };
+ const parsed = Number(raw);
+ if (!Number.isInteger(parsed)) {
+ return { error: 'hours must be an integer between 1 and 720' };
+ }
+ if (parsed < 1 || parsed > 720) {
+ return { error: 'hours must be between 1 and 720' };
+ }
+ return { value: parsed };
+}
+
+function parseSectionsParam(raw: string | null): { value?: Set; error?: string } {
+ if (!raw || raw.trim().length === 0) {
+ return { value: new Set(ALLOWED_SECTIONS) };
+ }
+
+ const requested = raw
+ .split(',')
+ .map((section) => section.trim())
+ .filter(Boolean);
+
+ if (requested.length === 0) {
+ return { error: 'section must include at least one valid value' };
+ }
+
+ const invalid = requested.filter((section) => !ALLOWED_SECTIONS.includes(section as DiagnosticsSection));
+ if (invalid.length > 0) {
+ return { error: `Invalid section value(s): ${invalid.join(', ')}` };
+ }
+
+ return { value: new Set(requested as DiagnosticsSection[]) };
+}
+
+/**
+ * GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API
+ *
+ * Provides an agent with its own performance metrics, error analysis,
+ * and trend data so it can self-optimize.
+ *
+ * Query params:
+ * hours - Time window in hours (default: 24, max: 720 = 30 days)
+ * section - Comma-separated sections to include (default: all)
+ * Options: summary, tasks, errors, activity, trends, tokens
+ *
+ * Response includes:
+ * summary - High-level KPIs (throughput, error rate, activity count)
+ * tasks - Task completion breakdown by status and priority
+ * errors - Error frequency, types, and recent error details
+ * activity - Activity breakdown by type with hourly timeline
+ * trends - Multi-period comparison for trend detection
+ * tokens - Token usage by model with cost estimates
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const auth = requireRole(request, 'viewer');
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
+ try {
+ const db = getDatabase();
+ const resolvedParams = await params;
+ const agentId = resolvedParams.id;
+ const workspaceId = auth.user.workspace_id ?? 1;
+
+ // Resolve agent by ID or name
+ let agent: any;
+ if (/^\d+$/.test(agentId)) {
+ agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
+ } else {
+ agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
+ }
+
+ if (!agent) {
+ return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const requesterAgentName = (request.headers.get('x-agent-name') || '').trim();
+ const privileged = searchParams.get('privileged') === '1';
+ const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name;
+
+ // Self-only by default. Cross-agent access requires explicit privileged override.
+ if (!isSelfRequest && !(privileged && auth.user.role === 'admin')) {
+ return NextResponse.json(
+ { error: 'Diagnostics are self-scoped. Use privileged=1 with admin role for cross-agent access.' },
+ { status: 403 }
+ );
+ }
+
+ const parsedHours = parseHoursParam(searchParams.get('hours'));
+ if (parsedHours.error) {
+ return NextResponse.json({ error: parsedHours.error }, { status: 400 });
+ }
+
+ const parsedSections = parseSectionsParam(searchParams.get('section'));
+ if (parsedSections.error) {
+ return NextResponse.json({ error: parsedSections.error }, { status: 400 });
+ }
+
+ const hours = parsedHours.value as number;
+ const sections = parsedSections.value as Set;
+
+ const now = Math.floor(Date.now() / 1000);
+ const since = now - hours * 3600;
+
+ const result: Record = {
+ agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
+ timeframe: { hours, since, until: now },
+ };
+
+ if (sections.has('summary')) {
+ result.summary = buildSummary(db, agent.name, workspaceId, since);
+ }
+
+ if (sections.has('tasks')) {
+ result.tasks = buildTaskMetrics(db, agent.name, workspaceId, since);
+ }
+
+ if (sections.has('errors')) {
+ result.errors = buildErrorAnalysis(db, agent.name, workspaceId, since);
+ }
+
+ if (sections.has('activity')) {
+ result.activity = buildActivityBreakdown(db, agent.name, workspaceId, since);
+ }
+
+ if (sections.has('trends')) {
+ result.trends = buildTrends(db, agent.name, workspaceId, hours);
+ }
+
+ if (sections.has('tokens')) {
+ result.tokens = buildTokenMetrics(db, agent.name, workspaceId, since);
+ }
+
+ return NextResponse.json(result);
+ } catch (error) {
+ logger.error({ err: error }, 'GET /api/agents/[id]/diagnostics error');
+ return NextResponse.json({ error: 'Failed to fetch diagnostics' }, { status: 500 });
+ }
+}
+
+/** High-level KPIs */
+function buildSummary(db: any, agentName: string, workspaceId: number, since: number) {
+ const tasksDone = (db.prepare(
+ `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ?`
+ ).get(agentName, workspaceId, since) as any).c;
+
+ const tasksTotal = (db.prepare(
+ `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ?`
+ ).get(agentName, workspaceId) as any).c;
+
+ const activityCount = (db.prepare(
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ?`
+ ).get(agentName, workspaceId, since) as any).c;
+
+ const errorCount = (db.prepare(
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND type LIKE '%error%'`
+ ).get(agentName, workspaceId, since) as any).c;
+
+ const errorRate = activityCount > 0 ? Math.round((errorCount / activityCount) * 10000) / 100 : 0;
+
+ return {
+ tasks_completed: tasksDone,
+ tasks_total: tasksTotal,
+ activity_count: activityCount,
+ error_count: errorCount,
+ error_rate_percent: errorRate,
+ };
+}
+
+/** Task completion breakdown */
+function buildTaskMetrics(db: any, agentName: string, workspaceId: number, since: number) {
+ const byStatus = db.prepare(
+ `SELECT status, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY status`
+ ).all(agentName, workspaceId) as Array<{ status: string; count: number }>;
+
+ const byPriority = db.prepare(
+ `SELECT priority, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY priority`
+ ).all(agentName, workspaceId) as Array<{ priority: string; count: number }>;
+
+ const recentCompleted = db.prepare(
+ `SELECT id, title, priority, updated_at FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 10`
+ ).all(agentName, workspaceId, since) as any[];
+
+ // Estimate throughput: tasks completed per day in the window
+ const windowDays = Math.max((Math.floor(Date.now() / 1000) - since) / 86400, 1);
+ const completedInWindow = recentCompleted.length;
+ const throughputPerDay = Math.round((completedInWindow / windowDays) * 100) / 100;
+
+ return {
+ by_status: Object.fromEntries(byStatus.map(r => [r.status, r.count])),
+ by_priority: Object.fromEntries(byPriority.map(r => [r.priority, r.count])),
+ recent_completed: recentCompleted,
+ throughput_per_day: throughputPerDay,
+ };
+}
+
+/** Error frequency and analysis */
+function buildErrorAnalysis(db: any, agentName: string, workspaceId: number, since: number) {
+ const errorActivities = db.prepare(
+ `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') GROUP BY type ORDER BY count DESC`
+ ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>;
+
+ const recentErrors = db.prepare(
+ `SELECT id, type, description, data, created_at FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') ORDER BY created_at DESC LIMIT 20`
+ ).all(agentName, workspaceId, since) as any[];
+
+ return {
+ by_type: errorActivities,
+ total: errorActivities.reduce((sum, e) => sum + e.count, 0),
+ recent: recentErrors.map(e => ({
+ ...e,
+ data: e.data ? JSON.parse(e.data) : null,
+ })),
+ };
+}
+
+/** Activity breakdown with hourly timeline */
+function buildActivityBreakdown(db: any, agentName: string, workspaceId: number, since: number) {
+ const byType = db.prepare(
+ `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY type ORDER BY count DESC`
+ ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>;
+
+ const timeline = db.prepare(
+ `SELECT (created_at / 3600) * 3600 as hour_bucket, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY hour_bucket ORDER BY hour_bucket ASC`
+ ).all(agentName, workspaceId, since) as Array<{ hour_bucket: number; count: number }>;
+
+ return {
+ by_type: byType,
+ timeline: timeline.map(t => ({
+ timestamp: t.hour_bucket,
+ hour: new Date(t.hour_bucket * 1000).toISOString(),
+ count: t.count,
+ })),
+ };
+}
+
+/** Multi-period trend comparison for anomaly/trend detection */
+function buildTrends(db: any, agentName: string, workspaceId: number, hours: number) {
+ const now = Math.floor(Date.now() / 1000);
+
+ // Compare current period vs previous period of same length
+ const currentSince = now - hours * 3600;
+ const previousSince = currentSince - hours * 3600;
+
+ const periodMetrics = (since: number, until: number) => {
+ const activities = (db.prepare(
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ?`
+ ).get(agentName, workspaceId, since, until) as any).c;
+
+ const errors = (db.prepare(
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ? AND (type LIKE '%error%' OR type LIKE '%fail%')`
+ ).get(agentName, workspaceId, since, until) as any).c;
+
+ const tasksCompleted = (db.prepare(
+ `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? AND updated_at < ?`
+ ).get(agentName, workspaceId, since, until) as any).c;
+
+ return { activities, errors, tasks_completed: tasksCompleted };
+ };
+
+ const current = periodMetrics(currentSince, now);
+ const previous = periodMetrics(previousSince, currentSince);
+
+ const pctChange = (cur: number, prev: number) => {
+ if (prev === 0) return cur > 0 ? 100 : 0;
+ return Math.round(((cur - prev) / prev) * 10000) / 100;
+ };
+
+ return {
+ current_period: { since: currentSince, until: now, ...current },
+ previous_period: { since: previousSince, until: currentSince, ...previous },
+ change: {
+ activities_pct: pctChange(current.activities, previous.activities),
+ errors_pct: pctChange(current.errors, previous.errors),
+ tasks_completed_pct: pctChange(current.tasks_completed, previous.tasks_completed),
+ },
+ alerts: buildTrendAlerts(current, previous),
+ };
+}
+
+/** Generate automatic alerts from trend data */
+function buildTrendAlerts(current: { activities: number; errors: number; tasks_completed: number }, previous: { activities: number; errors: number; tasks_completed: number }) {
+ const alerts: Array<{ level: string; message: string }> = [];
+
+ // Error rate spike
+ if (current.errors > 0 && previous.errors > 0) {
+ const errorIncrease = (current.errors - previous.errors) / previous.errors;
+ if (errorIncrease > 0.5) {
+ alerts.push({ level: 'warning', message: `Error count increased ${Math.round(errorIncrease * 100)}% vs previous period` });
+ }
+ } else if (current.errors > 3 && previous.errors === 0) {
+ alerts.push({ level: 'warning', message: `New error pattern: ${current.errors} errors (none in previous period)` });
+ }
+
+ // Throughput drop
+ if (previous.tasks_completed > 0 && current.tasks_completed === 0) {
+ alerts.push({ level: 'info', message: 'No tasks completed in current period (possible stall)' });
+ } else if (previous.tasks_completed > 2 && current.tasks_completed < previous.tasks_completed * 0.5) {
+ alerts.push({ level: 'info', message: `Task throughput dropped ${Math.round((1 - current.tasks_completed / previous.tasks_completed) * 100)}%` });
+ }
+
+ // Activity drop (possible offline)
+ if (previous.activities > 5 && current.activities < previous.activities * 0.25) {
+ alerts.push({ level: 'warning', message: `Activity dropped ${Math.round((1 - current.activities / previous.activities) * 100)}% — agent may be stalled` });
+ }
+
+ return alerts;
+}
+
+/** Token usage by model */
+function buildTokenMetrics(db: any, agentName: string, workspaceId: number, since: number) {
+ try {
+ // session_id on token_usage may store agent name or session key
+ const byModel = db.prepare(
+ `SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COUNT(*) as request_count FROM token_usage WHERE session_id = ? AND workspace_id = ? AND created_at >= ? GROUP BY model ORDER BY (input_tokens + output_tokens) DESC`
+ ).all(agentName, workspaceId, since) as Array<{ model: string; input_tokens: number; output_tokens: number; request_count: number }>;
+
+ const total = byModel.reduce((acc, r) => ({
+ input_tokens: acc.input_tokens + r.input_tokens,
+ output_tokens: acc.output_tokens + r.output_tokens,
+ requests: acc.requests + r.request_count,
+ }), { input_tokens: 0, output_tokens: 0, requests: 0 });
+
+ return {
+ by_model: byModel,
+ total,
+ };
+ } catch {
+ // token_usage table may not exist
+ return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 } };
+ }
+}
diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts
index 3f835f0..1f52638 100644
--- a/src/app/api/agents/route.ts
+++ b/src/app/api/agents/route.ts
@@ -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 = { ...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,
diff --git a/src/app/api/cleanup/route.ts b/src/app/api/cleanup/route.ts
index c590ea3..3cd13fc 100644
--- a/src/app/api/cleanup/route.ts
+++ b/src/app/api/cleanup/route.ts
@@ -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({
diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts
index 6615d35..a1aa609 100644
--- a/src/app/api/memory/route.ts
+++ b/src/app/api/memory/route.ts
@@ -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 })
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
index a6be8d4..d88130f 100644
--- a/src/app/api/settings/route.ts
+++ b/src/app/api/settings/route.ts
@@ -23,6 +23,7 @@ const settingDefinitions: Record 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',
diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts
index 70fd2f9..68898e2 100644
--- a/src/app/api/tasks/[id]/route.ts
+++ b/src/app/api/tasks/[id]/route.ts
@@ -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
);
diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts
index bdd1626..09b7883 100644
--- a/src/app/api/tasks/route.ts
+++ b/src/app/api/tasks/route.ts
@@ -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);
diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx
index 38ab1a9..eab1399 100644
--- a/src/components/dashboard/dashboard.tsx
+++ b/src/components/dashboard/dashboard.tsx
@@ -522,7 +522,7 @@ export function Dashboard() {
{isLocal ? (
} onNavigate={navigateToPanel} />
) : (
- } onNavigate={navigateToPanel} />
+ } onNavigate={navigateToPanel} />
)}
diff --git a/src/components/layout/live-feed.tsx b/src/components/layout/live-feed.tsx
index dae3c77..f64420b 100644
--- a/src/components/layout/live-feed.tsx
+++ b/src/components/layout/live-feed.tsx
@@ -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 (
-
+
{/* Header */}
@@ -80,7 +81,7 @@ export function LiveFeed() {
+ {/* Info Banner */}
+
+ Agent Memory vs Workspace Memory:{' '}
+ 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{' '}
+ Memory Browser page.
+
+
{/* Memory Content */}
+
+
)}
diff --git a/src/components/panels/agent-squad-panel-phase3.tsx b/src/components/panels/agent-squad-panel-phase3.tsx
index 97b827c..9dad847 100644
--- a/src/components/panels/agent-squad-panel-phase3.tsx
+++ b/src/components/panels/agent-squad-panel-phase3.tsx
@@ -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 || [])
diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx
index 61c9229..48022b5 100644
--- a/src/components/panels/memory-browser-panel.tsx
+++ b/src/components/panels/memory-browser-panel.tsx
@@ -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"
>
+
+
diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx
index 521301b..ed0ada5 100644
--- a/src/components/panels/office-panel.tsx
+++ b/src/components/panels/office-panel.tsx
@@ -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([])
const [viewMode, setViewMode] = useState('office')
+ const [orgSegmentMode, setOrgSegmentMode] = useState('category')
const [selectedAgent, setSelectedAgent] = useState(null)
const [loading, setLoading] = useState(true)
@@ -123,6 +125,64 @@ export function OfficePanel() {
return groups
}, [displayAgents])
+ const categoryGroups = useMemo(() => {
+ const groups = new Map()
+ 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()
+ 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 (
@@ -237,11 +297,40 @@ export function OfficePanel() {
+ Workspace Management:{' '}
+ To create or manage workspaces (tenant instances), go to the{' '}
+ {' '}
+ panel under Admin > Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs.
+
+ )}
+
{/* Feedback */}
{feedback && (
-
+
+
+
+
@@ -452,17 +460,21 @@ export function SuperAdminPanel() {
)}
-
-
- {createExpanded && (
+ {createExpanded && (
+
+
+
Create New Workspace
+
+
- Add a new workspace/client instance here. Fill the form below and click Create + Queue.
+ Fill in the workspace details below and click Create + Queue to provision a new client instance.
{gatewayLoadError && (
@@ -540,8 +552,8 @@ export function SuperAdminPanel() {