Slice 2: dashboard — nav, sandboxes/templates/environments/history pages, basePath

- New /dashboard layout with a top nav (Quick Deploy / Sandboxes /
  Templates / Environments / History) and a Logout button that
  invalidates the session.
- Quick Deploy: stage list switches per repo (Go vs PHP, so the
  composer-install stage is shown for the gateway), env-var textarea,
  host-port input.
- Sandboxes: list, create, clone-from-template, delete.
- Sandbox detail: live <key>_url map from the gateway's config.php,
  per-route toggle (OCP / sandbox override with a URL input),
  microservice deploys with per-service host port and env, branch
  picker.
- Templates / Environments: list + create + delete.
- History: filterable deployment list with state badges.
- Sandbox detail page is a server component with generateStaticParams
  that delegates to a client component; required for the static export.
- API client: prefix all /api and /ws URLs with NEXT_PUBLIC_BASE_PATH
  (set in next.config.js) so the dashboard works under a non-root
  basePath.
- next.config.js: basePath and assetPrefix set to /sandbox/credit-card
  so asset URLs and internal Link hrefs resolve under the sub-path.
  NEXT_PUBLIC_BASE_PATH env is exposed to the browser bundle for the
  fetch() prefix.
This commit is contained in:
Achmad
2026-06-24 03:59:13 +00:00
parent a7df9ffc6c
commit 78872de897
13 changed files with 1270 additions and 47 deletions
@@ -0,0 +1,93 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { listEnvironments, createEnvironment, deleteEnvironment, type Environment } from '@/lib/api'
export default function EnvironmentsPage() {
const [envs, setEnvs] = useState<Environment[]>([])
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [body, setBody] = useState('KEY=value')
const [busy, setBusy] = useState(false)
async function refresh() {
try { setEnvs(await listEnvironments()) } catch (e) { setError(String(e)) }
}
useEffect(() => { refresh() }, [])
async function create() {
if (!name) return
const values: Record<string, string> = {}
for (const line of body.split('\n')) {
const t = line.trim()
if (!t || !t.includes('=')) continue
const i = t.indexOf('=')
values[t.slice(0, i).trim()] = t.slice(i + 1).trim()
}
setBusy(true); setError(null)
try {
await createEnvironment(name, values)
setName('')
await refresh()
} catch (e) { setError(String(e)) } finally { setBusy(false) }
}
async function del(id: string) {
if (!confirm('Delete this environment?')) return
setBusy(true)
try { await deleteEnvironment(id); await refresh() } catch (e) { setError(String(e)) } finally { setBusy(false) }
}
return (
<div className="container py-8 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Environments</h1>
<p className="text-sm text-muted-foreground">Named sets of env vars. Attach to a sandbox, a service, or the gateway.</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Card>
<CardHeader>
<CardTitle>New environment</CardTitle>
<CardDescription>KEY=value per line.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="dev-creds" />
</div>
<div className="space-y-1.5">
<Label>Values</Label>
<textarea
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono shadow-sm"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
<Button onClick={create} disabled={!name || busy}>Create</Button>
</CardContent>
</Card>
<div className="space-y-2">
{envs.length === 0 ? (
<p className="text-sm text-muted-foreground">No environments yet.</p>
) : (
envs.map((e) => (
<Card key={e.id}>
<CardContent className="flex items-start justify-between p-4 gap-4">
<div>
<div className="font-medium">{e.name}</div>
<pre className="text-xs text-muted-foreground font-mono mt-1 whitespace-pre-wrap">
{Object.entries(e.values).map(([k, v]) => `${k}=${v}`).join('\n')}
</pre>
</div>
<Button variant="outline" size="sm" onClick={() => del(e.id)} disabled={busy}>Delete</Button>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,79 @@
'use client'
import { useEffect, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { listDeployments, listSandboxes, type Deployment, type Sandbox } from '@/lib/api'
export default function HistoryPage() {
const [deps, setDeps] = useState<Deployment[]>([])
const [sbs, setSbs] = useState<Sandbox[]>([])
const [filter, setFilter] = useState('')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
listSandboxes().then(setSbs).catch((e) => setError(String(e)))
refresh('')
}, [])
function refresh(sandboxID: string) {
listDeployments(sandboxID || undefined)
.then(setDeps)
.catch((e) => setError(String(e)))
}
function shortState(d: Deployment) {
return d.state
}
return (
<div className="container py-8 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">History</h1>
<div className="flex items-center gap-2 text-sm">
<label>Sandbox</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={filter}
onChange={(e) => { setFilter(e.target.value); refresh(e.target.value) }}
>
<option value="">All</option>
{sbs.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-2">
{deps.length === 0 ? (
<p className="text-sm text-muted-foreground">No deployments yet.</p>
) : (
deps.map((d) => (
<Card key={d.id}>
<CardContent className="flex items-center justify-between p-3">
<div>
<div className="font-mono text-xs text-muted-foreground">{d.id}</div>
<div className="text-sm">
<span className="font-medium">{d.repository}</span> · <span className="font-mono">{d.branch}</span>
{d.sandboxId && <span className="text-muted-foreground"> · {sbs.find((x) => x.id === d.sandboxId)?.name ?? d.sandboxId}</span>}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{d.hostPort ? <Badge variant="outline">:{d.hostPort}</Badge> : null}
<Badge variant={stateVariant(shortState(d))}>{shortState(d)}</Badge>
<span>{new Date(d.startedAt).toLocaleString()}</span>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}
function stateVariant(s: string) {
switch (s) {
case 'RUNNING': return 'success'
case 'FAILED': return 'destructive'
case 'STOPPED': return 'warning'
default: return 'secondary'
}
}
+10
View File
@@ -0,0 +1,10 @@
import { DashboardNav } from '@/components/dashboard-nav'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-zinc-50">
<DashboardNav />
<main>{children}</main>
</div>
)
}
+1 -5
View File
@@ -1,9 +1,5 @@
import { Dashboard } from '@/components/dashboard'
export default function DashboardPage() {
return (
<main className="min-h-screen bg-muted/30">
<Dashboard />
</main>
)
return <Dashboard />
}
@@ -0,0 +1,438 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
getSandbox, updateSandbox, deploySandboxService, deleteSandbox,
listBranches, listRoutes, pushRoutes, listEnvironments,
type Sandbox, type SandboxService, type Environment, type LiveRoute,
} from '@/lib/api'
export default function SandboxDetailClient() {
const params = useParams<{ id: string }>()
const id = params.id
const router = useRouter()
const [sb, setSb] = useState<Sandbox | null>(null)
const [envs, setEnvs] = useState<Environment[]>([])
const [routes, setRoutes] = useState<LiveRoute[]>([])
const [routeBranch, setRouteBranch] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [creds, setCreds] = useState({ username: '', password: '' })
const [newSvc, setNewSvc] = useState<{ repo: string; branch: string; hostPort: string; useOcp: boolean; envId: string }>({
repo: '', branch: '', hostPort: '', useOcp: false, envId: '',
})
const [repoOptions, setRepoOptions] = useState<{ name: string; node: string }[]>([])
async function refresh() {
try {
const s = await getSandbox(id)
setSb(s)
} catch (e) {
setError(String(e))
}
}
async function refreshRoutes() {
try {
const r = await listRoutes()
const list: LiveRoute[] = []
const overrides = r.overrides[id] ?? {}
for (const [key, url] of Object.entries(r.routes)) {
const ov = overrides[key]
list.push({
key,
url,
ocpDefault: r.defaults[key] ?? null,
overridden: !!ov,
sandboxId: ov ? id : undefined,
overrideValue: ov ? ov.value : undefined,
targetOcp: ov ? ov.targetOcp : undefined,
})
}
list.sort((a, b) => a.key.localeCompare(b.key))
setRoutes(list)
setRouteBranch(r.branch)
} catch (e) {
setError(String(e))
}
}
useEffect(() => {
refresh()
refreshRoutes()
listEnvironments().then(setEnvs).catch(() => {})
const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '')
fetch(`${base}/api/repos`, { credentials: 'include' })
.then((r) => r.json())
.then((arr: { name: string; node: string }[]) => setRepoOptions(arr))
.catch(() => {})
}, [id])
if (!sb) {
return <div className="container py-8">{error ? <p className="text-sm text-destructive">{error}</p> : <p className="text-sm text-muted-foreground">Loading</p>}</div>
}
function updateService(idx: number, patch: Partial<SandboxService>) {
setSb((cur) => {
if (!cur) return cur
const services = cur.services.slice()
services[idx] = { ...services[idx], ...patch }
return { ...cur, services }
})
}
function addService() {
if (!newSvc.repo) return
setSb((cur) => {
if (!cur) return cur
return {
...cur,
services: [
...cur.services,
{
id: `${cur.id}-${newSvc.repo}`,
sandboxId: cur.id,
repo: newSvc.repo,
branch: newSvc.branch,
hostPort: newSvc.hostPort ? Number(newSvc.hostPort) : 0,
useOcp: newSvc.useOcp,
envId: newSvc.envId || undefined,
},
],
}
})
setNewSvc({ repo: '', branch: '', hostPort: '', useOcp: false, envId: '' })
}
function removeService(idx: number) {
setSb((cur) => {
if (!cur) return cur
const services = cur.services.slice()
services.splice(idx, 1)
return { ...cur, services }
})
}
async function save() {
if (!sb) return
setBusy(true); setError(null)
try {
await updateSandbox(id, sb)
await refresh()
} catch (e) { setError(String(e)) } finally { setBusy(false) }
}
async function del() {
if (!confirm('Delete this sandbox? Route overrides will be dropped.')) return
setBusy(true)
try {
await deleteSandbox(id)
router.push('/dashboard/sandboxes')
} catch (e) { setError(String(e)) } finally { setBusy(false) }
}
async function deployService(svc: SandboxService) {
if (!creds.username || !creds.password) {
setError('enter Bitbucket credentials first')
return
}
if (!svc.branch) {
setError('set the branch first')
return
}
setBusy(true); setError(null)
try {
await deploySandboxService(id, svc.repo, {
branch: svc.branch,
username: creds.username,
password: creds.password,
envId: svc.envId,
})
setTimeout(refreshRoutes, 1500)
} catch (e) { setError(String(e)) } finally { setBusy(false) }
}
return (
<div className="container py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{sb.name}</h1>
<p className="text-sm text-muted-foreground">
Gateway: branch <span className="font-mono">{sb.gatewayBranch || '—'}</span> on port <span className="font-mono">{sb.gatewayHostPort || '—'}</span>
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={save} disabled={busy}>Save</Button>
<Button variant="outline" onClick={del} disabled={busy}>Delete</Button>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Card>
<CardHeader>
<CardTitle>Gateway</CardTitle>
<CardDescription>The API gateway branch this sandbox runs. Switching it triggers a re-deploy + route re-apply.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<div className="space-y-1.5">
<Label>Branch</Label>
<Input value={sb.gatewayBranch ?? ''} onChange={(e) => setSb({ ...sb, gatewayBranch: e.target.value })} placeholder="develop" />
</div>
<div className="space-y-1.5">
<Label>Host port</Label>
<Input value={sb.gatewayHostPort ?? 0} onChange={(e) => setSb({ ...sb, gatewayHostPort: Number(e.target.value) || 0 })} placeholder="8080" />
</div>
<div className="space-y-1.5">
<Label>Env</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={sb.gatewayEnvId ?? ''}
onChange={(e) => setSb({ ...sb, gatewayEnvId: e.target.value || undefined })}
>
<option value=""> none </option>
{envs.map((e) => <option key={e.id} value={e.id}>{e.name}</option>)}
</select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Routes (live from gateway config.php)</CardTitle>
<CardDescription>
{routeBranch ? <>Branch: <span className="font-mono">{routeBranch}</span></> : 'No deploy yet.'}
{' · '}
<button className="underline" onClick={refreshRoutes}>Refresh</button>
</CardDescription>
</div>
<Badge variant="secondary">{routes.length} services</Badge>
</div>
</CardHeader>
<CardContent>
{routes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No <code>{'<service>_url'}</code> lines in the current config.php. Deploy the gateway first.
</p>
) : (
<RouteTable
routes={routes}
onChange={async (key, value, targetOcp) => {
setBusy(true); setError(null)
try {
const existing = routes
.filter((r) => r.overridden && r.sandboxId === id && r.key !== key)
.map((r) => ({ key: r.key, value: r.url, targetOcp: false }))
const newOv = { key, value, targetOcp }
await pushRoutes(id, [...existing, newOv])
await refreshRoutes()
} catch (e) { setError(String(e)) } finally { setBusy(false) }
}}
busy={busy}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Microservices</CardTitle>
<CardDescription>Pick a branch, choose a host port, deploy. Use "use OCP" to leave the gateway's default URL in place.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{sb.services.length === 0 ? (
<p className="text-sm text-muted-foreground">No services yet. Add one below.</p>
) : (
sb.services.map((svc, idx) => (
<div key={svc.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="font-mono text-sm">{svc.repo}</div>
<div className="flex items-center gap-2">
<label className="text-xs flex items-center gap-1">
<input
type="checkbox"
checked={svc.useOcp}
onChange={(e) => updateService(idx, { useOcp: e.target.checked })}
/>
use OCP
</label>
<Button size="sm" variant="outline" onClick={() => removeService(idx)}>Remove</Button>
<Button size="sm" onClick={() => deployService(svc)} disabled={busy || svc.useOcp}>Deploy</Button>
</div>
</div>
<div className="grid gap-2 md:grid-cols-4">
<div className="space-y-1.5">
<Label>Branch</Label>
<Input value={svc.branch ?? ''} onChange={(e) => updateService(idx, { branch: e.target.value })} placeholder="feature/login-error" />
</div>
<div className="space-y-1.5">
<Label>Host port</Label>
<Input type="number" value={svc.hostPort ?? 0} onChange={(e) => updateService(idx, { hostPort: Number(e.target.value) || 0 })} placeholder="9001" />
</div>
<div className="space-y-1.5">
<Label>Env</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={svc.envId ?? ''}
onChange={(e) => updateService(idx, { envId: e.target.value || undefined })}
>
<option value="">— none —</option>
{envs.map((e) => <option key={e.id} value={e.id}>{e.name}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label>Branches (cached)</Label>
<BranchPicker
repo={svc.repo}
value={svc.branch ?? ''}
onChange={(b) => updateService(idx, { branch: b })}
/>
</div>
</div>
</div>
))
)}
<div className="rounded-md border border-dashed p-3 space-y-2">
<div className="text-sm font-medium">Add a service</div>
<div className="grid gap-2 md:grid-cols-5">
<div className="space-y-1.5">
<Label>Repo</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={newSvc.repo}
onChange={(e) => setNewSvc({ ...newSvc, repo: e.target.value })}
>
<option value="">— pick —</option>
{repoOptions.map((r) => <option key={r.name} value={r.name}>{r.name} ({r.node})</option>)}
</select>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label>Branch</Label>
<Input value={newSvc.branch} onChange={(e) => setNewSvc({ ...newSvc, branch: e.target.value })} placeholder="main" />
</div>
<div className="space-y-1.5">
<Label>Host port</Label>
<Input type="number" value={newSvc.hostPort} onChange={(e) => setNewSvc({ ...newSvc, hostPort: e.target.value })} placeholder="9001" />
</div>
<div className="space-y-1.5">
<Label>Env</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={newSvc.envId}
onChange={(e) => setNewSvc({ ...newSvc, envId: e.target.value })}
>
<option value="">— none —</option>
{envs.map((e) => <option key={e.id} value={e.id}>{e.name}</option>)}
</select>
</div>
</div>
<Button size="sm" onClick={addService} disabled={!newSvc.repo || busy}>Add</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bitbucket credentials</CardTitle>
<CardDescription>Used by every deploy from this page. Held in component state only.</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 md:grid-cols-2">
<div className="space-y-1.5">
<Label>Username</Label>
<Input value={creds.username} onChange={(e) => setCreds({ ...creds, username: e.target.value })} autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<Input type="password" value={creds.password} onChange={(e) => setCreds({ ...creds, password: e.target.value })} autoComplete="current-password" />
</div>
</CardContent>
</Card>
</div>
)
}
function BranchPicker({ repo, value, onChange }: { repo: string; value: string; onChange: (b: string) => void }) {
const [branches, setBranches] = useState<string[]>([])
useEffect(() => {
if (!repo) { setBranches([]); return }
listBranches(repo).then(setBranches).catch(() => setBranches([]))
}, [repo])
if (branches.length === 0) return <Input value={value} onChange={(e) => onChange(e.target.value)} placeholder="(type a branch)" />
return (
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">— pick —</option>
{branches.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
)
}
function RouteTable({
routes, onChange, busy,
}: {
routes: LiveRoute[]
onChange: (key: string, value: string, targetOcp: boolean) => void
busy: boolean
}) {
const [editing, setEditing] = useState<Record<string, string>>({})
return (
<table className="w-full text-sm">
<thead className="text-left text-xs text-muted-foreground">
<tr>
<th className="py-2 pr-3">Service</th>
<th className="py-2 pr-3">Current URL</th>
<th className="py-2 pr-3">OCP default</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Action</th>
</tr>
</thead>
<tbody>
{routes.map((r) => (
<tr key={r.key} className="border-t">
<td className="py-2 pr-3 font-mono">{r.key}</td>
<td className="py-2 pr-3 font-mono text-xs">{r.url}</td>
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{r.ocpDefault ?? ''}</td>
<td className="py-2 pr-3">
{r.overridden ? <Badge variant="warning">sandbox</Badge> : <Badge variant="secondary">OCP</Badge>}
</td>
<td className="py-2 pr-3">
{r.overridden ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onChange(r.key, r.ocpDefault ?? '', true)}>
Restore OCP
</Button>
) : (
<div className="flex items-center gap-2">
<Input
className="h-8 w-56 font-mono text-xs"
placeholder={`http://172.18.136.92:9001`}
value={editing[r.key] ?? ''}
onChange={(e) => setEditing((cur) => ({ ...cur, [r.key]: e.target.value }))}
/>
<Button
size="sm"
disabled={busy || !editing[r.key]}
onClick={() => {
onChange(r.key, editing[r.key], false)
setEditing((cur) => ({ ...cur, [r.key]: '' }))
}}
>
Take over
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
)
}
@@ -0,0 +1,14 @@
// Server component shell. Static export requires generateStaticParams
// to be defined in a server context. We emit a single placeholder
// id so the route is statically exported; the actual id is read at
// runtime by the client component.
export function generateStaticParams() {
return [{ id: '_' }]
}
import SandboxDetailClient from './client'
export default function Page() {
return <SandboxDetailClient />
}
@@ -0,0 +1,175 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { listSandboxes, listTemplates, listEnvironments, createSandbox, cloneSandbox, deleteSandbox, type Sandbox, type Environment } from '@/lib/api'
export default function SandboxesPage() {
const [sbs, setSbs] = useState<Sandbox[]>([])
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([])
const [envs, setEnvs] = useState<Environment[]>([])
const [error, setError] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [newBranch, setNewBranch] = useState('')
const [newPort, setNewPort] = useState('')
const [cloneTemplateId, setCloneTemplateId] = useState('')
const [cloneName, setCloneName] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
try {
const [s, t, e] = await Promise.all([listSandboxes(), listTemplates(), listEnvironments()])
setSbs(s)
setTemplates(t.map((x) => ({ id: x.id, name: x.name })))
setEnvs(e)
} catch (err) {
setError(String(err))
}
}
useEffect(() => { refresh() }, [])
async function create() {
if (!newName) return
setBusy(true)
setError(null)
try {
await createSandbox({
name: newName,
gatewayBranch: newBranch || undefined,
gatewayHostPort: newPort ? Number(newPort) : 0,
})
setNewName(''); setNewBranch(''); setNewPort('')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function clone() {
if (!cloneTemplateId || !cloneName) return
setBusy(true)
setError(null)
try {
await cloneSandbox(cloneTemplateId, cloneName)
setCloneName('')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function del(id: string) {
if (!confirm('Delete this sandbox? Route overrides are dropped but no containers are stopped.')) return
setBusy(true)
try {
await deleteSandbox(id)
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
return (
<div className="container py-8 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sandboxes</h1>
<p className="text-sm text-muted-foreground">
A sandbox groups one gateway branch with a set of microservice overrides. Each route can be pointed at the local stand-in or back at OCP.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>New sandbox</CardTitle>
<CardDescription>Pick a gateway branch. Add services after creating.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="QA-login-error" />
</div>
<div className="space-y-1.5">
<Label>Gateway branch</Label>
<Input value={newBranch} onChange={(e) => setNewBranch(e.target.value)} placeholder="develop" />
</div>
<div className="space-y-1.5">
<Label>Gateway host port</Label>
<Input value={newPort} onChange={(e) => setNewPort(e.target.value)} placeholder="8080" />
<p className="text-xs text-muted-foreground">Port on the gateway VM that the mobile app points at. One live at a time.</p>
</div>
<Button onClick={create} disabled={!newName || busy} className="w-full">Create</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Clone from template</CardTitle>
<CardDescription>Materialize a sandbox from a saved template.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={cloneTemplateId} onValueChange={setCloneTemplateId}>
<SelectTrigger><SelectValue placeholder="Pick a template" /></SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>New sandbox name</Label>
<Input value={cloneName} onChange={(e) => setCloneName(e.target.value)} placeholder="QA-login-error" />
</div>
<Button onClick={clone} disabled={!cloneTemplateId || !cloneName || busy} className="w-full">Clone</Button>
</CardContent>
</Card>
</div>
<div className="space-y-2">
{sbs.length === 0 ? (
<p className="text-sm text-muted-foreground">No sandboxes yet.</p>
) : (
sbs.map((s) => (
<Card key={s.id}>
<CardContent className="flex items-center justify-between p-4">
<div>
<Link href={`/dashboard/sandboxes/${s.id}`} className="font-medium hover:underline">
{s.name}
</Link>
<p className="text-xs text-muted-foreground">
Gateway branch: <span className="font-mono">{s.gatewayBranch || '—'}</span>
{' · '}
Gateway port: <span className="font-mono">{s.gatewayHostPort || '—'}</span>
{' · '}
Services: <span className="font-mono">{s.services.length}</span>
</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm"><Link href={`/dashboard/sandboxes/${s.id}`}>Open</Link></Button>
<Button onClick={() => del(s.id)} variant="outline" size="sm" disabled={busy}>Delete</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,86 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { listTemplates, createTemplate, deleteTemplate, type Template } from '@/lib/api'
export default function TemplatesPage() {
const [ts, setTs] = useState<Template[]>([])
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [branch, setBranch] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
try { setTs(await listTemplates()) } catch (e) { setError(String(e)) }
}
useEffect(() => { refresh() }, [])
async function create() {
if (!name) return
setBusy(true); setError(null)
try {
await createTemplate({ name, gatewayBranch: branch, services: [] })
setName(''); setBranch('')
await refresh()
} catch (e) { setError(String(e)) } finally { setBusy(false) }
}
async function del(id: string) {
if (!confirm('Delete this template?')) return
setBusy(true)
try { await deleteTemplate(id); await refresh() } catch (e) { setError(String(e)) } finally { setBusy(false) }
}
return (
<div className="container py-8 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Templates</h1>
<p className="text-sm text-muted-foreground">
A template is a saved set of (gateway branch, services). Clone it into a sandbox from the Sandboxes page.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Card>
<CardHeader>
<CardTitle>New template</CardTitle>
<CardDescription>Add services after creating.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="ACCOUNT-TESTING" />
</div>
<div className="space-y-1.5">
<Label>Gateway branch</Label>
<Input value={branch} onChange={(e) => setBranch(e.target.value)} placeholder="develop" />
</div>
<div className="flex items-end">
<Button onClick={create} disabled={!name || busy} className="w-full">Create</Button>
</div>
</CardContent>
</Card>
<div className="space-y-2">
{ts.length === 0 ? (
<p className="text-sm text-muted-foreground">No templates yet.</p>
) : (
ts.map((t) => (
<Card key={t.id}>
<CardContent className="flex items-center justify-between p-4">
<div>
<div className="font-medium">{t.name}</div>
<p className="text-xs text-muted-foreground">
Gateway: <span className="font-mono">{t.gatewayBranch || '—'}</span> · Services: <span className="font-mono">{t.services.length}</span>
</p>
</div>
<Button variant="outline" size="sm" onClick={() => del(t.id)} disabled={busy}>Delete</Button>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,63 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
const NAV = [
{ href: '/dashboard', label: 'Quick Deploy' },
{ href: '/dashboard/sandboxes', label: 'Sandboxes' },
{ href: '/dashboard/templates', label: 'Templates' },
{ href: '/dashboard/environments', label: 'Environments' },
{ href: '/dashboard/history', label: 'History' },
]
export function DashboardNav() {
const pathname = usePathname()
const router = useRouter()
const [busy, setBusy] = useState(false)
async function logout() {
setBusy(true)
try {
const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '')
await fetch(`${base}/api/logout`, { method: 'POST', credentials: 'include' })
} finally {
router.push('/')
}
}
return (
<header className="border-b">
<div className="container flex h-14 items-center justify-between gap-6">
<div className="flex items-center gap-6">
<Link href="/dashboard" className="font-semibold tracking-tight">SDP</Link>
<nav className="flex gap-1 text-sm">
{NAV.map((n) => {
const active = n.href === '/dashboard'
? pathname === '/dashboard'
: pathname?.startsWith(n.href)
return (
<Link
key={n.href}
href={n.href}
className={
'rounded-md px-3 py-1.5 transition-colors ' +
(active
? 'bg-zinc-900 text-zinc-50'
: 'text-zinc-700 hover:bg-zinc-100')
}
>
{n.label}
</Link>
)
})}
</nav>
</div>
<Button variant="outline" size="sm" onClick={logout} disabled={busy}>
{busy ? 'Logging out…' : 'Logout'}
</Button>
</div>
</header>
)
}
+63 -17
View File
@@ -1,5 +1,5 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -9,7 +9,8 @@ import { Input } from '@/components/ui/input'
import { listRepos, listBranches, startDeploy, type Repo } from '@/lib/api'
import { useDeploymentWS } from '@/lib/use-deployment-ws'
const STAGES = ['git fetch', 'git checkout', 'git pull', 'go build', 'start container']
const GO_STAGES = ['git fetch', 'git checkout', 'git pull', 'go build', 'start container']
const PHP_STAGES = ['git fetch', 'git checkout', 'git pull', 'composer install', 'start container']
export function Dashboard() {
const [repos, setRepos] = useState<Repo[]>([])
@@ -18,6 +19,8 @@ export function Dashboard() {
const [branch, setBranch] = useState<string>('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [hostPort, setHostPort] = useState<string>('')
const [envLines, setEnvLines] = useState<string>('KEY=value')
const [deploymentId, setDeploymentId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [deploying, setDeploying] = useState(false)
@@ -39,11 +42,30 @@ export function Dashboard() {
}).catch(() => setBranches([]))
}, [repo])
const stages = useMemo(() => {
const r = repos.find((x) => x.name === repo)
return r?.name === 'api-gateway' ? PHP_STAGES : GO_STAGES
}, [repo, repos])
async function deploy() {
setError(null)
setDeploying(true)
try {
const { id } = await startDeploy({ repository: repo, branch, username, password })
const env: Record<string, string> = {}
for (const line of envLines.split('\n')) {
const t = line.trim()
if (!t || !t.includes('=')) continue
const i = t.indexOf('=')
env[t.slice(0, i).trim()] = t.slice(i + 1).trim()
}
const { id } = await startDeploy({
repository: repo,
branch,
username,
password,
env: Object.keys(env).length ? env : undefined,
hostPort: hostPort ? Number(hostPort) : undefined,
})
setDeploymentId(id)
} catch (e) {
setError(String(e))
@@ -53,30 +75,30 @@ export function Dashboard() {
}
const stageDone: Record<string, 'pending' | 'ok' | 'failed' | 'in_progress'> = {}
for (const s of STAGES) stageDone[s] = 'pending'
for (const s of stages) stageDone[s] = 'pending'
let lastDone: string | null = null
for (const e of events) {
if (e.kind === 'progress' && e.stage && STAGES.includes(e.stage)) {
if (e.kind === 'progress' && e.stage && stages.includes(e.stage)) {
if (e.state === 'FAILED') stageDone[e.stage] = 'failed'
else stageDone[e.stage] = 'ok'
lastDone = e.stage
}
}
if (lastDone) {
const idx = STAGES.indexOf(lastDone)
if (idx >= 0 && idx + 1 < STAGES.length && stageDone[STAGES[idx + 1]] === 'pending') {
stageDone[STAGES[idx + 1]] = 'in_progress'
const idx = stages.indexOf(lastDone)
if (idx >= 0 && idx + 1 < stages.length && stageDone[stages[idx + 1]] === 'pending') {
stageDone[stages[idx + 1]] = 'in_progress'
}
}
return (
<div className="container py-8">
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sandbox Deployments</h1>
<p className="text-sm text-muted-foreground">Isolated feature-branch deployments for Backend and QA.</p>
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Quick Deploy</h1>
<p className="text-sm text-muted-foreground">
Deploy a single service. For sandboxes with route overrides, use the Sandboxes tab.
</p>
</div>
</header>
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<Card>
@@ -91,7 +113,7 @@ export function Dashboard() {
<SelectTrigger><SelectValue placeholder="Pick a repo" /></SelectTrigger>
<SelectContent>
{repos.map((r) => (
<SelectItem key={r.name} value={r.name}>{r.name}</SelectItem>
<SelectItem key={r.name} value={r.name}>{r.name} <span className="text-muted-foreground">· {r.node}</span></SelectItem>
))}
</SelectContent>
</Select>
@@ -107,6 +129,20 @@ export function Dashboard() {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="hp">Host port (optional)</Label>
<Input id="hp" value={hostPort} onChange={(e) => setHostPort(e.target.value)} placeholder="9001" />
<p className="text-xs text-muted-foreground">Maps the container's exposed port to this host port. Leave blank to skip port publishing.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="env">Env vars (KEY=value per line)</Label>
<textarea
id="env"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono shadow-sm"
value={envLines}
onChange={(e) => setEnvLines(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="u">Bitbucket username</Label>
<Input id="u" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
@@ -123,7 +159,7 @@ export function Dashboard() {
</Card>
<div className="space-y-6">
{deploymentId && (
{deploymentId ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -138,7 +174,7 @@ export function Dashboard() {
</CardHeader>
<CardContent className="space-y-4">
<ol className="space-y-1.5 text-sm">
{STAGES.map((s) => {
{stages.map((s) => {
const v = stageDone[s]
return (
<li key={s} className="flex items-center gap-3">
@@ -158,6 +194,17 @@ export function Dashboard() {
<LogView events={events} />
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>How this works</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>The agent on the chosen VM does <code className="rounded bg-zinc-100 px-1">git fetch → checkout → pull</code>, builds the service, and starts the container.</p>
<p>For PHP gateways we skip the build — the apache container serves the bind-mounted repo on the host port you specify.</p>
<p>Credentials are used once for the git operation, then discarded.</p>
</CardContent>
</Card>
)}
</div>
</div>
@@ -166,7 +213,6 @@ export function Dashboard() {
}
function LogView({ events }: { events: { kind: string; line?: string; stage?: string; backfill?: boolean }[] }) {
// ponytail: render the last 500 lines. Auto-scroll on new log.
return (
<pre className="h-72 overflow-auto rounded-md border bg-zinc-950 p-3 text-xs text-zinc-100 font-mono">
{events
+2 -1
View File
@@ -18,7 +18,8 @@ export function LoginForm() {
setLoading(true)
setError(null)
try {
const r = await fetch('/api/login', {
const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '')
const r = await fetch(`${base}/api/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
+241 -22
View File
@@ -1,35 +1,254 @@
// lib/api.ts — shared types and fetch helpers
export type Repo = { name: string; node: string; path: string }
// lib/api.ts — shared types and fetch helpers for the SDP dashboard.
export async function listRepos(): Promise<Repo[]> {
const r = await fetch('/api/repos', { credentials: 'include' })
if (!r.ok) throw new Error('failed to list repos')
// Slice-2: the dashboard is served under a Next.js basePath
// (default /sandbox/credit-card, see next.config.js). All
// fetch() URLs must be prefixed with it so /api/ and /ws/ resolve
// against the dashboard's mounted path, not the host root.
// NEXT_PUBLIC_BASE_PATH is set in next.config.js (build-time env) so
// the value is baked into the static bundle.
const BASE = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '')
function p(path: string): string {
if (!BASE) return path
if (path.startsWith('http://') || path.startsWith('https://')) return path
return BASE + path
}
export type Repo = { name: string; node: string; path: string; defaultBranch?: string }
export type Deployment = {
id: string
sandboxId?: string
repository: string
branch: string
user: string
state: string
containerId?: string
hostPort?: number
startedAt: number
completedAt?: number
}
export type Environment = {
id: string
name: string
values: Record<string, string>
createdAt: number
updatedAt: number
}
export type SandboxService = {
id: string
sandboxId: string
repo: string
branch?: string
envId?: string
hostPort: number
useOcp: boolean
}
export type Sandbox = {
id: string
name: string
gatewayBranch?: string
gatewayEnvId?: string
gatewayHostPort?: number
createdAt: number
updatedAt: number
services: SandboxService[]
}
export type TemplateService = SandboxService
export type Template = {
id: string
name: string
gatewayBranch?: string
createdAt: number
updatedAt: number
services: TemplateService[]
}
export type Route = {
id: string
sandboxId: string
key: string
value: string
targetOcp: boolean
}
// RoutePush is the body for POST /api/routes/push — the agent fills
// in id and sandboxId from the request context.
export type RoutePush = {
key: string
value: string
targetOcp: boolean
}
async function jget<T>(url: string): Promise<T> {
const r = await fetch(url, { credentials: 'include' })
if (!r.ok) {
const t = await r.text()
throw new Error(t || `${url} failed (${r.status})`)
}
return r.json()
}
export async function listBranches(repo: string): Promise<string[]> {
const r = await fetch(`/api/repos/branches?repo=${encodeURIComponent(repo)}`, { credentials: 'include' })
if (!r.ok) throw new Error('failed to list branches')
async function jsend<T>(url: string, method: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method,
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!r.ok) {
const t = await r.text()
throw new Error(t || `${url} failed (${r.status})`)
}
if (r.status === 204) return undefined as T
return r.json()
}
export type DeployResponse = { id: string }
// --- repos / agents ---
export async function startDeploy(payload: {
export function listRepos() {
return jget<Repo[]>(p('/api/repos'))
}
export function listBranches(repo: string) {
return jget<string[]>(p(`/api/repos/branches?repo=${encodeURIComponent(repo)}`))
}
export function listAgents() {
return jget<{ node: string }[]>(p('/api/agents'))
}
// --- deployments ---
export function startDeploy(payload: {
repository: string
branch: string
username: string
password: string
}): Promise<DeployResponse> {
const r = await fetch('/api/deployments', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
})
if (!r.ok) {
const text = await r.text()
throw new Error(text || 'deploy failed')
}
return r.json()
env?: Record<string, string>
sandboxId?: string
hostPort?: number
}) {
return jsend<{ id: string }>(p('/api/deployments/new'), 'POST', payload)
}
export function stopDeployment(id: string, node: string) {
return jsend<{ ok: boolean }>(p('/api/deployments/stop'), 'POST', { id, node })
}
export function listDeployments(sandboxID?: string) {
const q = sandboxID ? `?sandbox=${encodeURIComponent(sandboxID)}` : ''
return jget<Deployment[]>(p(`/api/deployments${q}`))
}
// --- environments ---
export function listEnvironments() {
return jget<Environment[]>(p('/api/environments'))
}
export function createEnvironment(name: string, values: Record<string, string>) {
return jsend<Environment>(p('/api/environments'), 'POST', { name, values })
}
export function updateEnvironment(id: string, body: Partial<{ name: string; values: Record<string, string> }>) {
return jsend<Environment>(p(`/api/environments/${id}`), 'PUT', body)
}
export function deleteEnvironment(id: string) {
return jsend<{ ok: boolean }>(p(`/api/environments/${id}`), 'DELETE')
}
// --- sandboxes ---
export function listSandboxes() {
return jget<Sandbox[]>(p('/api/sandboxes'))
}
export function getSandbox(id: string) {
return jget<Sandbox>(p(`/api/sandboxes/${id}`))
}
export function createSandbox(body: Partial<Sandbox>) {
return jsend<Sandbox>(p('/api/sandboxes'), 'POST', body)
}
export function updateSandbox(id: string, body: Partial<Sandbox>) {
return jsend<Sandbox>(p(`/api/sandboxes/${id}`), 'PUT', body)
}
export function deleteSandbox(id: string) {
return jsend<{ ok: boolean }>(p(`/api/sandboxes/${id}`), 'DELETE')
}
export function cloneSandbox(templateID: string, name: string) {
return jsend<Sandbox>(p('/api/sandboxes/clone'), 'POST', { templateId: templateID, name })
}
export function deploySandboxService(sandboxID: string, repo: string, body: {
branch: string
username: string
password: string
env?: Record<string, string>
envId?: string
}) {
return jsend<{ id: string }>(p(`/api/sandboxes/${sandboxID}/deploy/${repo}`), 'POST', body)
}
// --- templates ---
export function listTemplates() {
return jget<Template[]>(p('/api/templates'))
}
export function getTemplate(id: string) {
return jget<Template>(p(`/api/templates/${id}`))
}
export function createTemplate(body: Partial<Template>) {
return jsend<Template>(p('/api/templates'), 'POST', body)
}
export function updateTemplate(id: string, body: Partial<Template>) {
return jsend<Template>(p(`/api/templates/${id}`), 'PUT', body)
}
export function deleteTemplate(id: string) {
return jsend<{ ok: boolean }>(p(`/api/templates/${id}`), 'DELETE')
}
// --- routes ---
// LiveRoute is the live <key>_url line from the gateway's config.php,
// plus the dashboard's view of whether it's currently OCP or
// overridden.
export type LiveRoute = {
key: string // e.g. "haven"
url: string // current value in the file
ocpDefault: string | null // snapshot value for the current branch
overridden: boolean // true if some sandbox has an active override
sandboxId?: string // which sandbox owns the override
overrideValue?: string // the override's value (when overridden)
targetOcp?: boolean // true if the override is "restore to OCP"
}
export type LiveRoutesResponse = {
branch: string
routes: Record<string, string> // live map from agent
defaults: Record<string, string> // per-branch OCP snapshot
overrides: Record<string, Record<string, { key: string; value: string; targetOcp: boolean }>>
configPath: string
}
export function listRoutes() {
return jget<LiveRoutesResponse>(p('/api/routes'))
}
export function pushRoutes(sandboxID: string, routes: RoutePush[]) {
return jsend<{ ok: boolean }>(p('/api/routes/push'), 'POST', { sandboxId: sandboxID, routes })
}
+4 -1
View File
@@ -24,7 +24,10 @@ export function useDeploymentWS(deploymentId: string | null) {
setState('QUEUED')
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${proto}://${window.location.host}/ws/deployments/${deploymentId}`
// Slice-2: the WS endpoint is mounted at the dashboard's
// basePath. NEXT_PUBLIC_BASE_PATH is baked in at build time.
const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '')
const url = `${proto}://${window.location.host}${base}/ws/deployments/${deploymentId}`
const ws = new WebSocket(url)
wsRef.current = ws