Skip to content

Commit 345cb64

Browse files
committed
feat(admin): add admin dashboard app
1 parent 6b7d7dd commit 345cb64

41 files changed

Lines changed: 3182 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/admin/next.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { config } from 'dotenv'
2+
import type { NextConfig } from 'next'
3+
4+
config({ path: '../../.env' })
5+
6+
const nextConfig: NextConfig = {
7+
reactStrictMode: true,
8+
transpilePackages: ['@sandchest/contract', '@sandchest/db'],
9+
}
10+
11+
export default nextConfig

apps/admin/package.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "@sandchest/admin",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "next dev --turbopack -p 3003",
8+
"build": "next build",
9+
"start": "next start",
10+
"typecheck": "tsc --noEmit",
11+
"lint": "next lint"
12+
},
13+
"dependencies": {
14+
"@sandchest/contract": "workspace:*",
15+
"@sandchest/db": "workspace:*",
16+
"@tanstack/react-query": "^5.90.21",
17+
"cpu-features": "^0.0.10",
18+
"dotenv": "^17.3.1",
19+
"drizzle-orm": "^0.39.0",
20+
"jose": "^6.0.11",
21+
"mysql2": "^3.11.0",
22+
"next": "^15.3.3",
23+
"react": "^19.2.4",
24+
"react-dom": "^19.2.4",
25+
"server-only": "^0.0.1",
26+
"ssh2": "^1.16.0"
27+
},
28+
"devDependencies": {
29+
"@tailwindcss/postcss": "^4.0.0",
30+
"@types/bun": "^1.3.9",
31+
"@types/node": "^25.3.0",
32+
"@types/react": "^19.2.14",
33+
"@types/react-dom": "^19.2.3",
34+
"@types/ssh2": "^1.15.5",
35+
"tailwindcss": "^4.0.0",
36+
"typescript": "^5.7.0"
37+
}
38+
}

