'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { useMissionControl } from '@/store' type SuperTab = 'tenants' | 'jobs' | 'events' interface TenantRow { id: number slug: string display_name: string linux_user: string created_by?: string owner_gateway?: string status: string plan_tier: string gateway_port: number | null dashboard_port: number | null created_at: number latest_job_id?: number | null latest_job_status?: string | null } interface ProvisionJob { id: number tenant_id: number tenant_slug?: string tenant_display_name?: string job_type: string status: string dry_run: number requested_by: string approved_by?: string | null started_at?: number | null completed_at?: number | null error_text?: string | null created_at: number } interface ProvisionEvent { id: number level: string step_key?: string | null message: string created_at: number } interface DecommissionDialogState { open: boolean tenant: TenantRow | null dryRun: boolean removeLinuxUser: boolean removeStateDirs: boolean reason: string confirmText: string submitting: boolean } interface GatewayOption { id: number name: string status?: string is_primary?: number } interface SchedulerTask { id: string name: string enabled: boolean lastRun: number | null nextRun: number running: boolean lastResult?: { ok: boolean message: string timestamp: number } } const TENANT_PAGE_SIZE = 8 const JOB_PAGE_SIZE = 8 export function SuperAdminPanel() { const t = useTranslations('superAdmin') const { currentUser, dashboardMode } = useMissionControl() const isLocal = dashboardMode === 'local' const [tenants, setTenants] = useState([]) const [jobs, setJobs] = useState([]) const [selectedJobId, setSelectedJobId] = useState(null) const [selectedJobEvents, setSelectedJobEvents] = useState([]) const [localJobEvents, setLocalJobEvents] = useState>({}) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) const [busyJobId, setBusyJobId] = useState(null) const [activeTab, setActiveTab] = useState('tenants') const [createExpanded, setCreateExpanded] = useState(false) const [tenantSearch, setTenantSearch] = useState('') const [tenantStatusFilter, setTenantStatusFilter] = useState('all') const [tenantPage, setTenantPage] = useState(1) const [jobSearch, setJobSearch] = useState('') const [jobStatusFilter, setJobStatusFilter] = useState('all') const [jobTypeFilter, setJobTypeFilter] = useState('all') const [jobPage, setJobPage] = useState(1) const [openActionMenu, setOpenActionMenu] = useState(null) const [gatewayOptions, setGatewayOptions] = useState([]) const [gatewayLoadError, setGatewayLoadError] = useState(null) const [decommissionDialog, setDecommissionDialog] = useState({ open: false, tenant: null, dryRun: true, removeLinuxUser: false, removeStateDirs: false, reason: '', confirmText: '', submitting: false, }) const [form, setForm] = useState({ slug: '', display_name: '', linux_user: '', plan_tier: 'standard', owner_gateway: 'openclaw-main', gateway_port: '', dashboard_port: '', dry_run: true, }) const showFeedback = (ok: boolean, text: string) => { setFeedback({ ok, text }) setTimeout(() => setFeedback(null), 3500) } const load = useCallback(async () => { try { const [tenantsRes, jobsRes, gatewaysRes, schedulerRes] = await Promise.all([ fetch('/api/super/tenants', { cache: 'no-store' }), fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }), fetch('/api/gateways', { cache: 'no-store' }), isLocal ? fetch('/api/scheduler', { cache: 'no-store' }) : Promise.resolve(null), ]) const tenantsJson = await tenantsRes.json().catch(() => ({})) const jobsJson = await jobsRes.json().catch(() => ({})) const gatewaysJson = await gatewaysRes.json().catch(() => ({})) const schedulerJson = schedulerRes ? await schedulerRes.json().catch(() => ({})) : {} if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants') if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs') let tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : [] let jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : [] const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : [] const schedulerTasks: SchedulerTask[] = Array.isArray(schedulerJson?.tasks) ? schedulerJson.tasks : [] const localEvents: Record = {} if (isLocal) { if (tenantRows.length === 0) { const primaryGateway = gatewayRows.find((gw: any) => Number(gw?.is_primary) === 1) const now = Math.floor(Date.now() / 1000) tenantRows = [{ id: -1, slug: 'local-system', display_name: 'Local Mission Control', linux_user: currentUser?.username || 'local', created_by: 'local', owner_gateway: primaryGateway?.name || 'local', status: 'active', plan_tier: 'local', gateway_port: Number(primaryGateway?.port || 0) || null, dashboard_port: null, created_at: now, latest_job_id: null, latest_job_status: null, }] } if (jobRows.length === 0 && schedulerTasks.length > 0) { jobRows = schedulerTasks.map((task, index) => { const id = -1000 - index const status = task.running ? 'running' : (!task.enabled ? 'cancelled' : (task.lastResult?.ok === false ? 'failed' : (task.lastRun ? 'completed' : 'queued'))) const eventRows: ProvisionEvent[] = [] if (task.lastResult) { eventRows.push({ id: id * -10, level: task.lastResult.ok ? 'info' : 'error', step_key: task.id, message: task.lastResult.message, created_at: Math.floor(task.lastResult.timestamp / 1000), }) } eventRows.push({ id: id * -10 + 1, level: 'info', step_key: task.id, message: `Next run: ${new Date(task.nextRun).toLocaleString()}`, created_at: Math.floor(Date.now() / 1000), }) localEvents[id] = eventRows const lastRunSec = task.lastRun ? Math.floor(task.lastRun / 1000) : null return { id, tenant_id: -1, tenant_slug: 'local-system', tenant_display_name: 'Local Mission Control', job_type: 'automation', status, dry_run: 1, requested_by: 'scheduler', approved_by: null, started_at: lastRunSec, completed_at: status !== 'running' ? lastRunSec : null, error_text: task.lastResult?.ok === false ? task.lastResult.message : null, created_at: lastRunSec || Math.floor(task.nextRun / 1000), } as ProvisionJob }) } } setTenants(tenantRows) setJobs(jobRows) setLocalJobEvents(localEvents) setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary }))) setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways')) setError(null) } catch (e: any) { setError(e?.message || 'Failed to load super admin data') } finally { setLoading(false) } }, [currentUser?.username, isLocal]) const loadJobDetail = useCallback(async (jobId: number) => { if (isLocal && jobId < 0) { setSelectedJobId(jobId) setSelectedJobEvents(localJobEvents[jobId] || []) setActiveTab('events') return } try { const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(json?.error || 'Failed to load job details') setSelectedJobId(jobId) setSelectedJobEvents(Array.isArray(json?.job?.events) ? json.job.events : []) setActiveTab('events') } catch (e: any) { showFeedback(false, e?.message || 'Failed to load job details') } }, [isLocal, localJobEvents]) useEffect(() => { load() const id = setInterval(load, 10000) return () => clearInterval(id) }, [load]) useEffect(() => { setTenantPage(1) }, [tenantSearch, tenantStatusFilter]) useEffect(() => { setJobPage(1) }, [jobSearch, jobStatusFilter, jobTypeFilter]) useEffect(() => { setOpenActionMenu(null) }, [activeTab]) const latestByTenant = useMemo(() => { const map = new Map() for (const job of jobs) { if (!map.has(job.tenant_id)) map.set(job.tenant_id, job) } return map }, [jobs]) const statusOptions = useMemo(() => { const values = Array.from(new Set(tenants.map((t) => t.status))).sort() return ['all', ...values] }, [tenants]) const jobStatusOptions = useMemo(() => { const values = Array.from(new Set(jobs.map((j) => j.status))).sort() return ['all', ...values] }, [jobs]) const jobTypeOptions = useMemo(() => { const values = Array.from(new Set(jobs.map((j) => j.job_type))).sort() return ['all', ...values] }, [jobs]) const filteredTenants = useMemo(() => { const q = tenantSearch.trim().toLowerCase() return tenants.filter((tenant) => { if (tenantStatusFilter !== 'all' && tenant.status !== tenantStatusFilter) return false if (!q) return true return [tenant.display_name, tenant.slug, tenant.linux_user, tenant.created_by || '', tenant.owner_gateway || '', tenant.status].join(' ').toLowerCase().includes(q) }) }, [tenants, tenantSearch, tenantStatusFilter]) const tenantPages = Math.max(1, Math.ceil(filteredTenants.length / TENANT_PAGE_SIZE)) const pagedTenants = filteredTenants.slice((tenantPage - 1) * TENANT_PAGE_SIZE, tenantPage * TENANT_PAGE_SIZE) const filteredJobs = useMemo(() => { const q = jobSearch.trim().toLowerCase() return jobs.filter((job) => { if (jobStatusFilter !== 'all' && job.status !== jobStatusFilter) return false if (jobTypeFilter !== 'all' && job.job_type !== jobTypeFilter) return false if (!q) return true return [String(job.id), job.tenant_slug || '', String(job.tenant_id), job.requested_by, job.approved_by || '', job.status, job.job_type].join(' ').toLowerCase().includes(q) }) }, [jobs, jobSearch, jobStatusFilter, jobTypeFilter]) const jobPages = Math.max(1, Math.ceil(filteredJobs.length / JOB_PAGE_SIZE)) const pagedJobs = filteredJobs.slice((jobPage - 1) * JOB_PAGE_SIZE, jobPage * JOB_PAGE_SIZE) const kpis = useMemo(() => { const active = tenants.filter((t) => t.status === 'active').length const pending = tenants.filter((t) => ['pending', 'provisioning', 'decommissioning'].includes(t.status)).length const errored = tenants.filter((t) => t.status === 'error').length const queuedApprovals = jobs.filter((j) => j.status === 'queued').length return { active, pending, errored, queuedApprovals } }, [tenants, jobs]) const createTenant = async () => { if (!form.slug.trim() || !form.display_name.trim()) { showFeedback(false, t('slugAndNameRequired')) return } try { const res = await fetch('/api/super/tenants', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: form.slug.trim().toLowerCase(), display_name: form.display_name.trim(), linux_user: form.linux_user.trim() || undefined, plan_tier: form.plan_tier, owner_gateway: form.owner_gateway.trim() || undefined, gateway_port: form.gateway_port ? Number(form.gateway_port) : undefined, dashboard_port: form.dashboard_port ? Number(form.dashboard_port) : undefined, dry_run: form.dry_run, }), }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(json?.error || 'Failed to create tenant') showFeedback(true, t('tenantCreated', { slug: form.slug })) setForm({ slug: '', display_name: '', linux_user: '', plan_tier: 'standard', owner_gateway: 'openclaw-main', gateway_port: '', dashboard_port: '', dry_run: true, }) await load() const newJobId = json?.job?.id if (newJobId) await loadJobDetail(Number(newJobId)) } catch (e: any) { showFeedback(false, e?.message || 'Failed to create tenant') } } const runJob = async (jobId: number) => { setBusyJobId(jobId) try { const res = await fetch(`/api/super/provision-jobs/${jobId}/run`, { method: 'POST' }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(json?.error || 'Failed to run job') showFeedback(true, t('jobExecuted', { jobId })) await load() await loadJobDetail(jobId) } catch (e: any) { showFeedback(false, e?.message || `Failed to run job #${jobId}`) await load() await loadJobDetail(jobId) } finally { setBusyJobId(null) setOpenActionMenu(null) } } const approveAndRunJob = async (jobId: number) => { setBusyJobId(jobId) try { const approveRes = await fetch(`/api/super/provision-jobs/${jobId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'approve' }), }) const approveJson = await approveRes.json().catch(() => ({})) if (!approveRes.ok) throw new Error(approveJson?.error || `Failed to approve job #${jobId}`) const runRes = await fetch(`/api/super/provision-jobs/${jobId}/run`, { method: 'POST' }) const runJson = await runRes.json().catch(() => ({})) if (!runRes.ok) throw new Error(runJson?.error || `Failed to run job #${jobId}`) showFeedback(true, t('jobApprovedExecuted', { jobId })) await load() await loadJobDetail(jobId) } catch (e: any) { showFeedback(false, e?.message || `Failed to approve/run job #${jobId}`) await load() await loadJobDetail(jobId) } finally { setBusyJobId(null) setOpenActionMenu(null) } } const openDecommissionDialog = (tenant: TenantRow) => { setOpenActionMenu(null) setDecommissionDialog({ open: true, tenant, dryRun: true, removeLinuxUser: false, removeStateDirs: false, reason: '', confirmText: '', submitting: false, }) } const closeDecommissionDialog = () => { setDecommissionDialog((prev) => ({ ...prev, open: false, submitting: false })) } const queueDecommissionFromDialog = async () => { const tenant = decommissionDialog.tenant if (!tenant) return if (!decommissionDialog.dryRun && decommissionDialog.confirmText.trim() !== tenant.slug) { showFeedback(false, t('typeToConfirm', { slug: tenant.slug })) return } setDecommissionDialog((prev) => ({ ...prev, submitting: true })) try { const res = await fetch(`/api/super/tenants/${tenant.id}/decommission`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: decommissionDialog.dryRun, remove_linux_user: decommissionDialog.removeLinuxUser, remove_state_dirs: decommissionDialog.removeStateDirs, reason: decommissionDialog.reason.trim() || undefined, }), }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(json?.error || 'Failed to queue decommission job') const jobId = Number(json?.job?.id || 0) showFeedback(true, decommissionDialog.dryRun ? t('decommissionQueuedDryRun', { slug: tenant.slug }) : t('decommissionQueued', { slug: tenant.slug })) closeDecommissionDialog() await load() if (jobId > 0) await loadJobDetail(jobId) } catch (e: any) { setDecommissionDialog((prev) => ({ ...prev, submitting: false })) showFeedback(false, e?.message || `Failed to queue decommission for ${tenant.slug}`) } } const setJobState = async (jobId: number, action: 'approve' | 'reject' | 'cancel') => { const reason = window.prompt(t('optionalReason', { action })) || undefined setBusyJobId(jobId) try { const res = await fetch(`/api/super/provision-jobs/${jobId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, reason }), }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(json?.error || `Failed to ${action} job`) showFeedback(true, t('jobActioned', { jobId, action })) await load() await loadJobDetail(jobId) } catch (e: any) { showFeedback(false, e?.message || `Failed to ${action} job #${jobId}`) } finally { setBusyJobId(null) setOpenActionMenu(null) } } const canSubmitDecommission = !!decommissionDialog.tenant && ( decommissionDialog.dryRun || decommissionDialog.confirmText.trim() === decommissionDialog.tenant.slug ) if (currentUser?.role !== 'admin') { return (
{t('accessDenied')}

{t('accessDeniedDesc')}

) } if (loading) { return (
{t('loading')}
) } return (

{t('title')}

{isLocal ? t('subtitleLocal') : t('subtitleMultiTenant')}

{t('activeOrgs')}
{kpis.active}
{t('pendingInProgress')}
{kpis.pending}
{t('erroredOrgs')}
{kpis.errored}
{t('queuedApprovals')}
{kpis.queuedApprovals}
{feedback && (
{feedback.text}
)} {error && (
{error}
)} {createExpanded && (

{t('createNewWorkspace')}

{t('createWorkspaceHint')}
{gatewayLoadError && (
Gateway list unavailable: {gatewayLoadError}. Using fallback owner value.
)}
setForm((f) => ({ ...f, slug: e.target.value }))} placeholder={t('slugPlaceholder')} className="h-9 px-3 rounded-md bg-secondary border border-border text-sm text-foreground" /> setForm((f) => ({ ...f, display_name: e.target.value }))} placeholder={t('displayNamePlaceholder')} className="h-9 px-3 rounded-md bg-secondary border border-border text-sm text-foreground" /> setForm((f) => ({ ...f, linux_user: e.target.value }))} placeholder={t('linuxUserPlaceholder')} className="h-9 px-3 rounded-md bg-secondary border border-border text-sm text-foreground" /> setForm((f) => ({ ...f, gateway_port: e.target.value }))} placeholder={t('gatewayPortPlaceholder')} className="h-9 px-3 rounded-md bg-secondary border border-border text-sm text-foreground" /> setForm((f) => ({ ...f, dashboard_port: e.target.value }))} placeholder={t('dashboardPortPlaceholder')} className="h-9 px-3 rounded-md bg-secondary border border-border text-sm text-foreground" />
)}
{(['tenants', 'jobs', 'events'] as SuperTab[]).map((tab) => ( ))}
{activeTab === 'tenants' && (
setTenantSearch(e.target.value)} placeholder={t('searchOrganizations')} className="h-8 w-56 px-3 rounded-md bg-secondary border border-border text-xs text-foreground" />
{t('showing', { shown: pagedTenants.length, total: filteredTenants.length })}
{pagedTenants.map((tenant) => { const latest = latestByTenant.get(tenant.id) const menuKey = `tenant-${tenant.id}` return ( ) })} {pagedTenants.length === 0 && ( )}
Tenant list
{t('colTenant')} {t('colSystemUser')} {t('colOwner')} {t('colStatus')} {t('colLatestJob')} {t('colAction')}
{tenant.display_name}
{tenant.slug}
{tenant.linux_user}
{tenant.owner_gateway || t('unassigned')}
{t('by')} {tenant.created_by || t('unknownCreator')}
{tenant.status} {latest ? ( ) : ( - )} {isLocal && tenant.id < 0 ? ( {t('localReadOnly')} ) : ( <> {openActionMenu === menuKey && (
)} )}
{t('noMatchingOrgs')}
{t('page', { page: tenantPage, total: tenantPages })}
)} {activeTab === 'jobs' && (
setJobSearch(e.target.value)} placeholder={t('searchJobs')} className="h-8 w-56 px-3 rounded-md bg-secondary border border-border text-xs text-foreground" />
{t('showing', { shown: pagedJobs.length, total: filteredJobs.length })}
{pagedJobs.map((job) => { const menuKey = `job-${job.id}` return ( ) })} {pagedJobs.length === 0 && ( )}
Provisioning jobs
{t('colJob')} {t('colTenant')} {t('colStatus')} {t('colRequestedApproved')} {t('colAction')}
{job.job_type} {job.dry_run ? t('dryRun') : t('live')}
{job.tenant_slug || job.tenant_id} {job.status}
{t('reqShort')}: {job.requested_by}
{t('apprShort')}: {job.approved_by || '-'}
{isLocal && job.id < 0 ? ( ) : ( <> {openActionMenu === menuKey && (
)} )}
{t('noMatchingJobs')}
{t('page', { page: jobPage, total: jobPages })}
)} {activeTab === 'events' && (
{selectedJobId ? t('showingEventsForJob', { jobId: selectedJobId }) : t('selectJobForEvents')}
{selectedJobId && selectedJobEvents.length === 0 && (
{t('noEventsYet')}
)} {selectedJobEvents.map((ev) => (
{new Date(ev.created_at * 1000).toLocaleString()} · {ev.level}{ev.step_key ? ` · ${ev.step_key}` : ''}
{ev.message}
))}
)}
{decommissionDialog.open && decommissionDialog.tenant && (

{t('queueDecommissionTitle', { name: decommissionDialog.tenant.display_name })}

{t('reviewImpact')}

{t('impactSummary')}
  • • {t('impactStopsService', { user: decommissionDialog.tenant.linux_user })}
  • • {t('impactRemovesEnv', { user: decommissionDialog.tenant.linux_user })}
  • • {decommissionDialog.removeLinuxUser ? t('impactLinuxUserRemoved') : (decommissionDialog.removeStateDirs ? t('impactStateDirsRemoved') : t('impactRetained'))}