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:
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user