Skip to content

Commit 383e0f7

Browse files
dashboard: wire /app/deployments to /api/v1/deployments + drop "Phase 1 coming soon" lies (#35)
Today's deploy backend audit (Phase 1) confirmed POST /deploy/new is live — verified by a real e2e test deploy at https://6fffcc21.deployment.instanode.dev (nginx tarball → Kaniko build → k8s pod → Ingress + TLS, 30s). But the dashboard surface was contradicting the working backend: • /app/deployments queried GET /api/v1/stacks (multi-service stack model) instead of GET /api/v1/deployments (single-app /deploy/new path). A team that deployed via /deploy/new saw "No deployments yet" forever. • Empty-state copy literally said "Deployment lands in Phase 1 — for now, use kubectl on your own cluster" — telling users the feature was unbuilt. • DeployDetailPage's EnvVars + BoundResources tabs were hardcoded placeholders saying "Phase 1" + "kubectl get deploy -o yaml". • Log SSE URL pointed at /api/v1/stacks/:slug/logs/:svc — wrong for /deploy/new origin deployments. This PR: • Adds listDeployments() + getDeployment() in src/api/index.ts wired to GET /api/v1/deployments and GET /api/v1/deployments/:id with typed DashboardDeployment shape. • DeploymentsPage now calls listDeployments() first, falls back to listStacks() for legacy multi-service deploys. One unified list. • DeployDetailPage detects /deploy/new origin first (getDeployment by id), falls back to listStacks. Env vars panel parses deployment.env_vars and renders real rows. Bound resources surface from env vars that look like resource tokens. • Log SSE: for /deploy/new origin, calls /deploy/:id/logs; for stack origin, /api/v1/stacks/:slug/logs/:svc. • Drops every "Phase 1 coming soon" placeholder string. Empty state on DeploymentsPage now says "No deployments yet" + points at the agent prompt to deploy. Tests: tsc clean. Dashboard suite passes including the new DeploymentsPage.test.tsx + extended DeployDetailPage.test.tsx cases. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2d11677 commit 383e0f7

8 files changed

Lines changed: 1193 additions & 47 deletions

File tree

src/api/index.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
listAPIKeys,
2929
listStacks,
3030
fetchStackFamily,
31+
listDeployments,
32+
getDeployment,
3133
} from './index'
3234
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
3335
// fallback paths in fetchBilling() and listInvoices() were removed —
@@ -615,6 +617,193 @@ describe('listStacks() env field', () => {
615617
})
616618
})
617619

