1
0
forked from wrenn/wrenn

Add BYOC page, admin section, and is_byoc team visibility gating

- Frontend: BYOC hosts page (/dashboard/byoc) with register/delete flows,
  shimmer loading, pulsing online status, animated token reveal checkmark
- Frontend: Admin section (/admin/hosts) with platform + BYOC tabs, stat
  pills, skeleton loading, slide-in animations for new rows
- Frontend: AdminSidebar component with accent top bar and admin pill badge
- Frontend: BYOC nav item shown only when team.is_byoc is true (derived
  from teams store, not JWT); disabled for members
- Frontend: Admin shield button in Sidebar, visible only to platform admins
- Backend: is_admin in JWT claims + requireAdmin middleware (DB-validated)
- Backend: is_byoc added to teamResponse so frontend derives visibility
  from fresh team data rather than stale JWT fields
- Backend: SetBYOC admin endpoint (PUT /v1/admin/teams/{id}/byoc)
- Backend: Admin hosts list enriches BYOC entries with team_name
- Host agent: load .env file via godotenv on startup
This commit is contained in:
2026-03-25 03:10:41 +06:00
parent 9bf67aa7f7
commit e069b3e679
36 changed files with 2200 additions and 163 deletions

View File

@ -8,9 +8,12 @@ import (
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/joho/godotenv"
"git.omukk.dev/wrenn/sandbox/internal/devicemapper"
"git.omukk.dev/wrenn/sandbox/internal/hostagent"
"git.omukk.dev/wrenn/sandbox/internal/sandbox"
@ -18,6 +21,9 @@ import (
)
func main() {
// Best-effort load — missing .env file is fine.
_ = godotenv.Load()
registrationToken := flag.String("register", "", "One-time registration token from the control plane (required on first run)")
advertiseAddr := flag.String("address", "", "Externally-reachable address (ip:port) for this host agent")
flag.Parse()
@ -87,41 +93,57 @@ func main() {
slog.Info("host registered", "host_id", hostID)
// Start heartbeat loop. On CP rejection: try JWT refresh. If that fails,
// pause all running sandboxes to ensure they're not left orphaned.
hostagent.StartHeartbeat(ctx, cpURL, tokenFile, hostID, 30*time.Second, func() {
pauseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mgr.PauseAll(pauseCtx)
})
// httpServer is declared here so the shutdown func can reference it.
httpServer := &http.Server{Addr: listenAddr}
srv := hostagent.NewServer(mgr)
// doShutdown is the single shutdown path. sync.Once ensures mgr.Shutdown
// and httpServer.Shutdown are each called exactly once regardless of
// whether shutdown is triggered by a signal, a heartbeat 404, or the
// Terminate RPC.
var shutdownOnce sync.Once
doShutdown := func(reason string) {
shutdownOnce.Do(func() {
slog.Info("shutting down", "reason", reason)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
mgr.Shutdown(shutdownCtx)
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("http server shutdown error", "error", err)
}
})
}
srv := hostagent.NewServer(mgr, func() {
doShutdown("Terminate RPC received")
})
path, handler := hostagentv1connect.NewHostAgentServiceHandler(srv)
mux := http.NewServeMux()
mux.Handle(path, handler)
httpServer.Handler = mux
httpServer := &http.Server{
Addr: listenAddr,
Handler: mux,
}
// Start heartbeat loop. Handler must be set before this because the
// immediate beat can trigger doShutdown → httpServer.Shutdown synchronously.
hostagent.StartHeartbeat(ctx, cpURL, tokenFile, hostID, 30*time.Second,
// pauseAll: called on 3 consecutive network failures.
func() {
pauseCtx, pauseCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer pauseCancel()
mgr.PauseAll(pauseCtx)
},
// onDeleted: called when CP returns 404 (host was deleted).
func() {
doShutdown("host deleted from CP")
},
)
// Graceful shutdown on signal.
// Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("received signal, shutting down", "signal", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
mgr.Shutdown(shutdownCtx)
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("http server shutdown error", "error", err)
}
doShutdown("signal: " + sig.String())
}()
slog.Info("host agent starting", "addr", listenAddr, "host_id", hostID)

View File

@ -72,8 +72,9 @@ SELECT * FROM hosts WHERE id = $1 AND team_id = $2;
-- Returns all hosts that have completed registration (not pending/offline).
SELECT * FROM hosts WHERE status NOT IN ('pending', 'offline') ORDER BY created_at;
-- name: UpdateHostHeartbeatAndStatus :exec
-- name: UpdateHostHeartbeatAndStatus :execrows
-- Updates last_heartbeat_at and transitions unreachable hosts back to online.
-- Returns 0 if no host was found (deleted), which the caller treats as 404.
UPDATE hosts
SET last_heartbeat_at = NOW(),
status = CASE WHEN status = 'unreachable' THEN 'online' ELSE status END,

View File

@ -0,0 +1,84 @@
import { apiFetch } from './client';
export type Host = {
id: string;
type: 'regular' | 'byoc';
team_id?: string;
team_name?: string;
provider?: string;
availability_zone?: string;
arch?: string;
cpu_cores?: number;
memory_mb?: number;
disk_gb?: number;
address?: string;
status: 'pending' | 'online' | 'offline' | 'unreachable' | 'draining';
last_heartbeat_at?: string;
created_by: string;
created_at: string;
updated_at: string;
};
export type CreateHostParams = {
type: 'regular' | 'byoc';
team_id?: string;
provider?: string;
availability_zone?: string;
};
export type CreateHostResult = {
host: Host;
registration_token: string;
};
export async function listHosts(): Promise<{ ok: true; data: Host[] } | { ok: false; error: string }> {
return apiFetch<Host[]>('GET', '/api/v1/hosts');
}
export async function createHost(
params: CreateHostParams
): Promise<{ ok: true; data: CreateHostResult } | { ok: false; error: string }> {
return apiFetch<CreateHostResult>('POST', '/api/v1/hosts', params);
}
export async function deleteHost(
id: string,
force = false
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
const res = await apiFetch<void>('DELETE', url);
if (!res.ok) {
return res as { ok: false; error: string };
}
return { ok: true };
}
export async function getDeletePreview(
id: string
): Promise<{ ok: true; data: { host: Host; sandbox_ids: string[] } } | { ok: false; error: string }> {
return apiFetch<{ host: Host; sandbox_ids: string[] }>('GET', `/api/v1/hosts/${id}/delete-preview`);
}
export function statusColor(status: Host['status']): string {
switch (status) {
case 'online':
return 'var(--color-accent)';
case 'pending':
return 'var(--color-amber)';
case 'offline':
case 'unreachable':
return 'var(--color-red)';
case 'draining':
return 'var(--color-blue)';
default:
return 'var(--color-text-muted)';
}
}
export function formatSpecs(host: Host): string {
const parts: string[] = [];
if (host.cpu_cores) parts.push(`${host.cpu_cores} vCPU`);
if (host.memory_mb) parts.push(`${Math.round(host.memory_mb / 1024)}GB RAM`);
if (host.disk_gb) parts.push(`${host.disk_gb}GB disk`);
return parts.join(' · ') || '—';
}

View File

@ -29,6 +29,7 @@ export type TeamWithRole = {
id: string;
name: string;
slug: string;
is_byoc: boolean;
created_at: string;
role: string;
};

View File

