Skip to content

Commit a5e7abb

Browse files
committed
fix: return proper HTTP errors from middleware instead of Effect.fail
Middleware errors (auth, rate-limit) were using Effect.fail() which bypassed the router's catchAll handler, causing empty 500 responses. Also adds admin status route, admin dashboard status page, redis status CLI command, and updates API URL defaults to api.sandchest.com.
1 parent 6e2ed7d commit a5e7abb

40 files changed

Lines changed: 575 additions & 77 deletions

apps/admin/next.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ config({ path: '../../.env' })
66
const nextConfig: NextConfig = {
77
reactStrictMode: true,
88
transpilePackages: ['@sandchest/contract', '@sandchest/db'],
9+
serverExternalPackages: ['ssh2', 'cpu-features'],
10+
webpack: (config) => {
11+
config.resolve.extensionAlias = {
12+
'.js': ['.ts', '.tsx', '.js'],
13+
}
14+
return config
15+
},
916
}
1017

1118
export default nextConfig

apps/admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"dev": "next dev --turbopack -p 3003",
7+
"dev": "next dev -p 3003",
88
"build": "next build",
99
"start": "next start",
1010
"typecheck": "tsc --noEmit",

apps/admin/src/app/api/servers/[serverId]/action/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function POST(
4646

4747
// Call the admin API to update the node status
4848
const apiToken = process.env.ADMIN_API_TOKEN
49-
const apiUrl = process.env.API_URL ?? 'http://localhost:3001'
49+
const apiUrl = process.env.API_URL ?? 'https://api.sandchest.com'
5050
const nodeIdHex = Buffer.from(server.nodeId).toString('hex')
5151

5252
// Try to call the control plane API
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextResponse } from 'next/server'
2+
3+
export async function GET() {
4+
const apiToken = process.env.ADMIN_API_TOKEN
5+
const apiUrl = process.env.API_URL ?? 'https://api.sandchest.com'
6+
7+
try {
8+
const res = await fetch(`${apiUrl}/v1/admin/status`, {
9+
headers: {
10+
...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}),
11+
},
12+
// Don't cache — always fetch fresh
13+
cache: 'no-store',
14+
})
15+
16+
if (!res.ok) {
17+
const data = await res.json().catch(() => ({ error: 'Unknown error' })) as { error?: string }
18+
return NextResponse.json(
19+
{ api: { status: 'error' }, error: data.error ?? `HTTP ${res.status}` },
20+
{ status: 200 },
21+
)
22+
}
23+
24+
const data = await res.json()
25+
return NextResponse.json(data)
26+
} catch {
27+
return NextResponse.json({
28+
api: { status: 'unreachable', uptime_seconds: 0, version: 'unknown', draining: false },
29+
redis: { status: 'unknown' },
30+
workers: [],
31+
nodes: [],
32+
})
33+
}
34+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import AdminShell from '@/components/AdminShell'
2+
3+
export default function StatusLayout({ children }: { children: React.ReactNode }) {
4+
return <AdminShell>{children}</AdminShell>
5+
}

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

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
'use client'
2+
3+
import { useStatus } from '@/hooks/use-status'
4+
5+
function formatUptime(seconds: number): string {
6+
const d = Math.floor(seconds / 86400)
7+
const h = Math.floor((seconds % 86400) / 3600)
8+
const m = Math.floor((seconds % 3600) / 60)
9+
const s = seconds % 60
10+
const parts: string[] = []
11+
if (d > 0) parts.push(`${d}d`)
12+
if (h > 0) parts.push(`${h}h`)
13+
if (m > 0) parts.push(`${m}m`)
14+
parts.push(`${s}s`)
15+
return parts.join(' ')
16+
}
17+
18+
function StatusBadge({ status }: { status: string }) {
19+
const badgeClass =
20+
status === 'ok' || status === 'online'
21+
? 'badge-online'
22+
: status === 'draining'
23+
? 'badge-draining'
24+
: status === 'fail' || status === 'error' || status === 'offline' || status === 'disabled'
25+
? 'badge-offline'
26+
: 'badge-pending'
27+
28+
return (
29+
<span className={`badge ${badgeClass}`}>
30+
<span className="badge-dot" />
31+
{status}
32+
</span>
33+
)
34+
}
35+
36+
function SectionSkeleton() {
37+
return (
38+
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
39+
<div className="skeleton skeleton-text" style={{ width: '40%' }} />
40+
<div className="skeleton skeleton-text" style={{ width: '60%' }} />
41+
<div className="skeleton skeleton-text" style={{ width: '50%' }} />
42+
</div>
43+
)
44+
}
45+
46+
export default function StatusPage() {
47+
const { data, isLoading, error } = useStatus()
48+
49+
return (
50+
<>
51+
<div className="page-header">
52+
<h1 className="page-title">System Status</h1>
53+
</div>
54+
55+
{error ? (
56+
<div className="card feedback-card feedback-danger">
57+
Failed to fetch system status
58+
</div>
59+
) : null}
60+
61+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
62+
{/* API Section */}
63+
{isLoading ? (
64+
<SectionSkeleton />
65+
) : data ? (
66+
<div className="card">
67+
<div className="card-header">
68+
<span className="card-title">API</span>
69+
<StatusBadge status={data.api.status} />
70+
</div>
71+
{data.api.status === 'unreachable' || !data.api.uptime_seconds ? (
72+
<p style={{ fontSize: '0.8125rem' }} className="text-weak">
73+
{data.api.status === 'unreachable' ? 'Control plane is unreachable' : `API status: ${data.api.status}`}
74+
</p>
75+
) : (
76+
<div className="card-metrics">
77+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem' }}>
78+
<span className="text-weak">Uptime</span>
79+
<span>{formatUptime(data.api.uptime_seconds)}</span>
80+
</div>
81+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem' }}>
82+
<span className="text-weak">Version</span>
83+
<span>{data.api.version}</span>
84+
</div>
85+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem' }}>
86+
<span className="text-weak">Draining</span>
87+
<span>{data.api.draining ? 'Yes' : 'No'}</span>
88+
</div>
89+
</div>
90+
)}
91+
</div>
92+
) : null}
93+
94+
{/* Redis Section */}
95+
{isLoading ? (
96+
<SectionSkeleton />
97+
) : data?.redis ? (
98+
<div className="card">
99+
<div className="card-header">
100+
<span className="card-title">Redis</span>
101+
<StatusBadge status={data.redis.status} />
102+
</div>
103+
</div>
104+
) : null}
105+
106+
{/* Workers Section */}
107+
{isLoading ? (
108+
<SectionSkeleton />
109+
) : data?.workers ? (
110+
<div className="card">
111+
<div className="card-header" style={{ marginBottom: '0.5rem' }}>
112+
<span className="card-title">Workers</span>
113+
<span style={{ fontSize: '0.75rem' }} className="text-weak">
114+
{data.workers.filter((w) => w.active).length}/{data.workers.length} active
115+
</span>
116+
</div>
117+
{data.workers.length === 0 ? (
118+
<p style={{ fontSize: '0.8125rem' }} className="text-weak">
119+
No worker data available
120+
</p>
121+
) : (
122+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
123+
{data.workers.map((worker) => (
124+
<div
125+
key={worker.name}
126+
style={{
127+
display: 'flex',
128+
justifyContent: 'space-between',
129+
alignItems: 'center',
130+
fontSize: '0.8125rem',
131+
padding: '0.375rem 0',
132+
borderBottom: '1px solid var(--color-border-weak)',
133+
}}
134+
>
135+
<span>{worker.name}</span>
136+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
137+
{worker.active && worker.ttl_ms > 0 ? (
138+
<span className="text-weak" style={{ fontSize: '0.75rem' }}>
139+
TTL {Math.round(worker.ttl_ms / 1000)}s
140+
</span>
141+
) : null}
142+
<StatusBadge status={worker.active ? 'ok' : 'offline'} />
143+
</div>
144+
</div>
145+
))}
146+
</div>
147+
)}
148+
</div>
149+
) : null}
150+
151+
{/* Nodes Section */}
152+
{isLoading ? (
153+
<SectionSkeleton />
154+
) : data?.nodes ? (
155+
<div className="card">
156+
<div className="card-header" style={{ marginBottom: '0.5rem' }}>
157+
<span className="card-title">Nodes</span>
158+
<span style={{ fontSize: '0.75rem' }} className="text-weak">
159+
{data.nodes.length} registered
160+
</span>
161+
</div>
162+
{data.nodes.length === 0 ? (
163+
<p style={{ fontSize: '0.8125rem' }} className="text-weak">
164+
No nodes registered
165+
</p>
166+
) : (
167+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
168+
{data.nodes.map((node) => (
169+
<div
170+
key={node.id}
171+
style={{
172+
display: 'flex',
173+
justifyContent: 'space-between',
174+
alignItems: 'center',
175+
fontSize: '0.8125rem',
176+
padding: '0.375rem 0',
177+
borderBottom: '1px solid var(--color-border-weak)',
178+
}}
179+
>
180+
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
181+
{node.id}
182+
</span>
183+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
184+
<StatusBadge status={node.status} />
185+
<span
186+
className={`badge ${node.heartbeat_active ? 'badge-online' : 'badge-offline'}`}
187+
>
188+
<span className="badge-dot" />
189+
{node.heartbeat_active ? 'heartbeat' : 'no heartbeat'}
190+
</span>
191+
</div>
192+
</div>
193+
))}
194+
</div>
195+
)}
196+
</div>
197+
) : null}
198+
</div>
199+
</>
200+
)
201+
}