620+
// ─── listDeployments() — GET /api/v1/deployments adapter ─────────────────
621+
// The dashboard's /app/deployments surface previously queried listStacks(),
622+
// which only returned multi-service stacks and therefore showed an empty
623+
// list for any team that had only ever called POST /deploy/new. The new
624+
// listDeployments() adapter is the load-bearing fix — it must:
625+
// 1. hit GET /api/v1/deployments,
626+
// 2. normalise the server's 'healthy' status → 'running' so the shared
627+
// StatusPill renders the live state correctly,
628+
// 3. swap `env` (env_vars map) and `environment` (scope name) into the
629+
// dashboard's vocabulary (env_vars + env), and
630+
// 4. surface `app_id` / `id` / `url` faithfully so DeployDetailPage can
631+
// link back to the row.
632+
describe('listDeployments()', () => {
633+
it('adapts the API response — env_vars + env scope swap, status normalisation', async () => {
634+
const m = installFetch()
635+
m.mockResolvedValueOnce(jsonResponse({
636+
ok: true,
637+
total: 2,
638+
items: [
639+
{
640+
id: '11111111-1111-1111-1111-111111111111',
641+
app_id: '6fffcc21',
642+
token: '6fffcc21',
643+
url: 'https://6fffcc21.deployment.instanode.dev',
644+
status: 'healthy',
645+
port: 8080,
646+
tier: 'pro',
647+
env: { DATABASE_URL: 'postgres://...', NODE_ENV: 'production' },
648+
environment: 'production',
649+
created_at: '2026-05-12T11:00:00Z',
650+
updated_at: '2026-05-12T11:30:00Z',
651+
},
652+
{
653+
id: '22222222-2222-2222-2222-222222222222',
654+
app_id: 'abc123',
655+
url: 'https://abc123.deployment.instanode.dev',
656+
status: 'building',
657+
port: 3000,
658+
tier: 'hobby',
659+
env: { PORT: '3000' },
660+
environment: 'staging',
661+
created_at: '2026-05-12T11:10:00Z',
662+
updated_at: '2026-05-12T11:11:00Z',
663+
},
664+
],
665+
}))
666+
const r = await listDeployments()
667+
expect(r.ok).toBe(true)
668+
expect(r.total).toBe(2)
669+
expect(r.items.length).toBe(2)
670+
671+
const a = r.items[0]
672+
expect(a.id).toBe('11111111-1111-1111-1111-111111111111')
673+
expect(a.app_id).toBe('6fffcc21')
674+
// 'healthy' on the wire maps to 'running' for the dashboard's StatusPill.
675+
expect(a.status).toBe('running')
676+
expect(a.url).toBe('https://6fffcc21.deployment.instanode.dev')
677+
// Env scope from `environment`; env_vars from `env`.
678+
expect(a.env).toBe('production')
679+
expect(a.env_vars).toEqual({ DATABASE_URL: 'postgres://...', NODE_ENV: 'production' })
680+
expect(a.port).toBe(8080)
681+
expect(a.tier).toBe('pro')
682+
// last_deploy_at falls back to updated_at when the API doesn't yet
683+
// expose a dedicated last-deploy field.
684+
expect(a.last_deploy_at).toBe('2026-05-12T11:30:00Z')
685+
// Display name falls through to app_id until the API exposes one.
686+
expect(a.name).toBe('6fffcc21')
687+
688+
expect(r.items[1].env).toBe('staging')
689+
expect(r.items[1].status).toBe('building')
690+
})
691+
692+
it('hits GET /api/v1/deployments', async () => {
693+
const m = installFetch()
694+
m.mockResolvedValueOnce(jsonResponse({ ok: true, items: [], total: 0 }))
695+
await listDeployments()
696+
const [url, init] = m.mock.calls[0]
697+
expect(String(url)).toContain('/api/v1/deployments')
698+
// GET (default method) — no body, no method override.
699+
expect(init?.method ?? 'GET').toBe('GET')
700+
})
701+
702+
it('falls back to items.length when total is omitted', async () => {
703+
const m = installFetch()
704+
m.mockResolvedValueOnce(jsonResponse({
705+
ok: true,
706+
items: [
707+
{ id: 'd1', app_id: 'd1', status: 'building', port: 80, tier: 'free', env: {}, environment: 'production', created_at: 'x', updated_at: 'x' },
708+
],
709+
}))
710+
const r = await listDeployments()
711+
expect(r.total).toBe(1)
712+
})
713+
714+
it('returns env_vars: {} when env_vars / env map are omitted', async () => {
715+
const m = installFetch()
716+
m.mockResolvedValueOnce(jsonResponse({
717+
ok: true,
718+
items: [{
719+
id: 'd1', app_id: 'd1', status: 'running', port: 80, tier: 'free',
720+
environment: 'production', created_at: 'x', updated_at: 'x',
721+
}],
722+
}))
723+
const r = await listDeployments()
724+
expect(r.items[0].env_vars).toEqual({})
725+
})
726+
727+
it('accepts the dedicated env_vars field (forward compat)', async () => {
728+
// The audit doc spec listed `env_vars` directly. The live API still
729+
// returns env-map under `env`, so we accept either to insulate the
730+
// dashboard from the field rename whenever the API ships it.
731+
const m = installFetch()
732+
m.mockResolvedValueOnce(jsonResponse({
733+
ok: true,
734+
items: [{
735+
id: 'd1', app_id: 'd1', status: 'running', port: 80, tier: 'pro',
736+
environment: 'production',
737+
env_vars: { FOO: 'bar' },
738+
env: 'production', // string env scope alongside env_vars (forward compat)
739+
created_at: 'x', updated_at: 'x',
740+
}],
741+
}))
742+
const r = await listDeployments()
743+
expect(r.items[0].env_vars).toEqual({ FOO: 'bar' })
744+
expect(r.items[0].env).toBe('production')
745+
})
746+
747+
it('defaults env to "production" when the API omits both fields', async () => {
748+
const m = installFetch()
749+
m.mockResolvedValueOnce(jsonResponse({
750+
ok: true,
751+
items: [{ id: 'd1', app_id: 'd1', status: 'running', port: 80, tier: 'pro', created_at: 'x', updated_at: 'x' }],
752+
}))
753+
const r = await listDeployments()
754+
expect(r.items[0].env).toBe('production')
755+
})
756+
757+
it('propagates errors so the page can surface them honestly', async () => {
758+
const m = installFetch()
759+
m.mockResolvedValueOnce(jsonResponse({ error: 'list_failed' }, { status: 503 }))
760+
await expect(listDeployments()).rejects.toMatchObject({ status: 503 })
761+
})
762+
})
763+
764+
// ─── getDeployment() — single-deploy detail loader ───────────────────────
765+
describe('getDeployment()', () => {
766+
it('returns {ok, deployment} on success', async () => {
767+
const m = installFetch()
768+
m.mockResolvedValueOnce(jsonResponse({
769+
ok: true,
770+
item: {
771+
id: 'd1', app_id: 'd1', status: 'healthy', port: 8080, tier: 'pro',
772+
url: 'https://d1.deployment.instanode.dev',
773+
env: { DATABASE_URL: 'vault://env/DATABASE_URL' },
774+
environment: 'production',
775+
created_at: 'x', updated_at: 'y',
776+
},
777+
}))
778+
const r = await getDeployment('d1')
779+
expect(r.ok).toBe(true)
780+
expect(r.deployment?.id).toBe('d1')
781+
expect(r.deployment?.status).toBe('running')
782+
expect(r.deployment?.env_vars).toEqual({ DATABASE_URL: 'vault://env/DATABASE_URL' })
783+
})
784+
785+
it('hits GET /api/v1/deployments/:id (URI-encoded)', async () => {
786+
const m = installFetch()
787+
m.mockResolvedValueOnce(jsonResponse({ ok: true, item: { id: 'd weird', app_id: 'd', status: 'running', port: 1, tier: 'free', env: {}, environment: 'production', created_at: 'x', updated_at: 'x' } }))
788+
await getDeployment('d weird')
789+
expect(String(m.mock.calls[0][0])).toContain('/api/v1/deployments/d%20weird')
790+
})
791+
792+
it('returns {ok:true, deployment: null} on 404 so the page can fall back to the stack lookup', async () => {
793+
const m = installFetch()
794+
m.mockResolvedValueOnce(jsonResponse({ error: 'not_found' }, { status: 404 }))
795+
const r = await getDeployment('missing-id')
796+
expect(r.ok).toBe(true)
797+
expect(r.deployment).toBeNull()
798+
})
799+
800+
it('propagates non-404 errors (e.g. 500)', async () => {
801+
const m = installFetch()
802+
m.mockResolvedValueOnce(jsonResponse({ error: 'boom' }, { status: 500 }))
803+
await expect(getDeployment('d1')).rejects.toMatchObject({ status: 500 })
804+
})
805+
})
806+
618807
// ─── fetchStackFamily() — Pro+ env grid loader ───────────────────────────
619808
// The discriminated-union return shape is load-bearing for the dashboard's
620809
// Environments grid: it decides between rendering the grid (ok=true),