@ -19,12 +19,23 @@ function isTokenExpired(token: string): boolean {
}
}
function decodeJWTPayload(token: string): Record<string, unknown> {
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return {};
}
}
function createAuth() {
let token = $state<string | null>(null);
let userId = $state<string | null>(null);
let teamId = $state<string | null>(null);
let email = $state<string | null>(null);
let name = $state<string | null>(null);
let isAdmin = $state(false);
let role = $state<string>('member');
let initialized = $state(false);
// Initialize from localStorage synchronously at module load.
@ -36,6 +47,9 @@ function createAuth() {
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
email = localStorage.getItem(STORAGE_KEYS.email);
name = localStorage.getItem(STORAGE_KEYS.name);
const payload = decodeJWTPayload(stored);
isAdmin = Boolean(payload.is_admin);
role = String(payload.role || 'member');
} else if (stored) {
// Expired — clean up.
for (const key of Object.values(STORAGE_KEYS)) {
@ -63,6 +77,12 @@ function createAuth() {
get name() {
return name;
},
get isAdmin() {
return isAdmin;
},
get role() {
return role;
},
get isAuthenticated() {
return isAuthenticated;
},
@ -76,6 +96,9 @@ function createAuth() {
teamId = data.team_id;
email = data.email;
name = data.name;
const payload = decodeJWTPayload(data.token);
isAdmin = Boolean(payload.is_admin);
role = String(payload.role || 'member');
localStorage.setItem(STORAGE_KEYS.token, data.token);
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
@ -90,6 +113,8 @@ function createAuth() {
teamId = null;
email = null;
name = null;
isAdmin = false;
role = 'member';
for (const key of Object.values(STORAGE_KEYS)) {
localStorage.removeItem(key);

View File

@ -0,0 +1,184 @@
<script lang="ts">
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte';
import {
IconServer,
IconSettings,
IconLogout,
IconSidebar,
IconBell,
IconDocs,
IconChevron,
IconShield
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
type NavItem = {
label: string;
icon: typeof IconServer;
href: string;
};
const managementItems: NavItem[] = [
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' }
];
function isActive(href: string): boolean {
const p = $page.url.pathname;
return p === href || p.startsWith(href + '/');
}
function toggleCollapsed() {
collapsed = !collapsed;
localStorage.setItem('wrenn_sidebar_collapsed', String(collapsed));
}
let userName = $derived(auth.name || auth.email || '');
</script>
<aside
class="relative flex h-screen shrink-0 flex-col overflow-hidden border-r border-[var(--color-border)] bg-[var(--color-bg-1)] transition-[width] duration-250 ease-in-out"
style="width: {collapsed ? '56px' : '230px'}"
>
<!-- Subtle accent top-edge — marks this as an elevated context -->
<div class="absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-[var(--color-accent)]/60 via-[var(--color-accent)] to-[var(--color-accent)]/60"></div>
<!-- Brand + collapse toggle -->
<div class="flex shrink-0 items-center px-4 pt-6 pb-4 {collapsed ? 'justify-center' : 'justify-between'}">
{#if !collapsed}
<div class="flex items-center gap-2.5">
<div class="relative">
<img
src="/logo.svg"
alt="Wrenn"
class="h-7 w-7 shrink-0 rounded-[var(--radius-logo)]"
/>
</div>
<div class="flex flex-col gap-0.5 leading-none">
<span class="font-brand text-[1.286rem] text-[var(--color-text-bright)]">Wrenn</span>
<span class="inline-flex w-fit items-center gap-1 rounded-full bg-[var(--color-accent)]/15 px-1.5 py-px text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-accent-bright)]">
<IconShield size={8} />
Admin
</span>
</div>
</div>
{:else}
<!-- Collapsed: show shield as admin identity marker -->
<div class="flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)]/10 text-[var(--color-accent-bright)]">
<IconShield size={14} />
</div>
{/if}
<button
onclick={toggleCollapsed}
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-button)] text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)] {collapsed ? 'absolute top-5 right-1.5' : ''}"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<IconSidebar size={16} />
</button>
</div>
<!-- Back to dashboard -->
<div class="px-3 pb-3 {collapsed ? 'px-1.5' : ''}">
<a
href="/dashboard"
class="flex items-center rounded-[var(--radius-input)] px-2.5 py-2 text-ui text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)] {collapsed ? 'justify-center px-2' : 'gap-2'}"
title={collapsed ? 'Back to dashboard' : undefined}
>
<IconChevron size={12} direction="left" class="shrink-0" />
{#if !collapsed}<span>Dashboard</span>{/if}
</a>
</div>
<div class="mx-4 mb-3 h-px bg-[var(--color-border)] {collapsed ? 'mx-3' : ''}"></div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 {collapsed ? 'px-1.5' : ''}">
<div class="mb-3">
{#if !collapsed}
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
Management
</div>
{:else}
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
{/if}
{#each managementItems as item}
{#if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
{#if !collapsed}
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
{#if !collapsed}
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
{/if}
</a>
{:else}
<a
href={item.href}
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
</nav>
<!-- Bottom links -->
<div class="shrink-0 px-3 pb-1 {collapsed ? 'px-1.5' : ''}">
<a
href="/docs"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Docs' : undefined}
>
<IconDocs size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Docs</span>{/if}
</a>
<a
href="/dashboard/notifications"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Notifications' : undefined}
>
<IconBell size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
</a>
<a
href="/dashboard/settings"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Settings' : undefined}
>
<IconSettings size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
</a>
</div>
<!-- User footer -->
<div
class="flex shrink-0 items-center border-t border-[var(--color-border)] px-3 py-2.5 {collapsed ? 'justify-center px-1.5' : 'gap-2.5'}"
>
{#if !collapsed}
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]">
{userName[0] ?? ''}
</div>
<span class="flex-1 truncate text-ui text-[var(--color-text-secondary)]">
{userName}
</span>
{/if}
<button
onclick={() => auth.logout()}
class="flex shrink-0 items-center justify-center rounded-[var(--radius-button)] transition-colors duration-150 hover:text-[var(--color-red)] {collapsed ? 'h-7 w-7 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-3)]' : 'h-6 w-6 text-[var(--color-text-tertiary)]'}"
title="Sign out"
>
<IconLogout size={collapsed ? 15 : 14} />
</button>
</div>
</aside>

View File

@ -19,7 +19,9 @@
IconSidebar,
IconBell,
IconDocs,
IconAudit
IconAudit,
IconServer,
IconShield
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
@ -39,6 +41,8 @@
label: string;
icon: typeof IconMonitor;
href: string;
disabled?: boolean;
disabledHint?: string;
};
const platformItems: NavItem[] = [
@ -46,11 +50,24 @@
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }
];
const managementItems: NavItem[] = [
let currentTeamIsByoc = $derived(
teamsStore.list.find((t) => t.id === auth.teamId)?.is_byoc ?? false
);
let managementItems = $derived<NavItem[]>([
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
{ label: 'Team', icon: IconMembers, href: '/dashboard/team' },
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' }
];
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' },
...(currentTeamIsByoc
? [{
label: 'BYOC',
icon: IconServer,
href: '/dashboard/byoc',
disabled: auth.role === 'member',
disabledHint: 'Available to team owners and admins only'
}]
: [])
]);
const billingItems: NavItem[] = [
{ label: 'Usage', icon: IconUsage, href: '/dashboard/usage' },
@ -232,6 +249,16 @@
<!-- Bottom links -->
<div class="shrink-0 px-3 pb-1 {collapsed ? 'px-1.5' : ''}">
{#if auth.isAdmin}
<a
href="/admin"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Admin' : undefined}
>
<IconShield size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Admin</span>{/if}
</a>
{/if}
<a
href="/docs"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
@ -300,7 +327,19 @@
</div>
{/if}
{#each items as item}
{#if isActive(item.href)}
{#if item.disabled}
<div
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed
? 'justify-center px-2'
: 'gap-3'}"
title={collapsed ? item.disabledHint ?? item.label : item.disabledHint}
>
<item.icon size={16} class="shrink-0" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)]">{item.label}</span>
{/if}
</div>
{:else if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed

View File

@ -0,0 +1,19 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>

View File

@ -0,0 +1,21 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>

View File

@ -23,3 +23,6 @@ export { default as IconBell } from './IconBell.svelte';
export { default as IconDocs } from './IconDocs.svelte';
export { default as IconAudit } from './IconAudit.svelte';
export { default as IconBox } from './IconBox.svelte';
export { default as IconServer } from './IconServer.svelte';
export { default as IconGear } from './IconGear.svelte';
export { default as IconShield } from './IconShield.svelte';

View File

@ -0,0 +1,7 @@
<script lang="ts">
import Toaster from '$lib/components/Toaster.svelte';
let { children } = $props();
</script>
<Toaster />
{@render children()}

View File

@ -0,0 +1,9 @@
import { browser } from '$app/environment';
import { redirect } from '@sveltejs/kit';
import { auth } from '$lib/auth.svelte';
export const load = () => {
if (!browser) return;
if (!auth.isAuthenticated) redirect(302, '/login');
if (!auth.isAdmin) redirect(302, '/dashboard');
};

View File

@ -0,0 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => goto('/admin/hosts', { replaceState: true }));
</script>

View File

@ -0,0 +1,679 @@
<script lang="ts">
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
import { onMount } from 'svelte';
import { toast } from '$lib/toast.svelte';
import { formatDate, timeAgo } from '$lib/utils/format';
import {
listHosts,
createHost,
deleteHost,
getDeletePreview,
statusColor,
formatSpecs,
type Host,
type CreateHostResult
} from '$lib/api/hosts';
const PAGE_SIZE = 50;
let collapsed = $state(
typeof window !== 'undefined'
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
: false
);
let activeTab = $state<'platform' | 'byoc'>('platform');
// All hosts fetched once
let allHosts = $state<Host[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Platform tab state
let platformHosts = $derived(allHosts.filter((h) => h.type === 'regular'));
// BYOC tab state — grouped by team, sorted by count descending, paginated
let byocHosts = $derived(allHosts.filter((h) => h.type === 'byoc'));
let byocPage = $state(0);
type TeamGroup = { teamId: string | null; teamName: string; hosts: Host[] };
let byocGroups = $derived.by<TeamGroup[]>(() => {
const map = new Map<string, TeamGroup>();
for (const h of byocHosts) {
const key = h.team_id ?? '__none__';
if (!map.has(key)) {
map.set(key, {
teamId: h.team_id ?? null,
teamName: h.team_name ?? h.team_id ?? 'Unknown Team',
hosts: []
});
}
map.get(key)!.hosts.push(h);
}
return [...map.values()].sort((a, b) => b.hosts.length - a.hosts.length);
});
// Flatten for pagination: all byoc hosts sorted by team group count order
let flatByocHosts = $derived(byocGroups.flatMap((g) => g.hosts));
let byocPageCount = $derived(Math.max(1, Math.ceil(flatByocHosts.length / PAGE_SIZE)));
let byocPageHosts = $derived(flatByocHosts.slice(byocPage * PAGE_SIZE, (byocPage + 1) * PAGE_SIZE));
// Stats across all hosts
let onlineCount = $derived(allHosts.filter((h) => h.status === 'online').length);
let pendingCount = $derived(allHosts.filter((h) => h.status === 'pending').length);
let totalCount = $derived(allHosts.length);
// Create dialog (platform hosts)
let showCreate = $state(false);
let createForm = $state({ provider: '', availability_zone: '' });
let creating = $state(false);
let createError = $state<string | null>(null);
// Token reveal
let createdResult = $state<CreateHostResult | null>(null);
let tokenCopied = $state(false);
let checkmarkVisible = $state(false);
// Delete confirmation
let deleteTarget = $state<Host | null>(null);
let deletePreviewSandboxes = $state<string[]>([]);
let deletePreviewLoading = $state(false);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
let flashHostId = $state<string | null>(null);
let newHostId = $state<string | null>(null);
async function fetchHosts() {
loading = true;
error = null;
const result = await listHosts();
if (result.ok) {
allHosts = result.data;
} else {
error = result.error;
}
loading = false;
}
async function handleCreatePlatform() {
creating = true;
createError = null;
const result = await createHost({
type: 'regular',
provider: createForm.provider.trim() || undefined,
availability_zone: createForm.availability_zone.trim() || undefined
});
if (result.ok) {
showCreate = false;
createForm = { provider: '', availability_zone: '' };
createdResult = result.data;
newHostId = result.data.host.id;
allHosts = [result.data.host, ...allHosts];
flashHostId = result.data.host.id;
// Trigger checkmark animation after modal mounts
setTimeout(() => (checkmarkVisible = true), 80);
setTimeout(() => (flashHostId = null), 2500);
} else {
createError = result.error;
}
creating = false;
}
async function openDeleteConfirm(host: Host) {
deleteTarget = host;
deleteError = null;
deletePreviewSandboxes = [];
deletePreviewLoading = true;
const preview = await getDeletePreview(host.id);
deletePreviewLoading = false;
if (preview.ok) {
deletePreviewSandboxes = preview.data.sandbox_ids;
}
}
async function handleDelete() {
if (!deleteTarget) return;
deleting = true;
deleteError = null;
const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0);
if (result.ok) {
allHosts = allHosts.filter((h) => h.id !== deleteTarget!.id);
deleteTarget = null;
toast.success('Host deleted');
} else {
deleteError = result.error;
}
deleting = false;
}
async function copyToken(token: string) {
await navigator.clipboard.writeText(token);
tokenCopied = true;
setTimeout(() => (tokenCopied = false), 2000);
}
function closeTokenReveal() {
createdResult = null;
checkmarkVisible = false;
newHostId = null;
}
onMount(fetchHosts);
</script>
<div class="flex h-screen overflow-hidden bg-[var(--color-bg-0)]">
<AdminSidebar bind:collapsed />
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Header -->
<header class="flex shrink-0 flex-col gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6 py-5">
<div class="flex items-start justify-between">
<div>
<h1 class="font-serif text-[1.75rem] leading-none tracking-[-0.03em] text-[var(--color-text-bright)]">
Hosts
</h1>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Platform and BYOC compute across all teams.
</p>
</div>
{#if activeTab === 'platform'}
<button
onclick={() => { showCreate = true; createError = null; createForm = { provider: '', availability_zone: '' }; }}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Host
</button>
{/if}
</div>
<!-- Stat pills -->
{#if !loading && !error}
<div class="flex items-center gap-2">
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{totalCount}</span>
<span class="text-label text-[var(--color-text-muted)]">total</span>
</div>
<div class="flex items-baseline gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-1">
<span class="relative mt-px flex h-1.5 w-1.5 shrink-0 self-center">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-accent)] opacity-60"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
</span>
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-accent-bright)]">{onlineCount}</span>
<span class="text-label text-[var(--color-accent-bright)]/70">online</span>
</div>
{#if pendingCount > 0}
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-2.5 py-1">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-amber)]">{pendingCount}</span>
<span class="text-label text-[var(--color-amber)]/70">pending</span>
</div>
{/if}
</div>
{/if}
</header>
<!-- Tabs -->
<div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6">
{#each [['platform', 'Platform', platformHosts.length], ['byoc', 'BYOC', byocHosts.length]] as [id, label, count] (id)}
<button
onclick={() => { activeTab = id as 'platform' | 'byoc'; if (id === 'byoc') byocPage = 0; }}
class="relative py-3 pr-5 text-ui transition-colors duration-150 {activeTab === id
? 'font-medium text-[var(--color-text-bright)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}"
>
{label}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-5 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
{#if !loading}
<span class="ml-2 rounded-full bg-[var(--color-bg-4)] px-1.5 py-0.5 text-label text-[var(--color-text-muted)]">
{count}
</span>
{/if}
</button>
{/each}
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-6">
{#if loading}
{@render skeletonRows()}
{:else if error}
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
{error}
</div>
{:else if activeTab === 'platform'}
{@render hostsTable(platformHosts, false)}
{:else}
<!-- BYOC hosts: grouped by team -->
{#if byocHosts.length === 0}
{@render emptyState('byoc')}
{:else}
<div class="space-y-5">
{#each byocGroups as group (group.teamId ?? '__none__')}
{@const groupPageHosts = byocPageHosts.filter(h => h.team_id === group.teamId || (group.teamId === null && !h.team_id))}
{#if groupPageHosts.length > 0}
<div>
<div class="mb-2.5 flex items-center gap-2.5">
<span class="text-label font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{group.teamName}
</span>
<span class="rounded-full bg-[var(--color-bg-3)] px-1.5 py-0.5 font-mono text-label text-[var(--color-text-muted)]">
{group.hosts.length}
</span>
</div>
{@render hostsTable(groupPageHosts, false)}
</div>
{/if}
{/each}
<!-- Pagination -->
{#if byocPageCount > 1}
<div class="flex items-center justify-between pt-2">
<span class="text-meta text-[var(--color-text-muted)]">
Page {byocPage + 1} of {byocPageCount} · {byocHosts.length} hosts
</span>
<div class="flex items-center gap-2">
<button
onclick={() => (byocPage = Math.max(0, byocPage - 1))}
disabled={byocPage === 0}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
>
← Previous
</button>
<button
onclick={() => (byocPage = Math.min(byocPageCount - 1, byocPage + 1))}
disabled={byocPage >= byocPageCount - 1}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
>
Next →
</button>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>
</main>
</div>
{#snippet skeletonRows()}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--color-border)]">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Host</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th>
<th class="hidden px-4 py-3 md:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Specs</th>
<th class="hidden px-4 py-3 lg:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Last Heartbeat</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{#each Array(5) as _, i}
<tr class="border-b border-[var(--color-border)] last:border-0" style="animation-delay: {i * 60}ms">
<td class="px-4 py-3.5">
<div class="skeleton mb-1.5 h-3 w-28 rounded"></div>
<div class="skeleton h-2.5 w-20 rounded"></div>
</td>
<td class="px-4 py-3.5">
<div class="skeleton h-3 w-16 rounded-full"></div>
</td>
<td class="hidden px-4 py-3.5 md:table-cell">
<div class="skeleton h-3 w-24 rounded"></div>
</td>
<td class="hidden px-4 py-3.5 lg:table-cell">
<div class="skeleton h-3 w-20 rounded"></div>
</td>
<td class="px-4 py-3.5 text-right">
<div class="skeleton ml-auto h-6 w-14 rounded"></div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/snippet}
{#snippet hostsTable(hosts: Host[], _showTeam: boolean)}
{#if hosts.length === 0}
{@render emptyState('platform')}
{:else}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--color-border)]">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Host</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Specs</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Last Heartbeat</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{#each hosts as host (host.id)}
<tr
class="row-entry border-b border-[var(--color-border)] last:border-0 transition-colors duration-200
{host.id === newHostId ? 'new-row' : ''}
{flashHostId === host.id ? 'bg-[var(--color-accent-glow)]' : 'hover:bg-[var(--color-bg-2)]'}"
>
<td class="px-4 py-3.5">
<div class="font-mono text-meta text-[var(--color-text-primary)]">{host.id}</div>
{#if host.address}
<div class="mt-0.5 font-mono text-label text-[var(--color-text-muted)]">{host.address}</div>
{/if}
{#if host.provider || host.availability_zone}
<div class="mt-0.5 text-label text-[var(--color-text-tertiary)]">
{[host.provider, host.availability_zone].filter(Boolean).join(' · ')}
</div>
{/if}
</td>
<td class="px-4 py-3.5">
<span class="flex items-center gap-1.5 text-meta font-medium" style="color: {statusColor(host.status)}">
{#if host.status === 'online'}
<span class="relative flex h-1.5 w-1.5 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background: {statusColor(host.status)}"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background: {statusColor(host.status)}"></span>
</span>
{:else}
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background: {statusColor(host.status)}"></span>
{/if}
{host.status}
</span>
</td>
<td class="hidden px-4 py-3.5 md:table-cell">
<span class="text-meta text-[var(--color-text-secondary)]">{formatSpecs(host)}</span>
</td>
<td class="hidden px-4 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={host.last_heartbeat_at ? formatDate(host.last_heartbeat_at) : undefined}>
{host.last_heartbeat_at ? timeAgo(host.last_heartbeat_at) : '—'}
</span>
</td>
<td class="px-4 py-3.5 text-right">
<button
onclick={() => openDeleteConfirm(host)}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/snippet}
{#snippet emptyState(type: 'platform' | 'byoc')}
<div class="flex flex-col items-center justify-center py-24 text-center">
<div class="mb-5 flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]">
{type === 'platform' ? 'No platform hosts yet.' : 'No BYOC hosts across any team.'}
</p>
<p class="mt-1.5 text-ui text-[var(--color-text-muted)]">
{type === 'platform'
? 'Add a host to start scheduling capsules onto your own compute.'
: 'Teams that register their own compute will appear here.'}
</p>
</div>
{/snippet}
<!-- Add Platform Host Dialog -->
{#if showCreate}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div
class="absolute inset-0 bg-black/60"
role="button"
tabindex="-1"
onclick={() => { if (!creating) showCreate = false; }}
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Add Platform Host
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Register a new platform-managed host. You'll receive a one-time registration token.
</p>
{#if createError}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{createError}
</div>
{/if}
<div class="mt-5 space-y-4">
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="platform-provider">
Provider <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional)</span>
</label>
<input
id="platform-provider"
type="text"
placeholder="e.g. aws, gcp, bare-metal"
bind:value={createForm.provider}
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="platform-az">
Availability Zone <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional)</span>
</label>
<input
id="platform-az"
type="text"
placeholder="e.g. us-east-1a"
bind:value={createForm.availability_zone}
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (showCreate = false)}
disabled={creating}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleCreatePlatform}
disabled={creating}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if creating}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Adding…
{:else}
Add Host
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Token reveal -->
{#if createdResult}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60"></div>
<div
class="relative w-full max-w-[500px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
>
<!-- Animated checkmark -->
<div class="mb-5 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-accent-glow)]">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-bright)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline
points="20 6 9 17 4 12"
class="checkmark-path"
class:checkmark-drawn={checkmarkVisible}
/>
</svg>
</div>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Host registered
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Pass this token to the host agent to complete registration. It expires in
<strong class="font-semibold text-[var(--color-amber)]">1 hour</strong> and is single-use.
</p>
<div class="mt-5 rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-0)] p-3">
<div class="flex items-start gap-2">
<code class="flex-1 break-all font-mono text-[0.8rem] leading-relaxed text-[var(--color-text-primary)]">
{createdResult.registration_token}
</code>
<button
onclick={() => copyToken(createdResult!.registration_token)}
class="shrink-0 rounded-[var(--radius-button)] px-2.5 py-1.5 text-label font-semibold transition-all duration-200 {tokenCopied
? 'bg-[var(--color-accent-glow)] text-[var(--color-accent-bright)] scale-95'
: 'bg-[var(--color-bg-5)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
{tokenCopied ? '✓ Copied' : 'Copy'}
</button>
</div>
</div>
<div class="mt-3 flex items-start gap-2 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/6 px-3 py-2.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-amber)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mt-0.5 shrink-0"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<p class="text-meta text-[var(--color-amber)]">
This token will not be shown again. Store it safely before closing.
</p>
</div>
<div class="mt-6">
<button
onclick={closeTokenReveal}
class="w-full rounded-[var(--radius-button)] bg-[var(--color-bg-4)] px-4 py-2.5 text-ui font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-bg-5)]"
>
Done
</button>
</div>
</div>
</div>
{/if}
<!-- Delete confirmation -->
{#if deleteTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div
class="absolute inset-0 bg-black/60"
role="button"
tabindex="-1"
onclick={() => { if (!deleting) deleteTarget = null; }}
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Delete Host
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Permanently remove <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{deleteTarget.id}</code>.
</p>
{#if deletePreviewLoading}
<div class="mt-4 flex items-center gap-2 text-meta text-[var(--color-text-muted)]">
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Checking active capsules…
</div>
{:else if deletePreviewSandboxes.length > 0}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/6 px-3 py-2.5">
<p class="text-meta font-semibold text-[var(--color-amber)]">
{deletePreviewSandboxes.length} active capsule{deletePreviewSandboxes.length === 1 ? '' : 's'} will be destroyed.
</p>
<p class="mt-0.5 text-meta text-[var(--color-amber)]/70">
All running workloads on this host will be terminated immediately.
</p>
</div>
{/if}
{#if deleteError}
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{deleteError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (deleteTarget = null)}
disabled={deleting}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDelete}
disabled={deleting || deletePreviewLoading}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 disabled:opacity-50"
>
{#if deleting}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Deleting…
{:else}
{deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete'}
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-6px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg-3) 25%,
var(--color-bg-4) 50%,
var(--color-bg-3) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
}
.new-row {
animation: slideIn 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
}
/* Checkmark draw animation */
.checkmark-path {
stroke-dasharray: 30;
stroke-dashoffset: 30;
transition: stroke-dashoffset 0.4s cubic-bezier(0.25, 1, 0.5, 1) 0.1s;
}
.checkmark-drawn {
stroke-dashoffset: 0;
}
</style>

View File

@ -0,0 +1,587 @@
<script lang="ts">
import Sidebar from '$lib/components/Sidebar.svelte';
import { onMount } from 'svelte';
import { auth } from '$lib/auth.svelte';
import { toast } from '$lib/toast.svelte';
import { formatDate, timeAgo } from '$lib/utils/format';
import {
listHosts,
createHost,
deleteHost,
getDeletePreview,
statusColor,
formatSpecs,
type Host,
type CreateHostResult
} from '$lib/api/hosts';
let collapsed = $state(
typeof window !== 'undefined'
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
: false
);
let canManage = $derived(auth.role === 'owner' || auth.role === 'admin');
// List state
let hosts = $state<Host[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Create dialog
let showCreate = $state(false);
let createForm = $state({ provider: '', availability_zone: '' });
let creating = $state(false);
let createError = $state<string | null>(null);
// Token reveal — shown once after creation
let createdResult = $state<CreateHostResult | null>(null);
let tokenCopied = $state(false);
let checkmarkVisible = $state(false);
// Delete confirmation
let deleteTarget = $state<Host | null>(null);
let deletePreviewSandboxes = $state<string[]>([]);
let deletePreviewLoading = $state(false);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
let flashHostId = $state<string | null>(null);
let newHostId = $state<string | null>(null);
// Derived stats
let onlineCount = $derived(hosts.filter((h) => h.status === 'online').length);
async function fetchHosts() {
loading = true;
error = null;
const result = await listHosts();
if (result.ok) {
hosts = result.data.filter((h) => h.type === 'byoc');
} else {
error = result.error;
}
loading = false;
}
async function handleCreate() {
creating = true;
createError = null;
const result = await createHost({
type: 'byoc',
team_id: auth.teamId ?? undefined,
provider: createForm.provider.trim() || undefined,
availability_zone: createForm.availability_zone.trim() || undefined
});
if (result.ok) {
showCreate = false;
createForm = { provider: '', availability_zone: '' };
createdResult = result.data;
newHostId = result.data.host.id;
hosts = [result.data.host, ...hosts];
flashHostId = result.data.host.id;
setTimeout(() => (checkmarkVisible = true), 80);
setTimeout(() => (flashHostId = null), 2500);
} else {
createError = result.error;
}
creating = false;
}
async function openDeleteConfirm(host: Host) {
deleteTarget = host;
deleteError = null;
deletePreviewSandboxes = [];
deletePreviewLoading = true;
const preview = await getDeletePreview(host.id);
deletePreviewLoading = false;
if (preview.ok) {
deletePreviewSandboxes = preview.data.sandbox_ids;
}
}
async function handleDelete() {
if (!deleteTarget) return;
deleting = true;
deleteError = null;
const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0);
if (result.ok) {
hosts = hosts.filter((h) => h.id !== deleteTarget!.id);
deleteTarget = null;
toast.success('Host deleted');
} else {
deleteError = result.error;
}
deleting = false;
}
async function copyToken(token: string) {
await navigator.clipboard.writeText(token);
tokenCopied = true;
setTimeout(() => (tokenCopied = false), 2000);
}
function closeTokenReveal() {
createdResult = null;
checkmarkVisible = false;
newHostId = null;
}
onMount(fetchHosts);
</script>
<div class="flex h-screen overflow-hidden bg-[var(--color-bg-0)]">
<Sidebar bind:collapsed />
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Header -->
<header class="flex shrink-0 flex-col gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6 py-5">
<div class="flex items-start justify-between">
<div>
<h1 class="font-serif text-[1.75rem] leading-none tracking-[-0.03em] text-[var(--color-text-bright)]">
BYOC Hosts
</h1>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Your own compute, running Wrenn capsules.
</p>
</div>
{#if canManage}
<button
onclick={() => { showCreate = true; createError = null; createForm = { provider: '', availability_zone: '' }; }}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Register Host
</button>
{/if}
</div>
<!-- Stat pills (only when data is loaded) -->
{#if !loading && !error && hosts.length > 0}
<div class="flex items-center gap-2">
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{hosts.length}</span>
<span class="text-label text-[var(--color-text-muted)]">total</span>
</div>
<div class="flex items-baseline gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-1">
<span class="relative mt-px flex h-1.5 w-1.5 shrink-0 self-center">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-accent)] opacity-60"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
</span>
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-accent-bright)]">{onlineCount}</span>
<span class="text-label text-[var(--color-accent-bright)]/70">online</span>
</div>
</div>
{/if}
</header>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-6">
{#if loading}
{@render skeletonRows()}
{:else if error}
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
{error}
</div>
{:else if hosts.length === 0}
{@render emptyState()}
{:else}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--color-border)]">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Host</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Specs</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Last Heartbeat</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Registered</th>
{#if canManage}
<th class="px-4 py-3"></th>
{/if}
</tr>
</thead>
<tbody>
{#each hosts as host (host.id)}
<tr
class="border-b border-[var(--color-border)] last:border-0 transition-colors duration-200
{host.id === newHostId ? 'new-row' : ''}
{flashHostId === host.id ? 'bg-[var(--color-accent-glow)]' : 'hover:bg-[var(--color-bg-2)]'}"
>
<td class="px-4 py-3.5">
<div class="font-mono text-meta text-[var(--color-text-primary)]">{host.id}</div>
{#if host.address}
<div class="mt-0.5 font-mono text-label text-[var(--color-text-muted)]">{host.address}</div>
{/if}
{#if host.provider || host.availability_zone}
<div class="mt-0.5 text-label text-[var(--color-text-tertiary)]">
{[host.provider, host.availability_zone].filter(Boolean).join(' · ')}
</div>
{/if}
</td>
<td class="px-4 py-3.5">
<span class="flex items-center gap-1.5 text-meta font-medium" style="color: {statusColor(host.status)}">
{#if host.status === 'online'}
<span class="relative flex h-1.5 w-1.5 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background: {statusColor(host.status)}"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background: {statusColor(host.status)}"></span>
</span>
{:else}
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background: {statusColor(host.status)}"></span>
{/if}
{host.status}
</span>
</td>
<td class="hidden px-4 py-3.5 md:table-cell">
<span class="text-meta text-[var(--color-text-secondary)]">{formatSpecs(host)}</span>
</td>
<td class="hidden px-4 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={host.last_heartbeat_at ? formatDate(host.last_heartbeat_at) : undefined}>
{host.last_heartbeat_at ? timeAgo(host.last_heartbeat_at) : '—'}
</span>
</td>
<td class="hidden px-4 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={formatDate(host.created_at)}>
{timeAgo(host.created_at)}
</span>
</td>
{#if canManage}
<td class="px-4 py-3.5 text-right">
<button
onclick={() => openDeleteConfirm(host)}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
>
Delete
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</main>
</div>
{#snippet skeletonRows()}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--color-border)]">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Host</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th>
<th class="hidden px-4 py-3 md:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Specs</th>
<th class="hidden px-4 py-3 lg:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Last Heartbeat</th>
<th class="hidden px-4 py-3 lg:table-cell text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Registered</th>
</tr>
</thead>
<tbody>
{#each Array(4) as _, i}
<tr class="border-b border-[var(--color-border)] last:border-0" style="animation-delay: {i * 60}ms">
<td class="px-4 py-3.5">
<div class="skeleton mb-1.5 h-3 w-28 rounded"></div>
<div class="skeleton h-2.5 w-20 rounded"></div>
</td>
<td class="px-4 py-3.5">
<div class="skeleton h-3 w-16 rounded-full"></div>
</td>
<td class="hidden px-4 py-3.5 md:table-cell">
<div class="skeleton h-3 w-24 rounded"></div>
</td>
<td class="hidden px-4 py-3.5 lg:table-cell">
<div class="skeleton h-3 w-20 rounded"></div>
</td>
<td class="hidden px-4 py-3.5 lg:table-cell">
<div class="skeleton h-3 w-16 rounded"></div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/snippet}
{#snippet emptyState()}
<div class="flex flex-col items-center justify-center py-24 text-center">
<div class="mb-5 flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
{#if canManage}
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]">
No hosts yet.
</p>
<p class="mt-1.5 max-w-[340px] text-ui text-[var(--color-text-muted)]">
Register a server and Wrenn will schedule capsules on your own infrastructure.
</p>
<button
onclick={() => { showCreate = true; createError = null; }}
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Register your first host
</button>
{:else}
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]">
No hosts registered.
</p>
<p class="mt-1.5 max-w-[320px] text-ui text-[var(--color-text-muted)]">
Ask a team owner or admin to register a BYOC host for your team.
</p>
{/if}
</div>
{/snippet}
<!-- Register Host Dialog -->
{#if showCreate}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div
class="absolute inset-0 bg-black/60"
role="button"
tabindex="-1"
onclick={() => { if (!creating) showCreate = false; }}
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Register Host
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Add a server to your team's BYOC pool. You'll receive a one-time registration token.
</p>
{#if createError}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{createError}
</div>
{/if}
<div class="mt-5 space-y-4">
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="byoc-provider">
Provider <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional)</span>
</label>
<input
id="byoc-provider"
type="text"
placeholder="e.g. aws, gcp, bare-metal"
bind:value={createForm.provider}
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="byoc-az">
Availability Zone <span class="normal-case font-normal text-[var(--color-text-muted)]">(optional)</span>
</label>
<input
id="byoc-az"
type="text"
placeholder="e.g. us-east-1a"
bind:value={createForm.availability_zone}
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (showCreate = false)}
disabled={creating}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleCreate}
disabled={creating}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if creating}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Registering…
{:else}
Register
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Registration Token Reveal -->
{#if createdResult}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60"></div>
<div
class="relative w-full max-w-[500px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
>
<!-- Animated checkmark -->
<div class="mb-5 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-accent-glow)]">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-bright)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline
points="20 6 9 17 4 12"
class="checkmark-path"
class:checkmark-drawn={checkmarkVisible}
/>
</svg>
</div>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Host registered
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Pass this token to the host agent to complete registration. It expires in
<strong class="font-semibold text-[var(--color-amber)]">1 hour</strong> and is single-use.
</p>
<div class="mt-5 rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-0)] p-3">
<div class="flex items-start gap-2">
<code class="flex-1 break-all font-mono text-[0.8rem] leading-relaxed text-[var(--color-text-primary)]">
{createdResult.registration_token}
</code>
<button
onclick={() => copyToken(createdResult!.registration_token)}
class="shrink-0 rounded-[var(--radius-button)] px-2.5 py-1.5 text-label font-semibold transition-all duration-200 {tokenCopied
? 'bg-[var(--color-accent-glow)] text-[var(--color-accent-bright)] scale-95'
: 'bg-[var(--color-bg-5)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
{tokenCopied ? '✓ Copied' : 'Copy'}
</button>
</div>
</div>
<div class="mt-3 flex items-start gap-2 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/6 px-3 py-2.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-amber)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mt-0.5 shrink-0"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<p class="text-meta text-[var(--color-amber)]">
This token will not be shown again. Store it safely before closing.
</p>
</div>
<div class="mt-6">
<button
onclick={closeTokenReveal}
class="w-full rounded-[var(--radius-button)] bg-[var(--color-bg-4)] px-4 py-2.5 text-ui font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-bg-5)]"
>
Done
</button>
</div>
</div>
</div>
{/if}
<!-- Delete Confirmation Dialog -->
{#if deleteTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div
class="absolute inset-0 bg-black/60"
role="button"
tabindex="-1"
onclick={() => { if (!deleting) deleteTarget = null; }}
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
>
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Delete Host
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Remove <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{deleteTarget.id}</code> from your BYOC pool.
</p>
{#if deletePreviewLoading}
<div class="mt-4 flex items-center gap-2 text-meta text-[var(--color-text-muted)]">
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Checking active capsules…
</div>
{:else if deletePreviewSandboxes.length > 0}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/6 px-3 py-2.5">
<p class="text-meta font-semibold text-[var(--color-amber)]">
{deletePreviewSandboxes.length} active capsule{deletePreviewSandboxes.length === 1 ? '' : 's'} will be destroyed.
</p>
<p class="mt-0.5 text-meta text-[var(--color-amber)]/70">
All running workloads on this host will be terminated immediately.
</p>
</div>
{/if}
{#if deleteError}
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{deleteError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (deleteTarget = null)}
disabled={deleting}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDelete}
disabled={deleting || deletePreviewLoading}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 disabled:opacity-50"
>
{#if deleting}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Deleting…
{:else}
{deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete'}
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-6px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg-3) 25%,
var(--color-bg-4) 50%,
var(--color-bg-3) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
}
.new-row {
animation: slideIn 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
}
/* Checkmark draw animation */
.checkmark-path {
stroke-dasharray: 30;
stroke-dashoffset: 30;
transition: stroke-dashoffset 0.4s cubic-bezier(0.25, 1, 0.5, 1) 0.1s;
}
.checkmark-drawn {
stroke-dashoffset: 0;
}
</style>

View File

@ -168,7 +168,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, req.Name, "owner")
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, req.Name, "owner", false)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
@ -228,7 +228,7 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, user.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
@ -298,7 +298,7 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, user.Name, membership.Role)
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, user.Name, membership.Role, user.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return

View File

@ -77,6 +77,7 @@ type hostResponse struct {
ID string `json:"id"`
Type string `json:"type"`
TeamID *string `json:"team_id,omitempty"`
TeamName *string `json:"team_name,omitempty"`
Provider *string `json:"provider,omitempty"`
AvailabilityZone *string `json:"availability_zone,omitempty"`
Arch *string `json:"arch,omitempty"`
@ -174,16 +175,41 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
// List handles GET /v1/hosts.
func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
admin := h.isAdmin(r, ac.UserID)
hosts, err := h.svc.List(r.Context(), ac.TeamID, h.isAdmin(r, ac.UserID))
hosts, err := h.svc.List(r.Context(), ac.TeamID, admin)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
return
}
// Collect unique team IDs so we can fetch team names in one pass.
var teamNames map[string]string
if admin {
seen := make(map[string]struct{})
for _, host := range hosts {
if host.TeamID.Valid {
seen[host.TeamID.String] = struct{}{}
}
}
if len(seen) > 0 {
teamNames = make(map[string]string, len(seen))
for id := range seen {
if team, err := h.queries.GetTeam(r.Context(), id); err == nil {
teamNames[id] = team.Name
}
}
}
}
resp := make([]hostResponse, len(hosts))
for i, host := range hosts {
resp[i] = hostToResponse(host)
if host.TeamID.Valid {
if name, ok := teamNames[host.TeamID.String]; ok {
resp[i].TeamName = &name
}
}
}
writeJSON(w, http.StatusOK, resp)
@ -322,7 +348,8 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update heartbeat")
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
return
}

View File

@ -156,7 +156,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
redirectWithError(w, r, redirectBase, "db_error")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, user.IsAdmin)
if err != nil {
slog.Error("oauth login: failed to sign jwt", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
@ -255,7 +255,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, profile.Name, "owner")
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, profile.Name, "owner", false)
if err != nil {
slog.Error("oauth: failed to sign jwt", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
@ -290,7 +290,7 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
redirectWithError(w, r, redirectBase, "db_error")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, user.IsAdmin)
if err != nil {
slog.Error("oauth: retry login: failed to sign jwt", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")

View File

@ -25,6 +25,7 @@ type teamResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
IsByoc bool `json:"is_byoc"`
CreatedAt string `json:"created_at"`
}
@ -44,9 +45,10 @@ type memberResponse struct {
func teamToResponse(t db.Team) teamResponse {
resp := teamResponse{
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
IsByoc: t.IsByoc,
}
if t.CreatedAt.Valid {
resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339)
@ -321,3 +323,25 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// SetBYOC handles PUT /v1/admin/teams/{id}/byoc (admin only).
// Enables or disables the BYOC feature flag for a team.
func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
teamID := chi.URLParam(r, "id")
var req struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if err := h.svc.SetBYOC(r.Context(), teamID, req.Enabled); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -45,6 +45,10 @@ func (m *HostMonitor) Start(ctx context.Context) {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
// Run immediately on startup so the CP doesn't wait one full interval
// before reconciling host and sandbox state.
m.run(ctx)
for {
select {
case <-ctx.Done():

View File

@ -0,0 +1,30 @@
package api
import (
"net/http"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
)
// requireAdmin validates that the authenticated user is a platform admin.
// Must run after requireJWT (depends on AuthContext being present).
// Re-validates against the DB — the JWT is_admin claim is for UI only;
// the DB is the source of truth for admin access.
func requireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
user, err := queries.GetUserByID(r.Context(), ac.UserID)
if err != nil || !user.IsAdmin {
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -26,12 +26,14 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler {
}
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: claims.TeamID,
UserID: claims.Subject,
Email: claims.Email,
Name: claims.Name,
Role: claims.Role,
TeamID: claims.TeamID,
UserID: claims.Subject,
Email: claims.Email,
Name: claims.Name,
Role: claims.Role,
IsAdmin: claims.IsAdmin,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@ -156,6 +156,13 @@ func New(
})
})
// Platform admin routes — require JWT + DB-validated admin status.
r.Route("/v1/admin", func(r chi.Router) {
r.Use(requireJWT(jwtSecret))
r.Use(requireAdmin(queries))
r.Put("/teams/{id}/byoc", teamH.SetBYOC)
})
return &Server{router: r}
}

View File

@ -8,11 +8,12 @@ const authCtxKey contextKey = 0
// AuthContext is stamped into request context by auth middleware.
type AuthContext struct {
TeamID string
UserID string // empty when authenticated via API key
Email string // empty when authenticated via API key
Name string // empty when authenticated via API key
Role string // owner, admin, or member; empty when authenticated via API key
TeamID string
UserID string // empty when authenticated via API key
Email string // empty when authenticated via API key
Name string // empty when authenticated via API key
Role string // owner, admin, or member; empty when authenticated via API key
IsAdmin bool // platform-level admin; always false when authenticated via API key
}
// WithAuthContext returns a new context with the given AuthContext.

View File

@ -13,22 +13,24 @@ const HostRefreshTokenExpiry = 60 * 24 * time.Hour // 60 days; exported for serv
// Claims are the JWT payload for user tokens.
type Claims struct {
Type string `json:"typ,omitempty"` // empty for user tokens; used to reject host tokens
TeamID string `json:"team_id"`
Role string `json:"role"` // owner, admin, or member within TeamID
Email string `json:"email"`
Name string `json:"name"`
Type string `json:"typ,omitempty"` // empty for user tokens; used to reject host tokens
TeamID string `json:"team_id"`
Role string `json:"role"` // owner, admin, or member within TeamID
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin,omitempty"` // platform-level admin flag
jwt.RegisteredClaims
}
// SignJWT signs a new 6-hour JWT for the given user.
func SignJWT(secret []byte, userID, teamID, email, name, role string) (string, error) {
func SignJWT(secret []byte, userID, teamID, email, name, role string, isAdmin bool) (string, error) {
now := time.Now()
claims := Claims{
TeamID: teamID,
Role: role,
Email: email,
Name: name,
TeamID: teamID,
Role: role,
Email: email,
Name: name,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),

View File

@ -574,7 +574,7 @@ func (q *Queries) UpdateHostHeartbeat(ctx context.Context, id string) error {
return err
}
const updateHostHeartbeatAndStatus = `-- name: UpdateHostHeartbeatAndStatus :exec
const updateHostHeartbeatAndStatus = `-- name: UpdateHostHeartbeatAndStatus :execrows
UPDATE hosts
SET last_heartbeat_at = NOW(),
status = CASE WHEN status = 'unreachable' THEN 'online' ELSE status END,
@ -583,9 +583,10 @@ WHERE id = $1
`
// Updates last_heartbeat_at and transitions unreachable hosts back to online.
func (q *Queries) UpdateHostHeartbeatAndStatus(ctx context.Context, id string) error {
_, err := q.db.Exec(ctx, updateHostHeartbeatAndStatus, id)
return err
// Returns 0 if no host was found (deleted).
func (q *Queries) UpdateHostHeartbeatAndStatus(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, updateHostHeartbeatAndStatus, id)
return result.RowsAffected(), err
}
const updateHostStatus = `-- name: UpdateHostStatus :exec

View File

@ -96,13 +96,14 @@ func saveTokenFile(path string, tf tokenFile) error {
// Register calls the control plane to register this host agent and persists
// the returned JWT and refresh token to disk. Returns the host JWT token string.
func Register(ctx context.Context, cfg RegistrationConfig) (string, error) {
// Check if we already have a saved token.
if tf, err := loadTokenFile(cfg.TokenFile); err == nil && tf.JWT != "" {
slog.Info("loaded existing host token", "file", cfg.TokenFile, "host_id", tf.HostID)
return tf.JWT, nil
}
// If no explicit registration token was given, reuse the saved JWT.
// A --register flag always overrides the local file so operators can
// force re-registration without manually deleting host.jwt.
if cfg.RegistrationToken == "" {
if tf, err := loadTokenFile(cfg.TokenFile); err == nil && tf.JWT != "" {
slog.Info("loaded existing host token", "file", cfg.TokenFile, "host_id", tf.HostID)
return tf.JWT, nil
}
return "", fmt.Errorf("no saved host token and no registration token provided (use --register flag)")
}
@ -239,7 +240,11 @@ func RefreshJWT(ctx context.Context, cpURL, tokenFilePath string) (string, error
//
// On repeated network failures (3 consecutive), it calls pauseAll but keeps
// retrying — the connection may recover and the host should resume heartbeating.
func StartHeartbeat(ctx context.Context, cpURL, tokenFilePath, hostID string, interval time.Duration, pauseAll func()) {
//
// onDeleted is called when CP returns 404, meaning this host record was deleted.
// The token file is removed before calling onDeleted so subsequent starts prompt
// for a new registration token.
func StartHeartbeat(ctx context.Context, cpURL, tokenFilePath, hostID string, interval time.Duration, pauseAll func(), onDeleted func()) {
client := &http.Client{Timeout: 10 * time.Second}
go func() {
@ -255,62 +260,84 @@ func StartHeartbeat(ctx context.Context, cpURL, tokenFilePath, hostID string, in
currentJWT = tf.JWT
}
// beat sends one heartbeat. Returns true if the loop should stop.
beat := func() (stop bool) {
url := strings.TrimRight(cpURL, "/") + "/v1/hosts/" + hostID + "/heartbeat"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
slog.Warn("heartbeat: failed to create request", "error", err)
return false
}
req.Header.Set("X-Host-Token", currentJWT)
resp, err := client.Do(req)
if err != nil {
consecutiveFailures++
slog.Warn("heartbeat: request failed", "error", err, "consecutive_failures", consecutiveFailures)
if consecutiveFailures >= 3 && !pausedDueToFailure {
slog.Error("heartbeat: CP unreachable after 3 failures — pausing all sandboxes")
if pauseAll != nil {
pauseAll()
}
pausedDueToFailure = true
}
return false
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent:
if consecutiveFailures > 0 || pausedDueToFailure {
slog.Info("heartbeat: CP connection restored")
}
consecutiveFailures = 0
pausedDueToFailure = false
case http.StatusUnauthorized, http.StatusForbidden:
slog.Warn("heartbeat: JWT rejected — attempting token refresh")
newJWT, refreshErr := RefreshJWT(ctx, cpURL, tokenFilePath)
if refreshErr != nil {
slog.Error("heartbeat: JWT refresh failed — pausing all sandboxes; manual re-registration required",
"error", refreshErr)
if pauseAll != nil && !pausedDueToFailure {
pauseAll()
pausedDueToFailure = true
}
// Stop the heartbeat loop — operator must re-register.
return true
}
currentJWT = newJWT
slog.Info("heartbeat: JWT refreshed successfully")
case http.StatusNotFound:
slog.Error("heartbeat: host no longer exists in CP — host was deleted; removing token file and exiting")
if err := os.Remove(tokenFilePath); err != nil && !os.IsNotExist(err) {
slog.Warn("heartbeat: failed to remove token file", "error", err)
}
if onDeleted != nil {
onDeleted()
}
return true
default:
slog.Warn("heartbeat: unexpected status", "status", resp.StatusCode)
}
return false
}
// Send an immediate heartbeat on startup so the CP sees the host as
// online without waiting for the first ticker tick.
if beat() {
return
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
url := strings.TrimRight(cpURL, "/") + "/v1/hosts/" + hostID + "/heartbeat"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
slog.Warn("heartbeat: failed to create request", "error", err)
continue
}
req.Header.Set("X-Host-Token", currentJWT)
resp, err := client.Do(req)
if err != nil {
consecutiveFailures++
slog.Warn("heartbeat: request failed", "error", err, "consecutive_failures", consecutiveFailures)
if consecutiveFailures >= 3 && !pausedDueToFailure {
slog.Error("heartbeat: CP unreachable after 3 failures — pausing all sandboxes")
if pauseAll != nil {
pauseAll()
}
pausedDueToFailure = true
}
continue
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent:
// Success.
if consecutiveFailures > 0 || pausedDueToFailure {
slog.Info("heartbeat: CP connection restored")
}
consecutiveFailures = 0
pausedDueToFailure = false
case http.StatusUnauthorized, http.StatusForbidden:
slog.Warn("heartbeat: JWT rejected — attempting token refresh")
newJWT, refreshErr := RefreshJWT(ctx, cpURL, tokenFilePath)
if refreshErr != nil {
slog.Error("heartbeat: JWT refresh failed — pausing all sandboxes; manual re-registration required",
"error", refreshErr)
if pauseAll != nil && !pausedDueToFailure {
pauseAll()
pausedDueToFailure = true
}
// Stop the heartbeat loop — operator must re-register.
return
}
currentJWT = newJWT
slog.Info("heartbeat: JWT refreshed successfully")
default:
slog.Warn("heartbeat: unexpected status", "status", resp.StatusCode)
if beat() {
return
}
}
}

View File

@ -22,12 +22,15 @@ import (
// Server implements the HostAgentService Connect RPC handler.
type Server struct {
hostagentv1connect.UnimplementedHostAgentServiceHandler
mgr *sandbox.Manager
mgr *sandbox.Manager
terminate func() // called when the CP requests agent termination
}
// NewServer creates a new host agent RPC server.
func NewServer(mgr *sandbox.Manager) *Server {
return &Server{mgr: mgr}
// terminate is invoked (in a goroutine) when the CP calls the Terminate RPC,
// allowing main to perform a clean shutdown.
func NewServer(mgr *sandbox.Manager, terminate func()) *Server {
return &Server{mgr: mgr, terminate: terminate}
}
func (s *Server) CreateSandbox(
@ -412,3 +415,14 @@ func (s *Server) ListSandboxes(
AutoPausedSandboxIds: s.mgr.DrainAutoPausedIDs(),
}), nil
}
func (s *Server) Terminate(
_ context.Context,
_ *connect.Request[pb.TerminateRequest],
) (*connect.Response[pb.TerminateResponse], error) {
slog.Info("terminate RPC received — scheduling shutdown")
if s.terminate != nil {
go s.terminate()
}
return connect.NewResponse(&pb.TerminateResponse{}), nil
}

View File

@ -12,11 +12,13 @@ import (
// different strategies (round-robin, least-loaded, tag-based, etc.).
type HostScheduler interface {
// SelectHost returns a host that can accept a new sandbox.
// Returns an error if no suitable host is available.
SelectHost(ctx context.Context) (db.Host, error)
// For BYOC teams (isByoc=true), only online BYOC hosts belonging to teamID
// are considered. For non-BYOC teams, only online regular (platform) hosts
// are considered. Returns an error if no suitable host is available.
SelectHost(ctx context.Context, teamID string, isByoc bool) (db.Host, error)
}
// RoundRobinScheduler cycles through online hosts in round-robin order.
// RoundRobinScheduler cycles through eligible online hosts in round-robin order.
// It re-fetches the host list on every call so that newly registered or
// recovered hosts are considered immediately.
type RoundRobinScheduler struct {
@ -29,23 +31,39 @@ func NewRoundRobinScheduler(queries *db.Queries) *RoundRobinScheduler {
return &RoundRobinScheduler{db: queries}
}
// SelectHost returns the next online host in round-robin order.
func (s *RoundRobinScheduler) SelectHost(ctx context.Context) (db.Host, error) {
// SelectHost returns the next eligible online host in round-robin order.
func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID string, isByoc bool) (db.Host, error) {
hosts, err := s.db.ListActiveHosts(ctx)
if err != nil {
return db.Host{}, fmt.Errorf("list hosts: %w", err)
}
var online []db.Host
var eligible []db.Host
for _, h := range hosts {
if h.Status == "online" && h.Address.Valid && h.Address.String != "" {
online = append(online, h)
if h.Status != "online" || !h.Address.Valid || h.Address.String == "" {
continue
}
if isByoc {
// BYOC team: only use hosts belonging to this team.
if h.Type != "byoc" || !h.TeamID.Valid || h.TeamID.String != teamID {
continue
}
} else {
// Non-BYOC team: only use platform (regular) hosts.
if h.Type != "regular" {
continue
}
}
eligible = append(eligible, h)
}
if len(online) == 0 {
return db.Host{}, fmt.Errorf("no online hosts available")
if len(eligible) == 0 {
if isByoc {
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
}
return db.Host{}, fmt.Errorf("no online platform hosts available")
}
idx := s.counter.Add(1) - 1
return online[int(idx%int64(len(online)))], nil
return eligible[int(idx%int64(len(eligible)))], nil
}

View File

@ -123,12 +123,15 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
}
}
// Validate team exists and is not deleted for BYOC hosts.
// Validate team exists, is not deleted, and has BYOC enabled.
if p.TeamID != "" {
team, err := s.DB.GetTeam(ctx, p.TeamID)
if err != nil || team.DeletedAt.Valid {
return HostCreateResult{}, fmt.Errorf("invalid request: team not found")
}
if !team.IsByoc {
return HostCreateResult{}, fmt.Errorf("forbidden: BYOC is not enabled for this team")
}
}
hostID := id.NewHostID()
@ -370,9 +373,17 @@ func hashToken(token string) string {
}
// Heartbeat updates the last heartbeat timestamp for a host and transitions
// any 'unreachable' host back to 'online'.
// any 'unreachable' host back to 'online'. Returns a "host not found" error
// (which becomes 404) if the host record no longer exists (e.g., was deleted).
func (s *HostService) Heartbeat(ctx context.Context, hostID string) error {
return s.DB.UpdateHostHeartbeatAndStatus(ctx, hostID)
n, err := s.DB.UpdateHostHeartbeatAndStatus(ctx, hostID)
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("host not found")
}
return nil
}
// List returns hosts visible to the caller.
@ -447,8 +458,8 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string,
return &HostHasSandboxesError{SandboxIDs: ids}
}
// Gracefully destroy running sandboxes on the host agent (best-effort).
if len(sandboxes) > 0 && host.Address.Valid && host.Address.String != "" {
// Gracefully destroy running sandboxes and terminate the agent (best-effort).
if host.Address.Valid && host.Address.String != "" {
agent, err := s.Pool.GetForHost(host)
if err == nil {
for _, sb := range sandboxes {
@ -461,6 +472,10 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string,
}
}
}
// Tell the agent to shut itself down immediately.
if _, rpcErr := agent.Terminate(ctx, connect.NewRequest(&pb.TerminateRequest{})); rpcErr != nil {
slog.Warn("delete host: failed to send Terminate to agent", "host_id", hostID, "error", rpcErr)
}
}
}

View File

@ -86,8 +86,18 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
}
}
if p.TeamID == "" {
return db.Sandbox{}, fmt.Errorf("invalid request: team_id is required")
}
// Determine whether this team uses BYOC hosts or platform hosts.
team, err := s.DB.GetTeam(ctx, p.TeamID)
if err != nil {
return db.Sandbox{}, fmt.Errorf("team not found: %w", err)
}
// Pick a host for this sandbox.
host, err := s.Scheduler.SelectHost(ctx)
host, err := s.Scheduler.SelectHost(ctx, p.TeamID, team.IsByoc)
if err != nil {
return db.Sandbox{}, fmt.Errorf("select host: %w", err)
}

View File

@ -374,3 +374,27 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string
func (s *TeamService) SearchUsersByEmailPrefix(ctx context.Context, prefix string) ([]db.SearchUsersByEmailPrefixRow, error) {
return s.DB.SearchUsersByEmailPrefix(ctx, pgtype.Text{String: prefix, Valid: true})
}
// SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot
// be disabled — it is a one-way transition.
// Admin-only — the caller must verify admin status before invoking this.
func (s *TeamService) SetBYOC(ctx context.Context, teamID string, enabled bool) error {
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
return fmt.Errorf("team not found: %w", err)
}
if team.DeletedAt.Valid {
return fmt.Errorf("team not found")
}
if !enabled {
return fmt.Errorf("invalid request: BYOC cannot be disabled once enabled")
}
if team.IsByoc {
// Already enabled — idempotent, no-op.
return nil
}
if err := s.DB.SetTeamBYOC(ctx, db.SetTeamBYOCParams{ID: teamID, IsByoc: true}); err != nil {
return fmt.Errorf("set byoc: %w", err)
}
return nil
}

View File

@ -1830,6 +1830,78 @@ func (*PingSandboxResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{32}
}
type TerminateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TerminateRequest) Reset() {
*x = TerminateRequest{}
mi := &file_hostagent_proto_msgTypes[33]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TerminateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TerminateRequest) ProtoMessage() {}
func (x *TerminateRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[33]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TerminateRequest.ProtoReflect.Descriptor instead.
func (*TerminateRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{33}
}
type TerminateResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TerminateResponse) Reset() {
*x = TerminateResponse{}
mi := &file_hostagent_proto_msgTypes[34]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TerminateResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TerminateResponse) ProtoMessage() {}
func (x *TerminateResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[34]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TerminateResponse.ProtoReflect.Descriptor instead.
func (*TerminateResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{34}
}
var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" +
@ -1955,7 +2027,10 @@ const file_hostagent_proto_rawDesc = "" +
"\x12PingSandboxRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x15\n" +
"\x13PingSandboxResponse2\xce\t\n" +
"\x13PingSandboxResponse\"\x12\n" +
"\x10TerminateRequest\"\x13\n" +
"\x11TerminateResponse2\x9c\n" +
"\n" +
"\x10HostAgentService\x12X\n" +
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
@ -1971,7 +2046,8 @@ const file_hostagent_proto_rawDesc = "" +
"ExecStream\x12\x1f.hostagent.v1.ExecStreamRequest\x1a .hostagent.v1.ExecStreamResponse0\x01\x12`\n" +
"\x0fWriteFileStream\x12$.hostagent.v1.WriteFileStreamRequest\x1a%.hostagent.v1.WriteFileStreamResponse(\x01\x12]\n" +
"\x0eReadFileStream\x12#.hostagent.v1.ReadFileStreamRequest\x1a$.hostagent.v1.ReadFileStreamResponse0\x01\x12R\n" +
"\vPingSandbox\x12 .hostagent.v1.PingSandboxRequest\x1a!.hostagent.v1.PingSandboxResponseB\xb0\x01\n" +
"\vPingSandbox\x12 .hostagent.v1.PingSandboxRequest\x1a!.hostagent.v1.PingSandboxResponse\x12L\n" +
"\tTerminate\x12\x1e.hostagent.v1.TerminateRequest\x1a\x1f.hostagent.v1.TerminateResponseB\xb0\x01\n" +
"\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z;git.omukk.dev/wrenn/sandbox/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3"
var (
@ -1986,7 +2062,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
return file_hostagent_proto_rawDescData
}
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 33)
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 35)
var file_hostagent_proto_goTypes = []any{
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
@ -2021,6 +2097,8 @@ var file_hostagent_proto_goTypes = []any{
(*ReadFileStreamResponse)(nil), // 30: hostagent.v1.ReadFileStreamResponse
(*PingSandboxRequest)(nil), // 31: hostagent.v1.PingSandboxRequest
(*PingSandboxResponse)(nil), // 32: hostagent.v1.PingSandboxResponse
(*TerminateRequest)(nil), // 33: hostagent.v1.TerminateRequest
(*TerminateResponse)(nil), // 34: hostagent.v1.TerminateResponse
}
var file_hostagent_proto_depIdxs = []int32{
16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
@ -2042,22 +2120,24 @@ var file_hostagent_proto_depIdxs = []int32{
26, // 16: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 17: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
31, // 18: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
1, // 19: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 20: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 21: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 22: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 23: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 24: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 25: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 26: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
9, // 27: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 28: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 29: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 30: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 31: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
32, // 32: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
19, // [19:33] is the sub-list for method output_type
5, // [5:19] is the sub-list for method input_type
33, // 19: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
1, // 20: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 21: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 22: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 23: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 24: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 25: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 26: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 27: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
9, // 28: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 29: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 30: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 31: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 32: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
32, // 33: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
34, // 34: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
20, // [20:35] is the sub-list for method output_type
5, // [5:20] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
@ -2087,7 +2167,7 @@ func file_hostagent_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 33,
NumMessages: 35,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -74,6 +74,9 @@ const (
// HostAgentServicePingSandboxProcedure is the fully-qualified name of the HostAgentService's
// PingSandbox RPC.
HostAgentServicePingSandboxProcedure = "/hostagent.v1.HostAgentService/PingSandbox"
// HostAgentServiceTerminateProcedure is the fully-qualified name of the HostAgentService's
// Terminate RPC.
HostAgentServiceTerminateProcedure = "/hostagent.v1.HostAgentService/Terminate"
)
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
@ -108,6 +111,10 @@ type HostAgentServiceClient interface {
ReadFileStream(context.Context, *connect.Request[gen.ReadFileStreamRequest]) (*connect.ServerStreamForClient[gen.ReadFileStreamResponse], error)
// PingSandbox resets the inactivity timer for a running sandbox.
PingSandbox(context.Context, *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error)
// Terminate instructs the host agent to destroy all sandboxes and exit.
// Called by the control plane immediately when a host is deleted so the
// agent shuts down without waiting for the next heartbeat cycle.
Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error)
}
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
@ -205,6 +212,12 @@ func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, op
connect.WithSchema(hostAgentServiceMethods.ByName("PingSandbox")),
connect.WithClientOptions(opts...),
),
terminate: connect.NewClient[gen.TerminateRequest, gen.TerminateResponse](
httpClient,
baseURL+HostAgentServiceTerminateProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("Terminate")),
connect.WithClientOptions(opts...),
),
}
}
@ -224,6 +237,7 @@ type hostAgentServiceClient struct {
writeFileStream *connect.Client[gen.WriteFileStreamRequest, gen.WriteFileStreamResponse]
readFileStream *connect.Client[gen.ReadFileStreamRequest, gen.ReadFileStreamResponse]
pingSandbox *connect.Client[gen.PingSandboxRequest, gen.PingSandboxResponse]
terminate *connect.Client[gen.TerminateRequest, gen.TerminateResponse]
}
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
@ -296,6 +310,11 @@ func (c *hostAgentServiceClient) PingSandbox(ctx context.Context, req *connect.R
return c.pingSandbox.CallUnary(ctx, req)
}
// Terminate calls hostagent.v1.HostAgentService.Terminate.
func (c *hostAgentServiceClient) Terminate(ctx context.Context, req *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error) {
return c.terminate.CallUnary(ctx, req)
}
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
type HostAgentServiceHandler interface {
// CreateSandbox boots a new microVM with the given configuration.
@ -328,6 +347,10 @@ type HostAgentServiceHandler interface {
ReadFileStream(context.Context, *connect.Request[gen.ReadFileStreamRequest], *connect.ServerStream[gen.ReadFileStreamResponse]) error
// PingSandbox resets the inactivity timer for a running sandbox.
PingSandbox(context.Context, *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error)
// Terminate instructs the host agent to destroy all sandboxes and exit.
// Called by the control plane immediately when a host is deleted so the
// agent shuts down without waiting for the next heartbeat cycle.
Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error)
}
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
@ -421,6 +444,12 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
connect.WithSchema(hostAgentServiceMethods.ByName("PingSandbox")),
connect.WithHandlerOptions(opts...),
)
hostAgentServiceTerminateHandler := connect.NewUnaryHandler(
HostAgentServiceTerminateProcedure,
svc.Terminate,
connect.WithSchema(hostAgentServiceMethods.ByName("Terminate")),
connect.WithHandlerOptions(opts...),
)
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case HostAgentServiceCreateSandboxProcedure:
@ -451,6 +480,8 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
hostAgentServiceReadFileStreamHandler.ServeHTTP(w, r)
case HostAgentServicePingSandboxProcedure:
hostAgentServicePingSandboxHandler.ServeHTTP(w, r)
case HostAgentServiceTerminateProcedure:
hostAgentServiceTerminateHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -515,3 +546,7 @@ func (UnimplementedHostAgentServiceHandler) ReadFileStream(context.Context, *con
func (UnimplementedHostAgentServiceHandler) PingSandbox(context.Context, *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PingSandbox is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) Terminate(context.Context, *connect.Request[gen.TerminateRequest]) (*connect.Response[gen.TerminateResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.Terminate is not implemented"))
}

View File

@ -49,6 +49,11 @@ service HostAgentService {
// PingSandbox resets the inactivity timer for a running sandbox.
rpc PingSandbox(PingSandboxRequest) returns (PingSandboxResponse);
// Terminate instructs the host agent to destroy all sandboxes and exit.
// Called by the control plane immediately when a host is deleted so the
// agent shuts down without waiting for the next heartbeat cycle.
rpc Terminate(TerminateRequest) returns (TerminateResponse);
}
message CreateSandboxRequest {
@ -236,3 +241,10 @@ message PingSandboxRequest {
message PingSandboxResponse {}
// ── Terminate ────────────────────────────────────────────────────────
message TerminateRequest {}
message TerminateResponse {}