apps/admin/src/components/AdminShell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'
55

66
const NAV_ITEMS = [
77
{ href: '/servers', label: 'Servers' },
8+
{ href: '/status', label: 'Status' },
89
]
910

1011
export default function AdminShell({ children }: { children: React.ReactNode }) {

apps/admin/src/hooks/use-status.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client'
2+
3+
import { useQuery } from '@tanstack/react-query'
4+
5+
export interface SystemStatus {
6+
api: {
7+
status: 'ok' | 'error' | 'unreachable'
8+
uptime_seconds: number
9+
version: string
10+
draining: boolean
11+
}
12+
redis: {
13+
status: 'ok' | 'fail' | 'unknown'
14+
}
15+
workers: Array<{
16+
name: string
17+
active: boolean
18+
ttl_ms: number
19+
}>
20+
nodes: Array<{
21+
id: string
22+
status: string
23+
heartbeat_active: boolean
24+
}>
25+
error?: string
26+
}
27+
28+
async function fetchStatus(): Promise<SystemStatus> {
29+
const res = await fetch('/api/status')
30+
if (!res.ok) throw new Error('Failed to fetch status')
31+
return res.json() as Promise<SystemStatus>
32+
}
33+
34+
export function useStatus() {
35+
return useQuery({
36+
queryKey: ['system-status'],
37+
queryFn: fetchStatus,
38+
refetchInterval: 15_000,
39+
})
40+
}

apps/api/src/e2e-http.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ describe('HTTP E2E: sandbox CRUD', () => {
295295

296296
const body = (await res.json()) as { sandbox_id: string; status: string }
297297
expect(body.sandbox_id).toBe(id)
298-
expect(body.status).toBe('stopping')
298+
expect(body.status).toBe('stopped')
299299
})
300300

301301
test('delete sandbox returns 200', async () => {

apps/api/src/e2e.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ describe('E2E: sandbox lifecycle — create, exec, session, file, stop', () => {
361361
status: string
362362
}
363363

364-
expect(stopRes.status).toBe(200)
364+
expect(stopRes.status).toBe(202)
365365
expect(stopped.sandbox_id).toBe(sandboxId)
366366
expect(stopped.status).toBe('stopped')
367367

0 commit comments

Comments
 (0)