mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Fix persistence
This commit is contained in:
130
apps/sim/app/api/admin/mothership/route.ts
Normal file
130
apps/sim/app/api/admin/mothership/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const ENV_URLS: Record<string, string | undefined> = {
|
||||
dev: env.MOTHERSHIP_DEV_URL,
|
||||
staging: env.MOTHERSHIP_STAGING_URL,
|
||||
prod: env.MOTHERSHIP_PROD_URL,
|
||||
}
|
||||
|
||||
function getMothershipUrl(environment: string): string | null {
|
||||
return ENV_URLS[environment] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy to the mothership admin API.
|
||||
*
|
||||
* Query params:
|
||||
* env - "dev" | "staging" | "prod"
|
||||
* endpoint - the admin endpoint path, e.g. "requests", "licenses", "traces"
|
||||
*
|
||||
* The request body (for POST) is forwarded as-is. Additional query params
|
||||
* (e.g. requestId for GET /traces) are forwarded.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user || session.user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
|
||||
if (!adminKey) {
|
||||
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const environment = searchParams.get('env') || 'dev'
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseUrl = getMothershipUrl(environment)
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: `No URL configured for environment: ${environment}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const targetUrl = `${baseUrl}/api/admin/${endpoint}`
|
||||
|
||||
try {
|
||||
const body = await req.text()
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': adminKey,
|
||||
},
|
||||
...(body ? { body } : {}),
|
||||
})
|
||||
|
||||
const data = await upstream.json()
|
||||
return NextResponse.json(data, { status: upstream.status })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Failed to reach mothership (${environment}): ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user || session.user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
|
||||
if (!adminKey) {
|
||||
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const environment = searchParams.get('env') || 'dev'
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseUrl = getMothershipUrl(environment)
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: `No URL configured for environment: ${environment}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const forwardParams = new URLSearchParams()
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== 'env' && key !== 'endpoint') {
|
||||
forwardParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
const qs = forwardParams.toString()
|
||||
const targetUrl = `${baseUrl}/api/admin/${endpoint}${qs ? `?${qs}` : ''}`
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'x-api-key': adminKey },
|
||||
})
|
||||
|
||||
const data = await upstream.json()
|
||||
return NextResponse.json(data, { status: upstream.status })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Failed to reach mothership (${environment}): ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
readEvents,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/request/session'
|
||||
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
|
||||
export const maxDuration = 3600
|
||||
|
||||
@@ -113,11 +114,7 @@ export async function GET(request: NextRequest) {
|
||||
if (batchMode) {
|
||||
const afterSeq = afterCursor || '0'
|
||||
const events = await readEvents(streamId, afterSeq)
|
||||
const batchEvents = events.map((envelope) => ({
|
||||
eventId: envelope.seq,
|
||||
streamId: envelope.stream.streamId,
|
||||
event: envelope,
|
||||
}))
|
||||
const batchEvents = events.map(toStreamBatchEvent)
|
||||
logger.info('[Resume] Batch response', {
|
||||
streamId,
|
||||
afterCursor: afterSeq,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import { readEvents } from '@/lib/copilot/request/session/buffer'
|
||||
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import { taskPubSub } from '@/lib/copilot/tasks'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
@@ -47,7 +48,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
let streamSnapshot: {
|
||||
events: unknown[]
|
||||
events: StreamBatchEvent[]
|
||||
status: string
|
||||
} | null = null
|
||||
|
||||
@@ -56,7 +57,7 @@ export async function GET(
|
||||
const events = await readEvents(chat.conversationId, '0')
|
||||
|
||||
streamSnapshot = {
|
||||
events: events || [],
|
||||
events: events.map(toStreamBatchEvent),
|
||||
status: events.length > 0 ? 'active' : 'unknown',
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ToolSearchToolRegex,
|
||||
WorkspaceFile,
|
||||
} from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import {
|
||||
extractResourcesFromToolResult,
|
||||
isResourceToolName,
|
||||
@@ -148,12 +149,6 @@ type StreamingFilePreview = {
|
||||
content: string
|
||||
}
|
||||
|
||||
type StreamBatchEvent = {
|
||||
eventId: number
|
||||
streamId: string
|
||||
event: Record<string, unknown>
|
||||
}
|
||||
|
||||
type StreamBatchResponse = {
|
||||
success: boolean
|
||||
events: StreamBatchEvent[]
|
||||
|
||||
@@ -142,6 +142,13 @@ const Admin = dynamic(
|
||||
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin),
|
||||
{ loading: () => <AdminSkeleton /> }
|
||||
)
|
||||
const Mothership = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then(
|
||||
(m) => m.Mothership
|
||||
),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
const RecentlyDeleted = dynamic(
|
||||
() =>
|
||||
import(
|
||||
@@ -175,7 +182,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
|
||||
? 'general'
|
||||
: section === 'admin' && !sessionLoading && !isAdminRole
|
||||
? 'general'
|
||||
: section
|
||||
: section === 'mothership' && !sessionLoading && !isAdminRole
|
||||
? 'general'
|
||||
: section
|
||||
|
||||
const label =
|
||||
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
|
||||
@@ -207,6 +216,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
|
||||
{effectiveSection === 'inbox' && <Inbox />}
|
||||
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
|
||||
{effectiveSection === 'admin' && <Admin />}
|
||||
{effectiveSection === 'mothership' && <Mothership />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
type MothershipEnv,
|
||||
useGenerateLicense,
|
||||
useMothershipEnterpriseStats,
|
||||
useMothershipLicenses,
|
||||
useMothershipRequests,
|
||||
useMothershipTrace,
|
||||
useMothershipUserBreakdown,
|
||||
} from '@/hooks/queries/mothership-admin'
|
||||
|
||||
type Tab = 'overview' | 'licenses' | 'enterprise' | 'traces'
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'licenses', label: 'Licenses' },
|
||||
{ id: 'enterprise', label: 'Enterprise' },
|
||||
{ id: 'traces', label: 'Traces' },
|
||||
]
|
||||
|
||||
const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [
|
||||
{ id: 'dev', label: 'Dev' },
|
||||
{ id: 'staging', label: 'Staging' },
|
||||
{ id: 'prod', label: 'Prod' },
|
||||
]
|
||||
|
||||
function defaultTimeRange() {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 7)
|
||||
return {
|
||||
start: start.toISOString().slice(0, 16),
|
||||
end: end.toISOString().slice(0, 16),
|
||||
}
|
||||
}
|
||||
|
||||
function toRFC3339(local: string) {
|
||||
if (!local) return ''
|
||||
return new Date(local).toISOString()
|
||||
}
|
||||
|
||||
function formatCost(cost: number) {
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
|
||||
function formatDate(d: string | null | undefined) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleString()
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className='h-px bg-[var(--border-secondary)]' />
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className='font-medium text-[var(--text-primary)] text-sm'>{children}</p>
|
||||
}
|
||||
|
||||
export function Mothership() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||
const [environment, setEnvironment] = useState<MothershipEnv>('dev')
|
||||
const defaults = useMemo(() => defaultTimeRange(), [])
|
||||
const [start, setStart] = useState(defaults.start)
|
||||
const [end, setEnd] = useState(defaults.end)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-5'>
|
||||
{/* Environment selector */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-[var(--text-secondary)] text-sm'>Environment</Label>
|
||||
<div className='flex gap-1'>
|
||||
{ENV_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type='button'
|
||||
onClick={() => setEnvironment(opt.id)}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 font-medium text-sm transition-colors',
|
||||
environment === opt.id
|
||||
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className='flex gap-1 border-[var(--border-secondary)] border-b pb-px'>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type='button'
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'relative px-3 py-2 font-medium text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className='absolute right-0 bottom-0 left-0 h-[2px] bg-[var(--text-primary)]' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time range (shared across tabs) */}
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>From</Label>
|
||||
<EmcnInput
|
||||
type='datetime-local'
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className='h-[30px] text-caption'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>To</Label>
|
||||
<EmcnInput
|
||||
type='datetime-local'
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className='h-[30px] text-caption'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab environment={environment} start={toRFC3339(start)} end={toRFC3339(end)} />
|
||||
)}
|
||||
{activeTab === 'licenses' && <LicensesTab environment={environment} />}
|
||||
{activeTab === 'enterprise' && (
|
||||
<EnterpriseTab environment={environment} start={toRFC3339(start)} end={toRFC3339(end)} />
|
||||
)}
|
||||
{activeTab === 'traces' && <TracesTab environment={environment} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Overview Tab ─── */
|
||||
|
||||
function OverviewTab({
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
environment: MothershipEnv
|
||||
start: string
|
||||
end: string
|
||||
}) {
|
||||
const { data: breakdown, isLoading: breakdownLoading } = useMothershipUserBreakdown(
|
||||
environment,
|
||||
start,
|
||||
end
|
||||
)
|
||||
const { data: requests, isLoading: requestsLoading } = useMothershipRequests(
|
||||
environment,
|
||||
start,
|
||||
end
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-5'>
|
||||
{/* Summary cards */}
|
||||
<div className='grid grid-cols-4 gap-3'>
|
||||
<StatCard
|
||||
label='Total Requests'
|
||||
value={breakdown?.total_requests}
|
||||
loading={breakdownLoading}
|
||||
/>
|
||||
<StatCard label='Unique Users' value={breakdown?.total_users} loading={breakdownLoading} />
|
||||
<StatCard
|
||||
label='Total Cost'
|
||||
value={
|
||||
breakdown?.users
|
||||
? formatCost(
|
||||
breakdown.users.reduce(
|
||||
(s: number, u: { total_cost: number }) => s + u.total_cost,
|
||||
0
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
loading={breakdownLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label='Avg Cost/Request'
|
||||
value={
|
||||
breakdown?.total_requests && breakdown.users
|
||||
? formatCost(
|
||||
breakdown.users.reduce(
|
||||
(s: number, u: { total_cost: number }) => s + u.total_cost,
|
||||
0
|
||||
) / breakdown.total_requests
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
loading={breakdownLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User breakdown */}
|
||||
<SectionLabel>User Breakdown</SectionLabel>
|
||||
{breakdownLoading && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[36px] w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{breakdown?.users && (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
|
||||
<span className='flex-1'>User ID</span>
|
||||
<span className='w-[100px] text-right'>Requests</span>
|
||||
<span className='w-[100px] text-right'>Cost</span>
|
||||
<span className='w-[160px] text-right'>Last Request</span>
|
||||
</div>
|
||||
{breakdown.users.map(
|
||||
(u: {
|
||||
user_id: string
|
||||
request_count: number
|
||||
total_cost: number
|
||||
last_request: string
|
||||
}) => (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
|
||||
>
|
||||
<span className='flex-1 truncate font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
{u.user_id}
|
||||
</span>
|
||||
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
|
||||
{u.request_count}
|
||||
</span>
|
||||
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
|
||||
{formatCost(u.total_cost)}
|
||||
</span>
|
||||
<span className='w-[160px] text-right text-[var(--text-tertiary)] text-caption'>
|
||||
{formatDate(u.last_request)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent requests */}
|
||||
<Divider />
|
||||
<SectionLabel>Recent Requests ({requests?.count ?? '…'})</SectionLabel>
|
||||
{requestsLoading && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[36px] w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{requests?.requests && (
|
||||
<div className='max-h-[400px] overflow-auto'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='sticky top-0 z-10 flex items-center gap-3 border-[var(--border-secondary)] border-b bg-[var(--surface-1)] px-3 py-2 text-[var(--text-tertiary)] text-caption'>
|
||||
<span className='w-[180px]'>Request ID</span>
|
||||
<span className='w-[80px]'>Model</span>
|
||||
<span className='w-[80px] text-right'>Duration</span>
|
||||
<span className='w-[80px] text-right'>Cost</span>
|
||||
<span className='w-[60px] text-right'>Tools</span>
|
||||
<span className='w-[70px] text-right'>Status</span>
|
||||
<span className='flex-1 text-right'>Time</span>
|
||||
</div>
|
||||
{requests.requests
|
||||
.slice(0, 100)
|
||||
.map(
|
||||
(r: {
|
||||
request_id: string
|
||||
model: string
|
||||
duration_ms: number
|
||||
billed_total_cost: number
|
||||
tool_call_count: number
|
||||
error: boolean
|
||||
aborted: boolean
|
||||
created_at: string
|
||||
}) => (
|
||||
<div
|
||||
key={r.request_id}
|
||||
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-1.5 text-small last:border-b-0'
|
||||
>
|
||||
<span className='w-[180px] truncate font-mono text-[11px] text-[var(--text-primary)]'>
|
||||
{r.request_id ?? '—'}
|
||||
</span>
|
||||
<span className='w-[80px] truncate text-[var(--text-secondary)] text-caption'>
|
||||
{(r.model ?? '').replace('claude-', '')}
|
||||
</span>
|
||||
<span className='w-[80px] text-right text-[var(--text-secondary)] text-caption'>
|
||||
{r.duration_ms ? `${(r.duration_ms / 1000).toFixed(1)}s` : '—'}
|
||||
</span>
|
||||
<span className='w-[80px] text-right text-[var(--text-secondary)] text-caption'>
|
||||
{formatCost(r.billed_total_cost ?? 0)}
|
||||
</span>
|
||||
<span className='w-[60px] text-right text-[var(--text-secondary)] text-caption'>
|
||||
{r.tool_call_count ?? 0}
|
||||
</span>
|
||||
<span className='w-[70px] text-right'>
|
||||
{r.error ? (
|
||||
<Badge variant='red'>Error</Badge>
|
||||
) : r.aborted ? (
|
||||
<Badge variant='yellow'>Abort</Badge>
|
||||
) : (
|
||||
<Badge variant='green'>OK</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className='flex-1 text-right text-[var(--text-tertiary)] text-caption'>
|
||||
{formatDate(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Licenses Tab ─── */
|
||||
|
||||
function LicensesTab({ environment }: { environment: MothershipEnv }) {
|
||||
const { data, isLoading, refetch } = useMothershipLicenses(environment)
|
||||
const generateLicense = useGenerateLicense(environment)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newExpiry, setNewExpiry] = useState('')
|
||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
generateLicense.mutate(
|
||||
{
|
||||
name: newName.trim(),
|
||||
...(newExpiry ? { expirationDate: newExpiry } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setGeneratedKey(result.license_key)
|
||||
setNewName('')
|
||||
setNewExpiry('')
|
||||
refetch()
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [newName, newExpiry, generateLicense, refetch])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-5'>
|
||||
<SectionLabel>Generate License</SectionLabel>
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>Enterprise Name</Label>
|
||||
<EmcnInput
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value)
|
||||
setGeneratedKey(null)
|
||||
}}
|
||||
placeholder='e.g. Acme Corp'
|
||||
className='h-[32px] w-[200px]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>Expiration (optional)</Label>
|
||||
<EmcnInput
|
||||
type='date'
|
||||
value={newExpiry}
|
||||
onChange={(e) => setNewExpiry(e.target.value)}
|
||||
className='h-[32px] w-[160px]'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='h-[32px]'
|
||||
onClick={handleGenerate}
|
||||
disabled={generateLicense.isPending || !newName.trim()}
|
||||
>
|
||||
{generateLicense.isPending ? 'Generating...' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{generatedKey && (
|
||||
<div className='rounded-md border border-[var(--border-secondary)] bg-[var(--surface-hover)] p-3'>
|
||||
<p className='mb-1 text-[var(--text-secondary)] text-caption'>
|
||||
License key (only shown once):
|
||||
</p>
|
||||
<code className='block break-all font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
{generatedKey}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateLicense.error && (
|
||||
<p className='text-[var(--text-error)] text-small'>{generateLicense.error.message}</p>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<SectionLabel>All Licenses</SectionLabel>
|
||||
|
||||
{isLoading && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[40px] w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.licenses && (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
|
||||
<span className='flex-1'>Name</span>
|
||||
<span className='w-[100px] text-right'>Validations</span>
|
||||
<span className='w-[140px] text-right'>Expiration</span>
|
||||
<span className='w-[140px] text-right'>Created</span>
|
||||
</div>
|
||||
{data.licenses.length === 0 && (
|
||||
<div className='py-4 text-center text-[var(--text-tertiary)] text-small'>
|
||||
No licenses found.
|
||||
</div>
|
||||
)}
|
||||
{data.licenses.map(
|
||||
(lic: {
|
||||
id: string
|
||||
name: string
|
||||
count: number
|
||||
expiration_date?: string
|
||||
created_at: string
|
||||
}) => (
|
||||
<div
|
||||
key={lic.id}
|
||||
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
|
||||
>
|
||||
<span className='flex-1 text-[var(--text-primary)]'>{lic.name}</span>
|
||||
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
|
||||
{lic.count}
|
||||
</span>
|
||||
<span className='w-[140px] text-right text-[var(--text-tertiary)] text-caption'>
|
||||
{lic.expiration_date ? formatDate(lic.expiration_date) : 'Never'}
|
||||
</span>
|
||||
<span className='w-[140px] text-right text-[var(--text-tertiary)] text-caption'>
|
||||
{formatDate(lic.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Enterprise Tab ─── */
|
||||
|
||||
function EnterpriseTab({
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
environment: MothershipEnv
|
||||
start: string
|
||||
end: string
|
||||
}) {
|
||||
const [customerType, setCustomerType] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const { data, isLoading, error } = useMothershipEnterpriseStats(
|
||||
environment,
|
||||
customerType,
|
||||
start,
|
||||
end
|
||||
)
|
||||
|
||||
const handleSearch = () => {
|
||||
setCustomerType(searchInput.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<EmcnInput
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder='Enter customer type (e.g. enterprise name)...'
|
||||
/>
|
||||
<Button variant='primary' onClick={handleSearch} disabled={!searchInput.trim()}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className='text-[var(--text-error)] text-small'>{error.message}</p>}
|
||||
|
||||
{isLoading && customerType && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[60px] w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className='grid grid-cols-4 gap-3'>
|
||||
<StatCard label='Total Requests' value={data.total_requests} />
|
||||
<StatCard label='Unique Users' value={data.unique_users} />
|
||||
<StatCard label='Total Cost' value={formatCost(data.total_cost ?? 0)} />
|
||||
<StatCard
|
||||
label='Total Tokens'
|
||||
value={(
|
||||
(data.total_input_tokens ?? 0) + (data.total_output_tokens ?? 0)
|
||||
).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.top_models && (
|
||||
<>
|
||||
<Divider />
|
||||
<SectionLabel>Top Models</SectionLabel>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{data.top_models.map((m: { model: string; count: number }) => (
|
||||
<Badge key={m.model} variant='gray'>
|
||||
{m.model} ({m.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.users && (
|
||||
<>
|
||||
<Divider />
|
||||
<SectionLabel>User Breakdown</SectionLabel>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
|
||||
<span className='flex-1'>User ID</span>
|
||||
<span className='w-[100px] text-right'>Requests</span>
|
||||
<span className='w-[100px] text-right'>Cost</span>
|
||||
<span className='w-[160px] text-right'>Last Request</span>
|
||||
</div>
|
||||
{data.users.map(
|
||||
(u: {
|
||||
user_id: string
|
||||
request_count: number
|
||||
total_cost: number
|
||||
last_request: string
|
||||
}) => (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
|
||||
>
|
||||
<span className='flex-1 truncate font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
{u.user_id}
|
||||
</span>
|
||||
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
|
||||
{u.request_count}
|
||||
</span>
|
||||
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
|
||||
{formatCost(u.total_cost)}
|
||||
</span>
|
||||
<span className='w-[160px] text-right text-[var(--text-tertiary)] text-caption'>
|
||||
{formatDate(u.last_request)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Traces Tab ─── */
|
||||
|
||||
function TracesTab({ environment }: { environment: MothershipEnv }) {
|
||||
const [requestIdInput, setRequestIdInput] = useState('')
|
||||
const [activeRequestId, setActiveRequestId] = useState('')
|
||||
const { data: trace, isLoading, error } = useMothershipTrace(environment, activeRequestId)
|
||||
|
||||
const handleLookup = () => {
|
||||
setActiveRequestId(requestIdInput.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<EmcnInput
|
||||
value={requestIdInput}
|
||||
onChange={(e) => setRequestIdInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLookup()}
|
||||
placeholder='Paste a request ID (go_trace_id)...'
|
||||
className='font-mono text-[13px]'
|
||||
/>
|
||||
<Button variant='primary' onClick={handleLookup} disabled={!requestIdInput.trim()}>
|
||||
Lookup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className='text-[var(--text-error)] text-small'>{error.message}</p>}
|
||||
|
||||
{isLoading && activeRequestId && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[50px] w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trace && <TraceDetail trace={trace} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Trace Detail ─── */
|
||||
|
||||
interface TraceSpan {
|
||||
name: string
|
||||
kind?: string
|
||||
startMs: number
|
||||
endMs?: number
|
||||
durationMs?: number
|
||||
status: string
|
||||
parentName?: string
|
||||
source?: string
|
||||
attributes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface TraceData {
|
||||
id: string
|
||||
simRequestId: string
|
||||
goTraceId: string
|
||||
streamId?: string
|
||||
chatId?: string
|
||||
userId?: string
|
||||
startMs: number
|
||||
endMs: number
|
||||
durationMs: number
|
||||
outcome: string
|
||||
spans: TraceSpan[]
|
||||
model?: string
|
||||
provider?: string
|
||||
mode?: string
|
||||
source?: string
|
||||
message?: string
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
rawTotalCost?: number
|
||||
billedTotalCost?: number
|
||||
toolCallCount?: number
|
||||
error?: boolean
|
||||
aborted?: boolean
|
||||
errorMsg?: string
|
||||
}
|
||||
|
||||
function TraceDetail({ trace }: { trace: TraceData }) {
|
||||
const rootSpans = trace.spans.filter((s) => !s.parentName)
|
||||
const childMap = new Map<string, TraceSpan[]>()
|
||||
for (const span of trace.spans) {
|
||||
if (span.parentName) {
|
||||
const existing = childMap.get(span.parentName) || []
|
||||
existing.push(span)
|
||||
childMap.set(span.parentName, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Trace metadata */}
|
||||
<div className='grid grid-cols-2 gap-x-6 gap-y-2 rounded-md border border-[var(--border-secondary)] p-4'>
|
||||
<MetaRow label='Go Trace ID' value={trace.goTraceId} mono />
|
||||
<MetaRow label='Sim Request ID' value={trace.simRequestId} mono />
|
||||
<MetaRow label='Outcome'>
|
||||
<Badge
|
||||
variant={
|
||||
trace.outcome === 'success'
|
||||
? 'green'
|
||||
: trace.outcome === 'cancelled'
|
||||
? 'yellow'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{trace.outcome}
|
||||
</Badge>
|
||||
</MetaRow>
|
||||
<MetaRow label='Duration' value={`${(trace.durationMs / 1000).toFixed(2)}s`} />
|
||||
<MetaRow label='Model' value={trace.model || '—'} />
|
||||
<MetaRow label='Provider' value={trace.provider || '—'} />
|
||||
<MetaRow label='Source' value={trace.source || '—'} />
|
||||
<MetaRow label='Mode' value={trace.mode || '—'} />
|
||||
{trace.userId && <MetaRow label='User ID' value={trace.userId} mono />}
|
||||
{trace.chatId && <MetaRow label='Chat ID' value={trace.chatId} mono />}
|
||||
<MetaRow
|
||||
label='Tokens'
|
||||
value={`${(trace.inputTokens ?? 0).toLocaleString()} in / ${(trace.outputTokens ?? 0).toLocaleString()} out`}
|
||||
/>
|
||||
<MetaRow label='Billed Cost' value={formatCost(trace.billedTotalCost ?? 0)} />
|
||||
{trace.toolCallCount != null && trace.toolCallCount > 0 && (
|
||||
<MetaRow label='Tool Calls' value={String(trace.toolCallCount)} />
|
||||
)}
|
||||
{trace.message && (
|
||||
<div className='col-span-2'>
|
||||
<MetaRow label='Message' value={trace.message} />
|
||||
</div>
|
||||
)}
|
||||
{trace.errorMsg && (
|
||||
<div className='col-span-2'>
|
||||
<MetaRow label='Error'>
|
||||
<span className='text-[var(--text-error)]'>{trace.errorMsg}</span>
|
||||
</MetaRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Span tree */}
|
||||
<SectionLabel>Spans ({trace.spans.length})</SectionLabel>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{rootSpans
|
||||
.sort((a, b) => a.startMs - b.startMs)
|
||||
.map((span) => (
|
||||
<SpanNode
|
||||
key={span.name + span.startMs}
|
||||
span={span}
|
||||
childMap={childMap}
|
||||
traceStartMs={trace.startMs}
|
||||
traceDurationMs={trace.durationMs}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpanNode({
|
||||
span,
|
||||
childMap,
|
||||
traceStartMs,
|
||||
traceDurationMs,
|
||||
depth,
|
||||
}: {
|
||||
span: TraceSpan
|
||||
childMap: Map<string, TraceSpan[]>
|
||||
traceStartMs: number
|
||||
traceDurationMs: number
|
||||
depth: number
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth < 2)
|
||||
const children = childMap.get(span.name) || []
|
||||
const hasChildren = children.length > 0
|
||||
const durationMs = span.durationMs ?? (span.endMs ? span.endMs - span.startMs : 0)
|
||||
const offsetPct =
|
||||
traceDurationMs > 0 ? ((span.startMs - traceStartMs) / traceDurationMs) * 100 : 0
|
||||
const widthPct = traceDurationMs > 0 ? (durationMs / traceDurationMs) * 100 : 0
|
||||
|
||||
const statusColor =
|
||||
span.status === 'ok'
|
||||
? 'bg-emerald-500/70'
|
||||
: span.status === 'error'
|
||||
? 'bg-red-500/70'
|
||||
: span.status === 'cancelled'
|
||||
? 'bg-yellow-500/70'
|
||||
: 'bg-[var(--text-tertiary)]'
|
||||
|
||||
const attrs = span.attributes || {}
|
||||
const attrEntries = Object.entries(attrs).filter(
|
||||
([, v]) => v !== null && v !== undefined && v !== ''
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: depth * 16 }}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover-hover:hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span className='w-[14px] text-center text-[10px] text-[var(--text-tertiary)]'>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
) : (
|
||||
<span className='w-[14px]' />
|
||||
)}
|
||||
|
||||
<span className='min-w-0 flex-1'>
|
||||
<span className='block truncate text-[13px] text-[var(--text-primary)]'>{span.name}</span>
|
||||
{/* Waterfall bar */}
|
||||
<span className='mt-0.5 block h-[4px] w-full rounded-full bg-[var(--border-secondary)]'>
|
||||
<span
|
||||
className={cn('block h-full rounded-full', statusColor)}
|
||||
style={{
|
||||
marginLeft: `${Math.max(0, Math.min(offsetPct, 100))}%`,
|
||||
width: `${Math.max(0.5, Math.min(widthPct, 100 - offsetPct))}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Badge variant={span.source === 'go' ? 'blue' : 'gray'} className='shrink-0'>
|
||||
{span.source || '?'}
|
||||
</Badge>
|
||||
|
||||
<span className='w-[70px] shrink-0 text-right font-mono text-[11px] text-[var(--text-secondary)]'>
|
||||
{durationMs >= 1000 ? `${(durationMs / 1000).toFixed(2)}s` : `${durationMs}ms`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && attrEntries.length > 0 && (
|
||||
<div
|
||||
className='mb-1 ml-[30px] rounded border border-[var(--border-secondary)] bg-[var(--surface-hover)] px-3 py-2'
|
||||
style={{ marginLeft: 30 + depth * 16 }}
|
||||
>
|
||||
{attrEntries.map(([key, val]) => (
|
||||
<div key={key} className='flex gap-2 py-0.5 text-[11px]'>
|
||||
<span className='shrink-0 text-[var(--text-tertiary)]'>{key}:</span>
|
||||
<span className='min-w-0 break-all text-[var(--text-secondary)]'>
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded &&
|
||||
children
|
||||
.sort((a, b) => a.startMs - b.startMs)
|
||||
.map((child) => (
|
||||
<SpanNode
|
||||
key={child.name + child.startMs}
|
||||
span={child}
|
||||
childMap={childMap}
|
||||
traceStartMs={traceStartMs}
|
||||
traceDurationMs={traceDurationMs}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Shared components ─── */
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
loading,
|
||||
}: {
|
||||
label: string
|
||||
value?: string | number
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-md border border-[var(--border-secondary)] p-3'>
|
||||
<p className='text-[var(--text-tertiary)] text-caption'>{label}</p>
|
||||
{loading ? (
|
||||
<Skeleton className='mt-1 h-[24px] w-[80px] rounded-sm' />
|
||||
) : (
|
||||
<p className='mt-1 font-medium text-[18px] text-[var(--text-primary)]'>{value ?? '—'}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
mono?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-baseline gap-2'>
|
||||
<span className='shrink-0 text-[var(--text-tertiary)] text-caption'>{label}</span>
|
||||
{children || (
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 break-all text-[13px] text-[var(--text-primary)]',
|
||||
mono && 'font-mono text-[12px]'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export type SettingsSection =
|
||||
| 'workflow-mcp-servers'
|
||||
| 'inbox'
|
||||
| 'admin'
|
||||
| 'mothership'
|
||||
| 'recently-deleted'
|
||||
|
||||
export type NavigationSection =
|
||||
@@ -169,4 +170,11 @@ export const allNavigationItems: NavigationItem[] = [
|
||||
section: 'superuser',
|
||||
requiresAdminRole: true,
|
||||
},
|
||||
{
|
||||
id: 'mothership',
|
||||
label: 'Mothership',
|
||||
icon: Server,
|
||||
section: 'superuser',
|
||||
requiresAdminRole: true,
|
||||
},
|
||||
]
|
||||
|
||||
131
apps/sim/hooks/queries/mothership-admin.ts
Normal file
131
apps/sim/hooks/queries/mothership-admin.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
export type MothershipEnv = 'dev' | 'staging' | 'prod'
|
||||
|
||||
const BASE = '/api/admin/mothership'
|
||||
|
||||
async function mothershipPost(
|
||||
endpoint: string,
|
||||
environment: MothershipEnv,
|
||||
body?: Record<string, unknown>
|
||||
) {
|
||||
const res = await fetch(`${BASE}?env=${environment}&endpoint=${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.message || err.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function mothershipGet(
|
||||
endpoint: string,
|
||||
environment: MothershipEnv,
|
||||
params?: Record<string, string>
|
||||
) {
|
||||
const qs = new URLSearchParams({ env: environment, endpoint, ...params })
|
||||
const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.message || err.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const mothershipKeys = {
|
||||
all: ['mothership-admin'] as const,
|
||||
requests: (env: MothershipEnv, start: string, end: string, userId?: string) =>
|
||||
[...mothershipKeys.all, 'requests', env, start, end, userId] as const,
|
||||
userBreakdown: (env: MothershipEnv, start: string, end: string) =>
|
||||
[...mothershipKeys.all, 'user-breakdown', env, start, end] as const,
|
||||
licenses: (env: MothershipEnv) => [...mothershipKeys.all, 'licenses', env] as const,
|
||||
licenseDetails: (env: MothershipEnv, id?: string, name?: string) =>
|
||||
[...mothershipKeys.all, 'license-details', env, id, name] as const,
|
||||
enterpriseStats: (env: MothershipEnv, customerType: string, start: string, end: string) =>
|
||||
[...mothershipKeys.all, 'enterprise-stats', env, customerType, start, end] as const,
|
||||
trace: (env: MothershipEnv, requestId: string) =>
|
||||
[...mothershipKeys.all, 'trace', env, requestId] as const,
|
||||
}
|
||||
|
||||
export function useMothershipRequests(
|
||||
environment: MothershipEnv,
|
||||
start: string,
|
||||
end: string,
|
||||
userId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.requests(environment, start, end, userId),
|
||||
queryFn: () =>
|
||||
mothershipPost('requests', environment, {
|
||||
start,
|
||||
end,
|
||||
...(userId ? { userId } : {}),
|
||||
}),
|
||||
enabled: !!start && !!end,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipUserBreakdown(environment: MothershipEnv, start: string, end: string) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.userBreakdown(environment, start, end),
|
||||
queryFn: () => mothershipPost('user-breakdown', environment, { start, end }),
|
||||
enabled: !!start && !!end,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipLicenses(environment: MothershipEnv) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.licenses(environment),
|
||||
queryFn: () => mothershipGet('licenses', environment),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipLicenseDetails(
|
||||
environment: MothershipEnv,
|
||||
id?: string,
|
||||
name?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.licenseDetails(environment, id, name),
|
||||
queryFn: () =>
|
||||
mothershipPost('licenses/details', environment, {
|
||||
...(id ? { id } : {}),
|
||||
...(name ? { name } : {}),
|
||||
}),
|
||||
enabled: !!(id || name),
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenerateLicense(environment: MothershipEnv) {
|
||||
return useMutation({
|
||||
mutationFn: (params: { name: string; expirationDate?: string }) =>
|
||||
mothershipPost('licenses/generate', environment, params),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipEnterpriseStats(
|
||||
environment: MothershipEnv,
|
||||
customerType: string,
|
||||
start: string,
|
||||
end: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.enterpriseStats(environment, customerType, start, end),
|
||||
queryFn: () => mothershipPost('enterprise-stats', environment, { customerType, start, end }),
|
||||
enabled: !!customerType && !!start && !!end,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMothershipTrace(environment: MothershipEnv, requestId: string) {
|
||||
return useQuery({
|
||||
queryKey: mothershipKeys.trace(environment, requestId),
|
||||
queryFn: () => mothershipGet('traces', environment, { requestId }),
|
||||
enabled: !!requestId,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
export interface TaskMetadata {
|
||||
@@ -17,7 +18,7 @@ export interface TaskChatHistory {
|
||||
messages: PersistedMessage[]
|
||||
activeStreamId: string | null
|
||||
resources: MothershipResource[]
|
||||
streamSnapshot?: { events: unknown[]; status: string } | null
|
||||
streamSnapshot?: { events: StreamBatchEvent[]; status: string } | null
|
||||
}
|
||||
|
||||
export const taskKeys = {
|
||||
|
||||
102
apps/sim/lib/copilot/chat/terminal-state.test.ts
Normal file
102
apps/sim/lib/copilot/chat/terminal-state.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { selectLimit, selectWhere, selectFrom, select, updateWhere, updateSet, update } = vi.hoisted(
|
||||
() => {
|
||||
const selectLimit = vi.fn()
|
||||
const selectWhere = vi.fn(() => ({ limit: selectLimit }))
|
||||
const selectFrom = vi.fn(() => ({ where: selectWhere }))
|
||||
const select = vi.fn(() => ({ from: selectFrom }))
|
||||
|
||||
const updateWhere = vi.fn()
|
||||
const updateSet = vi.fn(() => ({ where: updateWhere }))
|
||||
const update = vi.fn(() => ({ set: updateSet }))
|
||||
|
||||
return {
|
||||
selectLimit,
|
||||
selectWhere,
|
||||
selectFrom,
|
||||
select,
|
||||
updateWhere,
|
||||
updateSet,
|
||||
update,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select,
|
||||
update,
|
||||
},
|
||||
}))
|
||||
|
||||
import { finalizeAssistantTurn } from './terminal-state'
|
||||
|
||||
describe('finalizeAssistantTurn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
updateWhere.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('appends the assistant message when the user turn is still last', async () => {
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
messages: [{ id: 'user-1', role: 'user', content: 'hello' }],
|
||||
},
|
||||
])
|
||||
|
||||
await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
assistantMessage: {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'hi',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
expect(updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updatedAt: expect.any(Date),
|
||||
conversationId: expect.anything(),
|
||||
messages: expect.anything(),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('only clears the active stream marker when a response is already persisted', async () => {
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'hello' },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'partial' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
assistantMessage: {
|
||||
id: 'assistant-2',
|
||||
role: 'assistant',
|
||||
content: 'final',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
const updateArg = updateSet.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(updateArg).toEqual(
|
||||
expect.objectContaining({
|
||||
updatedAt: expect.any(Date),
|
||||
conversationId: expect.anything(),
|
||||
})
|
||||
)
|
||||
expect(Object.hasOwn(updateArg, 'messages')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
createRunSegment,
|
||||
updateRunStatus,
|
||||
resetBuffer,
|
||||
scheduleBufferCleanup,
|
||||
allocateCursor,
|
||||
appendEvent,
|
||||
cleanupAbortMarker,
|
||||
@@ -20,6 +21,7 @@ const {
|
||||
createRunSegment: vi.fn(),
|
||||
updateRunStatus: vi.fn(),
|
||||
resetBuffer: vi.fn(),
|
||||
scheduleBufferCleanup: vi.fn(),
|
||||
allocateCursor: vi.fn(),
|
||||
appendEvent: vi.fn(),
|
||||
cleanupAbortMarker: vi.fn(),
|
||||
@@ -27,7 +29,7 @@ const {
|
||||
releasePendingChatStream: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/lifecycle/continue', () => ({
|
||||
vi.mock('@/lib/copilot/request/lifecycle/run', () => ({
|
||||
runCopilotLifecycle,
|
||||
}))
|
||||
|
||||
@@ -40,6 +42,7 @@ let mockPublisherController: ReadableStreamDefaultController | null = null
|
||||
|
||||
vi.mock('@/lib/copilot/request/session', () => ({
|
||||
resetBuffer,
|
||||
scheduleBufferCleanup,
|
||||
allocateCursor,
|
||||
appendEvent,
|
||||
cleanupAbortMarker,
|
||||
@@ -107,6 +110,7 @@ describe('createSSEStream terminal error handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetBuffer.mockResolvedValue(undefined)
|
||||
scheduleBufferCleanup.mockResolvedValue(undefined)
|
||||
allocateCursor
|
||||
.mockResolvedValueOnce({ seq: 1, cursor: '1' })
|
||||
.mockResolvedValueOnce({ seq: 2, cursor: '2' })
|
||||
@@ -149,6 +153,7 @@ describe('createSSEStream terminal error handling', () => {
|
||||
type: MothershipStreamV1EventType.error,
|
||||
})
|
||||
)
|
||||
expect(scheduleBufferCleanup).toHaveBeenCalledWith('stream-1')
|
||||
})
|
||||
|
||||
it('writes the thrown terminal error event before close for replay durability', async () => {
|
||||
@@ -175,5 +180,6 @@ describe('createSSEStream terminal error handling', () => {
|
||||
type: MothershipStreamV1EventType.error,
|
||||
})
|
||||
)
|
||||
expect(scheduleBufferCleanup).toHaveBeenCalledWith('stream-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
releasePendingChatStream,
|
||||
resetBuffer,
|
||||
StreamWriter,
|
||||
scheduleBufferCleanup,
|
||||
startAbortPoller,
|
||||
unregisterActiveStream,
|
||||
} from '@/lib/copilot/request/session'
|
||||
@@ -205,6 +206,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
if (chatId) {
|
||||
await releasePendingChatStream(chatId, streamId)
|
||||
}
|
||||
await scheduleBufferCleanup(streamId)
|
||||
await cleanupAbortMarker(streamId)
|
||||
|
||||
const trace = collector.build({
|
||||
|
||||
@@ -106,7 +106,13 @@ vi.mock('@/lib/core/config/redis', () => ({
|
||||
getRedisClient: () => mockRedis,
|
||||
}))
|
||||
|
||||
import { allocateCursor, appendEvent, readEvents } from '@/lib/copilot/request/session/buffer'
|
||||
import {
|
||||
allocateCursor,
|
||||
appendEvent,
|
||||
clearBuffer,
|
||||
readEvents,
|
||||
scheduleBufferCleanup,
|
||||
} from '@/lib/copilot/request/session/buffer'
|
||||
|
||||
describe('mothership-stream-outbox', () => {
|
||||
beforeEach(() => {
|
||||
@@ -114,7 +120,7 @@ describe('mothership-stream-outbox', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.concurrent('replays envelopes after a given cursor', async () => {
|
||||
it('replays envelopes after a given cursor', async () => {
|
||||
const firstCursor = await allocateCursor('stream-1')
|
||||
const secondCursor = await allocateCursor('stream-1')
|
||||
|
||||
@@ -145,4 +151,66 @@ describe('mothership-stream-outbox', () => {
|
||||
const replayed = await readEvents('stream-1', '1')
|
||||
expect(replayed.map((entry) => entry.payload.text)).toEqual(['world'])
|
||||
})
|
||||
|
||||
it('does not trim active stream history while appending events', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRedis.zremrangebyrank).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears persisted stream state during teardown cleanup', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
expect((await readEvents('stream-1', '0')).length).toBe(1)
|
||||
|
||||
await clearBuffer('stream-1')
|
||||
|
||||
expect(await readEvents('stream-1', '0')).toEqual([])
|
||||
})
|
||||
|
||||
it('shortens completed stream retention without deleting replay data immediately', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
await scheduleBufferCleanup('stream-1', 30)
|
||||
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:events', 30)
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:seq', 30)
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:abort', 30)
|
||||
expect((await readEvents('stream-1', '0')).map((entry) => entry.payload.text)).toEqual([
|
||||
'hello',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ const logger = createLogger('SessionBuffer')
|
||||
|
||||
const STREAM_OUTBOX_PREFIX = 'mothership_stream:'
|
||||
const DEFAULT_TTL_SECONDS = 60 * 60
|
||||
const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60
|
||||
const DEFAULT_EVENT_LIMIT = 5_000
|
||||
const RETRY_DELAYS_MS = [0, 50, 150] as const
|
||||
|
||||
@@ -97,11 +98,36 @@ export async function allocateCursor(streamId: string): Promise<{
|
||||
}
|
||||
|
||||
export async function resetBuffer(streamId: string): Promise<void> {
|
||||
await withRedisRetry({ operation: 'reset_outbox', streamId }, async (redis) => {
|
||||
await clearBuffer(streamId, 'reset_outbox')
|
||||
}
|
||||
|
||||
export async function clearBuffer(streamId: string, operation = 'clear_outbox'): Promise<void> {
|
||||
await withRedisRetry({ operation, streamId }, async (redis) => {
|
||||
await redis.del(getEventsKey(streamId), getSeqKey(streamId), getAbortKey(streamId))
|
||||
})
|
||||
}
|
||||
|
||||
export async function scheduleBufferCleanup(
|
||||
streamId: string,
|
||||
ttlSeconds = DEFAULT_COMPLETED_TTL_SECONDS
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withRedisRetry({ operation: 'schedule_outbox_cleanup', streamId }, async (redis) => {
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.expire(getEventsKey(streamId), ttlSeconds)
|
||||
pipeline.expire(getSeqKey(streamId), ttlSeconds)
|
||||
pipeline.expire(getAbortKey(streamId), ttlSeconds)
|
||||
await pipeline.exec()
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to shorten stream buffer TTL during cleanup', {
|
||||
streamId,
|
||||
ttlSeconds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendEvents(
|
||||
envelopes: MothershipStreamV1EventEnvelope[]
|
||||
): Promise<MothershipStreamV1EventEnvelope[]> {
|
||||
@@ -123,9 +149,6 @@ export async function appendEvents(
|
||||
pipeline.zadd(key, ...(zaddArgs as [number, string, ...Array<number | string>]))
|
||||
pipeline.expire(key, config.ttlSeconds)
|
||||
pipeline.set(seqKey, String(envelopes[envelopes.length - 1].seq), 'EX', config.ttlSeconds)
|
||||
if (config.eventLimit > 0) {
|
||||
pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1)
|
||||
}
|
||||
await pipeline.exec()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,12 +14,14 @@ export {
|
||||
appendEvent,
|
||||
appendEvents,
|
||||
clearAbortMarker,
|
||||
clearBuffer,
|
||||
getLatestSeq,
|
||||
getOldestSeq,
|
||||
hasAbortMarker,
|
||||
InvalidCursorError,
|
||||
readEvents,
|
||||
resetBuffer,
|
||||
scheduleBufferCleanup,
|
||||
writeAbortMarker,
|
||||
} from './buffer'
|
||||
export { createEvent, eventToStreamEvent, isEventRecord, TOOL_CALL_STATUS } from './event'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MothershipStreamV1EventEnvelope,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1StreamScope,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
@@ -8,3 +9,17 @@ export interface StreamEvent {
|
||||
payload: Record<string, unknown>
|
||||
scope?: MothershipStreamV1StreamScope
|
||||
}
|
||||
|
||||
export interface StreamBatchEvent {
|
||||
eventId: number
|
||||
streamId: string
|
||||
event: MothershipStreamV1EventEnvelope
|
||||
}
|
||||
|
||||
export function toStreamBatchEvent(envelope: MothershipStreamV1EventEnvelope): StreamBatchEvent {
|
||||
return {
|
||||
eventId: envelope.seq,
|
||||
streamId: envelope.stream.streamId,
|
||||
event: envelope,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,12 @@ export const env = createEnv({
|
||||
// Admin API
|
||||
ADMIN_API_KEY: z.string().min(32).optional(), // Admin API key for self-hosted GitOps access (generate with: openssl rand -hex 32)
|
||||
|
||||
// Mothership Admin
|
||||
MOTHERSHIP_API_ADMIN_KEY: z.string().min(1).optional(), // Admin API key for mothership/copilot admin endpoints
|
||||
MOTHERSHIP_DEV_URL: z.string().url().optional(), // Mothership dev environment URL
|
||||
MOTHERSHIP_STAGING_URL: z.string().url().optional(), // Mothership staging environment URL
|
||||
MOTHERSHIP_PROD_URL: z.string().url().optional(), // Mothership production environment URL
|
||||
|
||||
// Infrastructure & Deployment
|
||||
NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment
|
||||
DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment
|
||||
|
||||
Reference in New Issue
Block a user