src/api/index.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
// real error banner instead of lying with mock data.
99

1010
import type {
11-
Resource, DashboardStack, DashboardTeam, BillingDetails, Invoice,
11+
Resource, DashboardStack, DashboardDeployment, DeploymentStatus,
12+
DashboardTeam, BillingDetails, Invoice,
1213
TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem
1314
} from './types'
1415

@@ -367,6 +368,123 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[];
367368
}
368369
}
369370

371+
// ─── Deployments (LIVE — POST /deploy/new single-container apps) ────────
372+
//
373+
// `listDeployments()` hits GET /api/v1/deployments on the agent API and
374+
// returns the typed dashboard shape. The server response keys collide
375+
// with DashboardStack vocabulary in one place: it returns `env` for the
376+
// env-vars map and `environment` for the scope name. We swap them here
377+
// so the rest of the dashboard can treat env (scope) and env_vars (map)
378+
// the same way it does for stacks.
379+
//
380+
// Status mapping: the server emits 'healthy' for a live deploy, which the
381+
// dashboard's shared StatusPill renders as 'running' (matching stacks).
382+
// We normalise here so consumer code doesn't need to special-case it.
383+
type DeploymentRespItem = {
384+
id?: string
385+
token?: string
386+
app_id?: string
387+
url?: string
388+
port?: number
389+
tier?: string
390+
status?: string
391+
// Server returns env as a map of env_vars (legacy alias). New callers
392+
// should also accept env_vars for forward compat with the spec.
393+
env?: Record<string, string> | string
394+
env_vars?: Record<string, string>
395+
// Env scope (production / staging / dev / ...).
396+
environment?: string
397+
created_at?: string
398+
updated_at?: string
399+
last_deploy_at?: string
400+
build_duration_s?: number
401+
resource_id?: string
402+
name?: string
403+
}
404+
405+
type DeploymentsListResp = {
406+
ok: boolean
407+
items?: DeploymentRespItem[]
408+
total?: number
409+
}
410+
411+
type DeploymentGetResp = {
412+
ok: boolean
413+
item?: DeploymentRespItem
414+
}
415+
416+
function normaliseDeploymentStatus(s: string | undefined): DeploymentStatus {
417+
switch (s) {
418+
case 'healthy':
419+
return 'running' // dashboard's StatusPill speaks 'running'
420+
case 'building':
421+
case 'deploying':
422+
case 'failed':
423+
case 'stopped':
424+
case 'running':
425+
return s
426+
default:
427+
return 'building'
428+
}
429+
}
430+
431+
function adaptDeployment(d: DeploymentRespItem): DashboardDeployment {
432+
// The server's `env` field is the env_vars map (legacy alias); the env
433+
// scope name lives under `environment`. New callers may also send a
434+
// dedicated `env_vars` field — accept either.
435+
const envVarsRaw =
436+
d.env_vars ??
437+
(typeof d.env === 'object' && d.env !== null ? d.env : undefined)
438+
const envScope = d.environment ?? (typeof d.env === 'string' ? d.env : undefined)
439+
const id = d.id ?? d.app_id ?? d.token ?? ''
440+
const appID = d.app_id ?? d.token ?? id
441+
return {
442+
id,
443+
app_id: appID,
444+
// The server doesn't ship a separate display name yet — fall back to
445+
// the app_id so the UI has something stable to render. Once the API
446+
// exposes a real name, this falls through automatically.
447+
name: d.name ?? appID,
448+
url: d.url ?? null,
449+
status: normaliseDeploymentStatus(d.status),
450+
env: (envScope ?? 'production') as DashboardDeployment['env'],
451+
port: d.port ?? 0,
452+
tier: (d.tier ?? 'free') as DashboardDeployment['tier'],
453+
env_vars: envVarsRaw ?? {},
454+
created_at: d.created_at ?? '',
455+
last_deploy_at: d.last_deploy_at ?? d.updated_at,
456+
build_duration_s: d.build_duration_s,
457+
resource_id: d.resource_id,
458+
}
459+
}
460+
461+
export async function listDeployments(): Promise<{ ok: true; items: DashboardDeployment[]; total: number }> {
462+
// No try/catch fallback to empty — errors propagate so DeploymentsPage
463+
// can render a real error state instead of silently lying. The list
464+
// endpoint requires auth; 401 still triggers the AuthGate redirect.
465+
const r = await call<DeploymentsListResp>('/api/v1/deployments')
466+
const items = (r.items ?? []).map(adaptDeployment)
467+
return { ok: true, items, total: r.total ?? items.length }
468+
}
469+
470+
/**
471+
* Fetch a single deployment by ID. Returns `null` when the API returns
472+
* 404 so the caller (DeployDetailPage) can fall back to the stack lookup
473+
* without a noisy console error. Other errors still propagate.
474+
*/
475+
export async function getDeployment(
476+
id: string,
477+
): Promise<{ ok: true; deployment: DashboardDeployment | null }> {
478+
try {
479+
const r = await call<DeploymentGetResp>(`/api/v1/deployments/${encodeURIComponent(id)}`)
480+
if (!r.item) return { ok: true, deployment: null }
481+
return { ok: true, deployment: adaptDeployment(r.item) }
482+
} catch (e: any) {
483+
if (e?.status === 404) return { ok: true, deployment: null }
484+
throw e
485+
}
486+
}
487+
370488
// ─── Stack family — env-sibling grid ─────────────────────────────────────
371489
// GET /api/v1/stacks/:slug/family returns root + every direct child as a
372490
// flat list (root first) so the dashboard can render "production · staging

