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'
|
import { Dashboard } from '@/components/dashboard'
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return <Dashboard />
|
||||||
<main className="min-h-screen bg-muted/30">
|
|
||||||
<Dashboard />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
'use client'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { listRepos, listBranches, startDeploy, type Repo } from '@/lib/api'
|
||||||
import { useDeploymentWS } from '@/lib/use-deployment-ws'
|
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() {
|
export function Dashboard() {
|
||||||
const [repos, setRepos] = useState<Repo[]>([])
|
const [repos, setRepos] = useState<Repo[]>([])
|
||||||
@@ -18,6 +19,8 @@ export function Dashboard() {
|
|||||||
const [branch, setBranch] = useState<string>('')
|
const [branch, setBranch] = useState<string>('')
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = 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 [deploymentId, setDeploymentId] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [deploying, setDeploying] = useState(false)
|
const [deploying, setDeploying] = useState(false)
|
||||||
@@ -39,11 +42,30 @@ export function Dashboard() {
|
|||||||
}).catch(() => setBranches([]))
|
}).catch(() => setBranches([]))
|
||||||
}, [repo])
|
}, [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() {
|
async function deploy() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setDeploying(true)
|
setDeploying(true)
|
||||||
try {
|
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)
|
setDeploymentId(id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
@@ -53,30 +75,30 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stageDone: Record<string, 'pending' | 'ok' | 'failed' | 'in_progress'> = {}
|
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
|
let lastDone: string | null = null
|
||||||
for (const e of events) {
|
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'
|
if (e.state === 'FAILED') stageDone[e.stage] = 'failed'
|
||||||
else stageDone[e.stage] = 'ok'
|
else stageDone[e.stage] = 'ok'
|
||||||
lastDone = e.stage
|
lastDone = e.stage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastDone) {
|
if (lastDone) {
|
||||||
const idx = STAGES.indexOf(lastDone)
|
const idx = stages.indexOf(lastDone)
|
||||||
if (idx >= 0 && idx + 1 < STAGES.length && stageDone[STAGES[idx + 1]] === 'pending') {
|
if (idx >= 0 && idx + 1 < stages.length && stageDone[stages[idx + 1]] === 'pending') {
|
||||||
stageDone[STAGES[idx + 1]] = 'in_progress'
|
stageDone[stages[idx + 1]] = 'in_progress'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-8">
|
<div className="container py-8">
|
||||||
<header className="mb-6 flex items-center justify-between">
|
<div className="mb-6">
|
||||||
<div>
|
<h1 className="text-2xl font-semibold tracking-tight">Quick Deploy</h1>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Sandbox Deployments</h1>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">Isolated feature-branch deployments for Backend and QA.</p>
|
Deploy a single service. For sandboxes with route overrides, use the Sandboxes tab.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -91,7 +113,7 @@ export function Dashboard() {
|
|||||||
<SelectTrigger><SelectValue placeholder="Pick a repo" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Pick a repo" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{repos.map((r) => (
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -107,6 +129,20 @@ export function Dashboard() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="u">Bitbucket username</Label>
|
<Label htmlFor="u">Bitbucket username</Label>
|
||||||
<Input id="u" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
<Input id="u" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||||
@@ -123,7 +159,7 @@ export function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{deploymentId && (
|
{deploymentId ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -138,7 +174,7 @@ export function Dashboard() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<ol className="space-y-1.5 text-sm">
|
<ol className="space-y-1.5 text-sm">
|
||||||
{STAGES.map((s) => {
|
{stages.map((s) => {
|
||||||
const v = stageDone[s]
|
const v = stageDone[s]
|
||||||
return (
|
return (
|
||||||
<li key={s} className="flex items-center gap-3">
|
<li key={s} className="flex items-center gap-3">
|
||||||
@@ -158,6 +194,17 @@ export function Dashboard() {
|
|||||||
<LogView events={events} />
|
<LogView events={events} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +213,6 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LogView({ events }: { events: { kind: string; line?: string; stage?: string; backfill?: boolean }[] }) {
|
function LogView({ events }: { events: { kind: string; line?: string; stage?: string; backfill?: boolean }[] }) {
|
||||||
// ponytail: render the last 500 lines. Auto-scroll on new log.
|
|
||||||
return (
|
return (
|
||||||
<pre className="h-72 overflow-auto rounded-md border bg-zinc-950 p-3 text-xs text-zinc-100 font-mono">
|
<pre className="h-72 overflow-auto rounded-md border bg-zinc-950 p-3 text-xs text-zinc-100 font-mono">
|
||||||
{events
|
{events
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export function LoginForm() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|||||||
+241
-22
@@ -1,35 +1,254 @@
|
|||||||
// lib/api.ts — shared types and fetch helpers
|
// lib/api.ts — shared types and fetch helpers for the SDP dashboard.
|
||||||
export type Repo = { name: string; node: string; path: string }
|
|
||||||
|
|
||||||
export async function listRepos(): Promise<Repo[]> {
|
// Slice-2: the dashboard is served under a Next.js basePath
|
||||||
const r = await fetch('/api/repos', { credentials: 'include' })
|
// (default /sandbox/credit-card, see next.config.js). All
|
||||||
if (!r.ok) throw new Error('failed to list repos')
|
// 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()
|
return r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listBranches(repo: string): Promise<string[]> {
|
async function jsend<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||||
const r = await fetch(`/api/repos/branches?repo=${encodeURIComponent(repo)}`, { credentials: 'include' })
|
const r = await fetch(url, {
|
||||||
if (!r.ok) throw new Error('failed to list branches')
|
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()
|
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
|
repository: string
|
||||||
branch: string
|
branch: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
}): Promise<DeployResponse> {
|
env?: Record<string, string>
|
||||||
const r = await fetch('/api/deployments', {
|
sandboxId?: string
|
||||||
method: 'POST',
|
hostPort?: number
|
||||||
headers: { 'content-type': 'application/json' },
|
}) {
|
||||||
credentials: 'include',
|
return jsend<{ id: string }>(p('/api/deployments/new'), 'POST', payload)
|
||||||
body: JSON.stringify(payload),
|
}
|
||||||
})
|
|
||||||
if (!r.ok) {
|
export function stopDeployment(id: string, node: string) {
|
||||||
const text = await r.text()
|
return jsend<{ ok: boolean }>(p('/api/deployments/stop'), 'POST', { id, node })
|
||||||
throw new Error(text || 'deploy failed')
|
}
|
||||||
}
|
|
||||||
return r.json()
|
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')
|
setState('QUEUED')
|
||||||
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
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)
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user