apps/admin/postcss.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const config = {
2+
plugins: {
3+
'@tailwindcss/postcss': {},
4+
},
5+
}
6+
7+
export default config
69.3 KB
Binary file not shown.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { NextResponse } from 'next/server'
2+
import { SESSION_COOKIE } from '@/lib/auth'
3+
4+
export async function POST() {
5+
const response = NextResponse.json({ ok: true })
6+
response.cookies.delete(SESSION_COOKIE)
7+
return response
8+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { NextResponse } from 'next/server'
2+
import { validatePassword, createSessionToken, SESSION_COOKIE } from '@/lib/auth'
3+
4+
export async function POST(request: Request) {
5+
const body = await request.json() as { password?: string }
6+
const password = body.password
7+
8+
if (typeof password !== 'string' || !validatePassword(password)) {
9+
return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
10+
}
11+
12+
const token = await createSessionToken()
13+
const response = NextResponse.json({ ok: true })
14+
15+
response.cookies.set(SESSION_COOKIE, token, {
16+
httpOnly: true,
17+
secure: process.env.NODE_ENV === 'production',
18+
sameSite: 'lax',
19+
path: '/',
20+
maxAge: 60 * 60 * 24, // 24 hours
21+
})
22+
23+
return response
24+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextResponse } from 'next/server'
2+
import { eq } from 'drizzle-orm'
3+
import { getDb } from '@/lib/db'
4+
import { adminServers } from '@sandchest/db/schema'
5+
import { decrypt } from '@/lib/encryption'
6+
import { createSshConnection, execCommand } from '@/lib/ssh'
7+
8+
export async function POST(
9+
request: Request,
10+
{ params }: { params: Promise<{ serverId: string }> },
11+
) {
12+
const { serverId } = await params
13+
const body = await request.json() as { command?: string }
14+
15+
if (!body.command || typeof body.command !== 'string') {
16+
return NextResponse.json({ error: 'command is required' }, { status: 400 })
17+
}
18+
19+
const db = getDb()
20+
const [server] = await db
21+
.select()
22+
.from(adminServers)
23+
.where(eq(adminServers.id, Buffer.from(serverId, 'hex') as unknown as Uint8Array))
24+
.limit(1)
25+
26+
if (!server) {
27+
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
28+
}
29+
30+
let sshKey: string
31+
try {
32+
sshKey = decrypt(server.sshKeyEncrypted, server.sshKeyIv, server.sshKeyTag)
33+
} catch {
34+
return NextResponse.json({ error: 'Failed to decrypt SSH key' }, { status: 500 })
35+
}
36+
37+
const start = Date.now()
38+
39+
let conn
40+
try {
41+
conn = await createSshConnection({
42+
host: server.ip,
43+
port: server.sshPort,
44+
username: server.sshUser,
45+
privateKey: sshKey,
46+
})
47+
} catch (err) {
48+
return NextResponse.json(
49+
{
50+
stdout: '',
51+
stderr: `SSH connection failed: ${err instanceof Error ? err.message : String(err)}`,
52+
exit_code: -1,
53+
duration_ms: Date.now() - start,
54+
},
55+
{ status: 200 },
56+
)
57+
}
58+
59+
try {
60+
const result = await execCommand(conn, body.command)
61+
conn.end()
62+
63+
return NextResponse.json({
64+
stdout: result.stdout,
65+
stderr: result.stderr,
66+
exit_code: result.code,
67+
duration_ms: Date.now() - start,
68+
})
69+
} catch (err) {
70+
conn.end()
71+
return NextResponse.json({
72+
stdout: '',
73+
stderr: `Command execution error: ${err instanceof Error ? err.message : String(err)}`,
74+
exit_code: -1,
75+
duration_ms: Date.now() - start,
76+
})
77+
}
78+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { NextResponse } from 'next/server'
2+
import { eq } from 'drizzle-orm'
3+
import { getDb } from '@/lib/db'
4+
import { adminServers } from '@sandchest/db/schema'
5+
import { decrypt } from '@/lib/encryption'
6+
import { createSshConnection, execCommand } from '@/lib/ssh'
7+
import { PROVISION_STEPS, type StepResult } from '@/lib/provisioner'
8+
9+
export async function POST(
10+
_request: Request,
11+
{ params }: { params: Promise<{ serverId: string }> },
12+
) {
13+
const { serverId } = await params
14+
const db = getDb()
15+
const serverIdBuf = Buffer.from(serverId, 'hex') as unknown as Uint8Array
16+
17+
const [server] = await db
18+
.select()
19+
.from(adminServers)
20+
.where(eq(adminServers.id, serverIdBuf))
21+
.limit(1)
22+
23+
if (!server) {
24+
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
25+
}
26+
27+
if (server.provisionStatus !== 'failed') {
28+
return NextResponse.json({ error: 'Can only retry from failed state' }, { status: 409 })
29+
}
30+
31+
// Find the failed step index (PlanetScale may return JSON columns as strings)
32+
const raw = server.provisionSteps
33+
const steps: StepResult[] = Array.isArray(raw)
34+
? raw as StepResult[]
35+
: typeof raw === 'string'
36+
? JSON.parse(raw) as StepResult[]
37+
: []
38+
const failedIndex = steps.findIndex((s) => s.status === 'failed')
39+
if (failedIndex === -1) {
40+
return NextResponse.json({ error: 'No failed step found' }, { status: 400 })
41+
}
42+
43+
// Decrypt SSH key
44+
let sshKey: string
45+
try {
46+
sshKey = decrypt(server.sshKeyEncrypted, server.sshKeyIv, server.sshKeyTag)
47+
} catch {
48+
return NextResponse.json({ error: 'Failed to decrypt SSH key' }, { status: 500 })
49+
}
50+
51+
// Reset failed and subsequent steps to pending
52+
for (let i = failedIndex; i < steps.length; i++) {
53+
steps[i] = { id: steps[i]!.id, status: 'pending' }
54+
}
55+
56+
await db
57+
.update(adminServers)
58+
.set({
59+
provisionStatus: 'provisioning',
60+
provisionStep: PROVISION_STEPS[failedIndex]!.id,
61+
provisionSteps: steps,
62+
provisionError: null,
63+
updatedAt: new Date(),
64+
})
65+
.where(eq(adminServers.id, serverIdBuf))
66+
67+
// Run remaining steps in background
68+
retryProvisioning(serverId, server.ip, server.sshPort, server.sshUser, sshKey, steps, failedIndex).catch(
69+
() => {},
70+
)
71+
72+
return NextResponse.json({ status: 'provisioning', retry_from: PROVISION_STEPS[failedIndex]!.id })
73+
}
74+
75+
async function retryProvisioning(
76+
serverId: string,
77+
ip: string,
78+
port: number,
79+
username: string,
80+
privateKey: string,
81+
stepResults: StepResult[],
82+
startIndex: number,
83+
) {
84+
const db = getDb()
85+
const serverIdBuf = Buffer.from(serverId, 'hex') as unknown as Uint8Array
86+
87+
let conn
88+
try {
89+
conn = await createSshConnection({ host: ip, port, username, privateKey })
90+
} catch (err) {
91+
await db
92+
.update(adminServers)
93+
.set({
94+
provisionStatus: 'failed',
95+
provisionError: `SSH connection failed: ${err instanceof Error ? err.message : String(err)}`,
96+
updatedAt: new Date(),
97+
})
98+
.where(eq(adminServers.id, serverIdBuf))
99+
return
100+
}
101+
102+
for (let i = startIndex; i < PROVISION_STEPS.length; i++) {
103+
const step = PROVISION_STEPS[i]!
104+
105+
stepResults[i] = { id: step.id, status: 'running' }
106+
await db
107+
.update(adminServers)
108+
.set({
109+
provisionStep: step.id,
110+
provisionSteps: [...stepResults],
111+
updatedAt: new Date(),
112+
})
113+
.where(eq(adminServers.id, serverIdBuf))
114+
115+
const fullCommand = step.commands.join(' && ')
116+
117+
try {
118+
const result = await execCommand(conn, fullCommand)
119+
const output = result.stdout + (result.stderr ? `\n${result.stderr}` : '')
120+
121+
if (result.code !== 0) {
122+
stepResults[i] = { id: step.id, status: 'failed', output: output.trim() }
123+
await db
124+
.update(adminServers)
125+
.set({
126+
provisionStatus: 'failed',
127+
provisionStep: step.id,
128+
provisionSteps: [...stepResults],
129+
provisionError: `Step "${step.name}" failed with exit code ${result.code}`,
130+
updatedAt: new Date(),
131+
})
132+
.where(eq(adminServers.id, serverIdBuf))
133+
conn.end()
134+
return
135+
}
136+
137+
let fullOutput = output
138+
if (step.validate) {
139+
const valResult = await execCommand(conn, step.validate)
140+
if (valResult.code !== 0) {
141+
stepResults[i] = { id: step.id, status: 'failed', output: `Validation failed: ${valResult.stderr || valResult.stdout}`.trim() }
142+
await db
143+
.update(adminServers)
144+
.set({
145+
provisionStatus: 'failed',
146+
provisionStep: step.id,
147+
provisionSteps: [...stepResults],
148+
provisionError: `Validation for "${step.name}" failed`,
149+
updatedAt: new Date(),
150+
})
151+
.where(eq(adminServers.id, serverIdBuf))
152+
conn.end()
153+
return
154+
}
155+
fullOutput += `\nValidation: ${valResult.stdout.trim()}`
156+
}
157+
158+
stepResults[i] = { id: step.id, status: 'completed', output: fullOutput.trim() }
159+
} catch (err) {
160+
stepResults[i] = { id: step.id, status: 'failed', output: `Error: ${err instanceof Error ? err.message : String(err)}` }
161+
await db
162+
.update(adminServers)
163+
.set({
164+
provisionStatus: 'failed',
165+
provisionStep: step.id,
166+
provisionSteps: [...stepResults],
167+
provisionError: `Step "${step.name}" threw: ${err instanceof Error ? err.message : String(err)}`,
168+
updatedAt: new Date(),
169+
})
170+
.where(eq(adminServers.id, serverIdBuf))
171+
conn.end()
172+
return
173+
}
174+
175+
await db
176+
.update(adminServers)
177+
.set({
178+
provisionSteps: [...stepResults],
179+
updatedAt: new Date(),
180+
})
181+
.where(eq(adminServers.id, serverIdBuf))
182+
}
183+
184+
conn.end()
185+
await db
186+
.update(adminServers)
187+
.set({
188+
provisionStatus: 'completed',
189+
provisionSteps: [...stepResults],
190+
updatedAt: new Date(),
191+
})
192+
.where(eq(adminServers.id, serverIdBuf))
193+
}

0 commit comments

Comments
 (0)