src/api/types.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,53 @@ export interface DashboardStack {
5858
tier: Tier
5959
}
6060

61+
// ─── Deployment (POST /deploy/new — single-container app) ───────────────
62+
//
63+
// The agent API exposes two deploy surfaces that the dashboard renders
64+
// through the same pages:
65+
// 1. multi-service stacks → POST /stacks/new, GET /api/v1/stacks
66+
// 2. single-container deployments → POST /deploy/new, GET /api/v1/deployments
67+
//
68+
// `DashboardDeployment` is the typed shape of (2) after adaptation. The
69+
// server response includes `env` as a map of env_vars (legacy alias) and
70+
// `environment` as the env scope name; we surface them as `env_vars` and
71+
// `env` here so the type matches the DashboardStack vocabulary.
72+
export type DeploymentStatus =
73+
| 'building'
74+
| 'deploying'
75+
| 'healthy'
76+
| 'failed'
77+
| 'stopped'
78+
// Mapped onto StackStatus for shared UI: 'healthy' → 'running'.
79+
| 'running'
80+
81+
export interface DashboardDeployment {
82+
/** UUID of the deployment row (used in /deploy/:id paths). */
83+
id: string
84+
/** Public app token; doubles as the URL slug under deployment.instanode.dev. */
85+
app_id: string
86+
/** Human-readable name. Server doesn't expose one yet — falls back to app_id. */
87+
name: string
88+
/** Application URL — e.g. https://<app_id>.deployment.instanode.dev. */
89+
url: string | null
90+
status: DeploymentStatus
91+
/** Env scope: production / staging / dev / ... — defaults to 'production'. */
92+
env: Env
93+
/** Listening port inside the container. */
94+
port: number
95+
tier: Tier
96+
/** User-supplied env vars (excluding vault refs are still strings). */
97+
env_vars: Record<string, string>
98+
created_at: string
99+
/** Updated_at from the row — used as the "last deploy" timestamp until the
100+
* API exposes a dedicated field. */
101+
last_deploy_at?: string
102+
/** Not exposed by the API yet; reserved for forward compatibility. */
103+
build_duration_s?: number
104+
/** Optional resource binding (UUID of the primary resource). */
105+
resource_id?: string
106+
}
107+
61108
export interface DashboardTeam {
62109
id: string
63110
name: string

0 commit comments

Comments
 (0)