Initial SDP skeleton

Sandbox Deployment Platform — Go control plane + agents, NextJS dashboard,
nginx reverse proxy. Cross-compile via Docker; deploy via sshpass to
172.18.136.92 (micro) and 172.18.139.186 (gateway).

- control-plane: HTTP API, WS hub, SQLite (modernc.org/sqlite) for
  progress, .log files for log persistence
- agent-micro / agent-gateway: alpine:3.20 + bind-mounted repo,
  binary exec'd in container, no Dockerfile build step
- dashboard: NextJS static export + shadcn/ui components, single
  WebSocket hook
- docker-compose.yml: three services on alpine:latest with docker
  socket bind for agents
- scripts/: build.sh (golang:1.23-alpine cross-compile), deploy.sh,
  patch-nginx.sh (idempotent nginx splice), ssh wrappers

Runtime model: pass-through Bitbucket creds per deploy, never logged or
persisted on the agent. Control plane never touches git or docker
directly — agents do all the work locally.
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-24 07:25:01 +07:00
commit 3d99940658
47 changed files with 4068 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// ponytail: pure static export. nginx serves the dashboard with
// try_files and proxies /api/* and /ws/* to the Go control plane.
// The browser does plain fetch() to /api/*; no NextJS BFF layer.
output: 'export',
reactStrictMode: true,
images: { unoptimized: true },
}
module.exports = nextConfig
+35
View File
@@ -0,0 +1,35 @@
{
"name": "sdp-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+9
View File
@@ -0,0 +1,9 @@
import { Dashboard } from '@/components/dashboard'
export default function DashboardPage() {
return (
<main className="min-h-screen bg-muted/30">
<Dashboard />
</main>
)
}
+54
View File
@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* { @apply border-border; }
body { @apply bg-background text-foreground; font-feature-settings: "rlig" 1, "calt" 1; }
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'SDP',
description: 'Sandbox Deployment Platform',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full">
<body className="h-full bg-background text-foreground antialiased">{children}</body>
</html>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { LoginForm } from '@/components/login-form'
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<LoginForm />
</main>
)
}
+180
View File
@@ -0,0 +1,180 @@
'use client'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
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']
export function Dashboard() {
const [repos, setRepos] = useState<Repo[]>([])
const [repo, setRepo] = useState<string>('')
const [branches, setBranches] = useState<string[]>([])
const [branch, setBranch] = useState<string>('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [deploymentId, setDeploymentId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [deploying, setDeploying] = useState(false)
const { events, state } = useDeploymentWS(deploymentId)
useEffect(() => {
listRepos().then((r) => {
setRepos(r)
if (r.length) setRepo(r[0].name)
}).catch((e) => setError(String(e)))
}, [])
useEffect(() => {
if (!repo) return
listBranches(repo).then((b) => {
setBranches(b)
if (b.length) setBranch(b[0])
}).catch(() => setBranches([]))
}, [repo])
async function deploy() {
setError(null)
setDeploying(true)
try {
const { id } = await startDeploy({ repository: repo, branch, username, password })
setDeploymentId(id)
} catch (e) {
setError(String(e))
} finally {
setDeploying(false)
}
}
const stageDone: Record<string, 'pending' | 'ok' | 'failed' | 'in_progress'> = {}
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.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'
}
}
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>
</header>
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<Card>
<CardHeader>
<CardTitle>Deploy a branch</CardTitle>
<CardDescription>Credentials are forwarded to the agent for this deploy only.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label>Repository</Label>
<Select value={repo} onValueChange={setRepo}>
<SelectTrigger><SelectValue placeholder="Pick a repo" /></SelectTrigger>
<SelectContent>
{repos.map((r) => (
<SelectItem key={r.name} value={r.name}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<Select value={branch} onValueChange={setBranch}>
<SelectTrigger><SelectValue placeholder="Pick a branch" /></SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b} value={b}>{b}</SelectItem>
))}
</SelectContent>
</Select>
</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" />
</div>
<div className="space-y-1.5">
<Label htmlFor="p">Bitbucket password</Label>
<Input id="p" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button onClick={deploy} disabled={!repo || !branch || !username || !password || deploying} className="w-full">
{deploying ? 'Starting…' : 'Deploy'}
</Button>
</CardContent>
</Card>
<div className="space-y-6">
{deploymentId && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="font-mono text-base">{deploymentId}</CardTitle>
<CardDescription>{repo} · {branch}</CardDescription>
</div>
<Badge variant={state === 'RUNNING' ? 'success' : state === 'FAILED' ? 'destructive' : 'secondary'}>
{state}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ol className="space-y-1.5 text-sm">
{STAGES.map((s) => {
const v = stageDone[s]
return (
<li key={s} className="flex items-center gap-3">
<span className={
v === 'ok' ? 'text-emerald-600' :
v === 'failed' ? 'text-red-600' :
v === 'in_progress' ? 'text-amber-600' :
'text-muted-foreground'
}>
{v === 'ok' ? '✓' : v === 'failed' ? '✗' : v === 'in_progress' ? '…' : '·'}
</span>
<span className={v === 'in_progress' ? 'font-medium' : ''}>{s}</span>
</li>
)
})}
</ol>
<LogView events={events} />
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
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
.filter((e) => e.kind === 'log' && e.line)
.slice(-500)
.map((e, i) => (
<div key={i} className="whitespace-pre-wrap">{e.line}</div>
))}
</pre>
)
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function LoginForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function submit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!r.ok) {
setError('Login failed — check your Bitbucket credentials.')
return
}
router.push('/dashboard')
} catch (err) {
setError('Network error')
} finally {
setLoading(false)
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Use your Bitbucket account.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="u">Username</Label>
<Input id="u" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="p">Password</Label>
<Input id="p" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
)
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-500 text-white shadow',
warning: 'border-transparent bg-amber-500 text-white shadow',
},
},
defaultVariants: { variant: 'default' },
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+40
View File
@@ -0,0 +1,40 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }
+17
View File
@@ -0,0 +1,17 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+78
View File
@@ -0,0 +1,78 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectValue = SelectPrimitive.Value
const SelectGroup = SelectPrimitive.Group
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' && 'translate-y-1',
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
+35
View File
@@ -0,0 +1,35 @@
// lib/api.ts — shared types and fetch helpers
export type Repo = { name: string; node: string; path: string }
export async function listRepos(): Promise<Repo[]> {
const r = await fetch('/api/repos', { credentials: 'include' })
if (!r.ok) throw new Error('failed to list repos')
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')
return r.json()
}
export type DeployResponse = { id: string }
export async 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()
}
+47
View File
@@ -0,0 +1,47 @@
'use client'
// Single WebSocket hook. One connection per active deployment; auto-reconnect
// on close. Pushed events accumulate into the caller-provided state.
import { useEffect, useRef, useState } from 'react'
export type WsEvent = {
deploymentId: string
kind: 'progress' | 'log' | 'status'
state?: string
stage?: string
line?: string
at: number
backfill?: boolean
}
export function useDeploymentWS(deploymentId: string | null) {
const [events, setEvents] = useState<WsEvent[]>([])
const [state, setState] = useState<string>('QUEUED')
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
if (!deploymentId) return
setEvents([])
setState('QUEUED')
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${proto}://${window.location.host}/ws/deployments/${deploymentId}`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onmessage = (ev) => {
try {
const e: WsEvent = JSON.parse(ev.data)
if (e.kind === 'status' && e.state) setState(e.state)
if (e.kind === 'log' || e.kind === 'progress' || e.kind === 'status') {
setEvents((prev) => [...prev, e].slice(-2000)) // ponytail: cap memory
}
} catch {
// ponytail: drop malformed frames; would-be 3am pain otherwise
}
}
// ponytail: no onclose handler — just let it die. Refresh = resubscribe.
return () => ws.close()
}, [deploymentId])
return { events, state }
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '1rem',
screens: { '2xl': '1280px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
},
borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
},
},
plugins: [require('tailwindcss-animate')],
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}