Skip to content

Commit 8db8a22

Browse files
committed
feat(admin): add smoke test page and API route
1 parent 1075add commit 8db8a22

4 files changed

Lines changed: 182 additions & 0 deletions

File tree

apps/admin/next.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ config({ path: '../../.env' })
66
const nextConfig: NextConfig = {
77
reactStrictMode: true,
88
transpilePackages: ['@sandchest/contract', '@sandchest/db'],
9+
experimental: {
10+
externalDir: true,
11+
},
912
serverExternalPackages: ['ssh2', 'cpu-features'],
1013
webpack: (config) => {
1114
config.resolve.extensionAlias = {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextResponse } from 'next/server'
2+
import {
3+
runSandboxSmokeTest,
4+
type SmokeProfile,
5+
} from '../../../../../../packages/admin-cli/src/sandbox-smoke.js'
6+
7+
export const runtime = 'nodejs'
8+
export const dynamic = 'force-dynamic'
9+
export const maxDuration = 300
10+
11+
const DEFAULT_BASE_URL = 'https://api.sandchest.com'
12+
13+
function getSmokeConfig() {
14+
const apiKey =
15+
process.env['SANDCHEST_SMOKE_API_KEY']?.trim() ||
16+
process.env['SANDCHEST_API_KEY']?.trim()
17+
18+
if (!apiKey) {
19+
throw new Error(
20+
'Missing SANDCHEST_SMOKE_API_KEY or SANDCHEST_API_KEY for admin smoke runs.',
21+
)
22+
}
23+
24+
const ttlValue = process.env['SANDCHEST_SMOKE_TTL_SECONDS']
25+
const ttlSeconds = ttlValue ? Number.parseInt(ttlValue, 10) : undefined
26+
27+
return {
28+
apiKey,
29+
baseUrl:
30+
process.env['SANDCHEST_SMOKE_BASE_URL']?.trim() ||
31+
process.env['SANDCHEST_BASE_URL']?.trim() ||
32+
DEFAULT_BASE_URL,
33+
image: process.env['SANDCHEST_SMOKE_IMAGE']?.trim() || undefined,
34+
profile: (process.env['SANDCHEST_SMOKE_PROFILE']?.trim() || undefined) as
35+
| SmokeProfile
36+
| undefined,
37+
ttlSeconds,
38+
}
39+
}
40+
41+
export async function POST() {
42+
try {
43+
const result = await runSandboxSmokeTest(getSmokeConfig())
44+
return NextResponse.json(result)
45+
} catch (error) {
46+
const message = error instanceof Error ? error.message : 'Unknown error'
47+
return NextResponse.json({ error: message }, { status: 500 })
48+
}
49+
}

apps/admin/src/app/smoke/page.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
5+
interface SmokeCheck {
6+
name: string
7+
durationMs: number
8+
}
9+
10+
interface SmokeResult {
11+
runId: string
12+
baseUrl: string
13+
rootSandboxId: string
14+
forkSandboxId: string
15+
checks: SmokeCheck[]
16+
}
17+
18+
export default function SmokePage() {
19+
const [isRunning, setIsRunning] = useState(false)
20+
const [error, setError] = useState<string | null>(null)
21+
const [result, setResult] = useState<SmokeResult | null>(null)
22+
23+
async function handleRun() {
24+
setIsRunning(true)
25+
setError(null)
26+
setResult(null)
27+
28+
try {
29+
const response = await fetch('/api/smoke', { method: 'POST' })
30+
const body = (await response.json().catch(() => ({ error: 'Request failed' }))) as
31+
| SmokeResult
32+
| { error: string }
33+
34+
if (!response.ok || 'error' in body) {
35+
throw new Error('error' in body ? body.error : `Request failed (${response.status})`)
36+
}
37+
38+
setResult(body)
39+
} catch (runError) {
40+
setError(runError instanceof Error ? runError.message : 'Unknown error')
41+
} finally {
42+
setIsRunning(false)
43+
}
44+
}
45+
46+
return (
47+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
48+
<div className="page-header">
49+
<h1 className="page-title">Sandbox Smoke</h1>
50+
</div>
51+
52+
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
53+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
54+
<div className="card-title">Production Lifecycle Test</div>
55+
<p className="text-weak" style={{ fontSize: '0.8125rem', lineHeight: 1.6 }}>
56+
Runs the full sandbox lifecycle from the admin server using configured environment
57+
credentials. The flow creates live sandboxes, exercises exec/session/file/fork paths,
58+
and attempts cleanup in all cases.
59+
</p>
60+
</div>
61+
62+
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
63+
<button className="btn btn-primary" onClick={handleRun} disabled={isRunning}>
64+
{isRunning ? 'Running smoke test...' : 'Run smoke test'}
65+
</button>
66+
<span className="text-weak" style={{ fontSize: '0.75rem' }}>
67+
Uses `SANDCHEST_SMOKE_API_KEY` or `SANDCHEST_API_KEY` on the admin server.
68+
</span>
69+
</div>
70+
</div>
71+
72+
{error ? (
73+
<div className="card feedback-card feedback-danger">
74+
Smoke test failed: {error}
75+
</div>
76+
) : null}
77+
78+
{result ? (
79+
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
80+
<div className="card-header" style={{ marginBottom: 0 }}>
81+
<div>
82+
<div className="card-title">Last Run</div>
83+
<div className="card-subtitle">{result.runId}</div>
84+
</div>
85+
<span className="badge badge-online">
86+
<span className="badge-dot" />
87+
passed
88+
</span>
89+
</div>
90+
91+
<div className="card-metrics">
92+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', fontSize: '0.8125rem' }}>
93+
<span className="text-weak">Base URL</span>
94+
<span>{result.baseUrl}</span>
95+
</div>
96+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', fontSize: '0.8125rem' }}>
97+
<span className="text-weak">Root sandbox</span>
98+
<span>{result.rootSandboxId}</span>
99+
</div>
100+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', fontSize: '0.8125rem' }}>
101+
<span className="text-weak">Fork sandbox</span>
102+
<span>{result.forkSandboxId}</span>
103+
</div>
104+
</div>
105+
106+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
107+
<div className="card-section-title">Checks</div>
108+
{result.checks.map((check) => (
109+
<div
110+
key={check.name}
111+
style={{
112+
display: 'flex',
113+
justifyContent: 'space-between',
114+
gap: '1rem',
115+
fontSize: '0.8125rem',
116+
paddingTop: '0.5rem',
117+
borderTop: '1px solid var(--color-border-weak)',
118+
}}
119+
>
120+
<span>{check.name}</span>
121+
<span className="text-weak">{check.durationMs}ms</span>
122+
</div>
123+
))}
124+
</div>
125+
</div>
126+
) : null}
127+
</div>
128+
)
129+
}

apps/admin/src/components/AdminShell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation'
66
const NAV_ITEMS = [
77
{ href: '/servers', label: 'Servers' },
88
{ href: '/status', label: 'Status' },
9+
{ href: '/smoke', label: 'Smoke' },
910
{ href: '/simulate', label: 'Simulate' },
1011
]
1112

0 commit comments

Comments
 (0)