Skip to content

Commit 2d11677

Browse files
dashboard: env-aware deployments — real env + family grid (#34)
Pairs with the agent-API workstream that lands env + parent_stack_id on the list endpoint and adds GET /api/v1/stacks/:slug/family. - listStacks now reads the real env from the API response. Stops hardcoding 'production' on every stack. Legacy stacks without env still fall back to 'production' so the UI is back-compat. - NEW: fetchStackFamily(slug) — typed wrapper around the family endpoint. Returns a discriminated union so the UI can branch on upgrade_required (render PromoteUpsell), not_found, and unknown without leaking APIError into page components. - DeployDetailPage gains an EnvironmentsGrid section above the Promote / Copy-vault PromptCards (Pro+ only). One card per env: env pill, status, URL, last-deploy timestamp. Non-root members get an inline "Promote <env> → production" PromptCard so the agent prose stays adjacent to the source env. - Hobby / free tier path unchanged — the existing PromoteUpsell card still renders, the new grid is rendered above only for Pro+. Tests: - 7 new api/index.test.ts cases (listStacks env, fetchStackFamily happy path + 402 + 404 + 500 + URL-encoding) - 6 new DeployDetailPage.test.tsx cases (loading, multi-env render, single-env render, upgrade_required + unknown silent failure, empty-family fallback) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 855f7f4 commit 2d11677

4 files changed

Lines changed: 615 additions & 56 deletions

File tree

src/api/index.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
listResources,
2727
deleteResource,
2828
listAPIKeys,
29+
listStacks,
30+
fetchStackFamily,
2931
} from './index'
3032
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
3133
// fallback paths in fetchBilling() and listInvoices() were removed —
@@ -573,3 +575,123 @@ describe('non-JSON response bodies', () => {
573575
await expect(cancelSubscription()).rejects.toMatchObject({ status: 502 })
574576
})
575577
})
578+
579+
// ─── listStacks() — env field plumbed through ────────────────────────────
580+
// Verifies the §10.17 follow-up: dashboard reads real `env` from the API
581+
// response instead of hardcoding 'production'. Locks in the contract the
582+
// agent API now serves (GET /api/v1/stacks includes env + parent_stack_id).
583+
describe('listStacks() env field', () => {
584+
it('returns the real env value from the API', async () => {
585+
const m = installFetch()
586+
m.mockResolvedValueOnce(jsonResponse({
587+
ok: true,
588+
total: 2,
589+
items: [
590+
{
591+
stack_id: 'stk-prod', name: 'demo', status: 'running', tier: 'pro',
592+
namespace: 'ns', env: 'production', parent_stack_id: '',
593+
created_at: '2026-05-12T00:00:00Z',
594+
},
595+
{
596+
stack_id: 'stk-staging', name: 'demo', status: 'running', tier: 'pro',
597+
namespace: 'ns', env: 'staging', parent_stack_id: 'root-id',
598+
created_at: '2026-05-12T00:01:00Z',
599+
},
600+
],
601+
}))
602+
const r = await listStacks()
603+
expect(r.items[0].env).toBe('production')
604+
expect(r.items[1].env).toBe('staging')
605+
})
606+
607+
it("falls back to 'production' when the API omits env (legacy stack rows)", async () => {
608+
const m = installFetch()
609+
m.mockResolvedValueOnce(jsonResponse({
610+
ok: true,
611+
items: [{ stack_id: 'stk-old', name: 'legacy', status: 'running', tier: 'pro', namespace: 'ns', created_at: 'x' }],
612+
}))
613+
const r = await listStacks()
614+
expect(r.items[0].env).toBe('production')
615+
})
616+
})
617+
618+
// ─── fetchStackFamily() — Pro+ env grid loader ───────────────────────────
619+
// The discriminated-union return shape is load-bearing for the dashboard's
620+
// Environments grid: it decides between rendering the grid (ok=true),
621+
// the existing PromoteUpsell card (upgrade_required), or the silent fall-
622+
// through (not_found / unknown). Cover each branch explicitly so a future
623+
// shape change can't silently regress the UI behaviour.
624+
describe('fetchStackFamily()', () => {
625+
it('adapts the family payload and preserves order', async () => {
626+
const m = installFetch()
627+
m.mockResolvedValueOnce(jsonResponse({
628+
ok: true,
629+
slug: 'stk-prod',
630+
total: 3,
631+
family: [
632+
{
633+
slug: 'stk-prod', name: 'demo', env: 'production', status: 'running', tier: 'pro',
634+
url: 'https://demo.deployment.instanode.dev', is_root: true, parent_stack_id: '',
635+
last_deploy_at: '2026-05-12T01:00:00Z', created_at: '2026-05-12T00:00:00Z',
636+
},
637+
{
638+
slug: 'stk-staging', name: 'demo', env: 'staging', status: 'building', tier: 'pro',
639+
url: '', is_root: false, parent_stack_id: 'root-id',
640+
last_deploy_at: '2026-05-12T02:00:00Z', created_at: '2026-05-12T00:02:00Z',
641+
},
642+
{
643+
slug: 'stk-dev', name: 'demo', env: 'dev', status: 'running', tier: 'pro',
644+
url: 'https://dev-demo.deployment.instanode.dev', is_root: false, parent_stack_id: 'root-id',
645+
last_deploy_at: '2026-05-12T03:00:00Z', created_at: '2026-05-12T00:03:00Z',
646+
},
647+
],
648+
}))
649+
const r = await fetchStackFamily('stk-prod')
650+
expect(r.ok).toBe(true)
651+
if (!r.ok) throw new Error('typeguard')
652+
expect(r.slug).toBe('stk-prod')
653+
expect(r.total).toBe(3)
654+
expect(r.family.map((m) => m.env)).toEqual(['production', 'staging', 'dev'])
655+
expect(r.family[0].is_root).toBe(true)
656+
expect(r.family[1].is_root).toBe(false)
657+
expect(r.family[1].parent_stack_id).toBe('root-id')
658+
})
659+
660+
it('returns upgrade_required on 402 so the UI can render PromoteUpsell', async () => {
661+
const m = installFetch()
662+
m.mockResolvedValueOnce(jsonResponse(
663+
{ ok: false, error: 'upgrade_required', agent_action: 'Tell user to upgrade...' },
664+
{ status: 402 },
665+
))
666+
const r = await fetchStackFamily('stk-hobby')
667+
expect(r.ok).toBe(false)
668+
if (r.ok) throw new Error('typeguard')
669+
expect(r.reason).toBe('upgrade_required')
670+
})
671+
672+
it('returns not_found on 404 so the UI silently falls back', async () => {
673+
const m = installFetch()
674+
m.mockResolvedValueOnce(jsonResponse({ ok: false, error: 'not_found' }, { status: 404 }))
675+
const r = await fetchStackFamily('stk-missing')
676+
expect(r.ok).toBe(false)
677+
if (r.ok) throw new Error('typeguard')
678+
expect(r.reason).toBe('not_found')
679+
})
680+
681+
it("buckets every other failure under reason='unknown'", async () => {
682+
const m = installFetch()
683+
m.mockResolvedValueOnce(jsonResponse({ ok: false, error: 'internal' }, { status: 500 }))
684+
const r = await fetchStackFamily('stk-x')
685+
expect(r.ok).toBe(false)
686+
if (r.ok) throw new Error('typeguard')
687+
expect(r.reason).toBe('unknown')
688+
})
689+
690+
it('URI-encodes the slug so weird inputs do not bypass the route', async () => {
691+
const m = installFetch()
692+
m.mockResolvedValueOnce(jsonResponse({ ok: true, slug: '', family: [], total: 0 }))
693+
await fetchStackFamily('stk weird/slug')
694+
const [url] = m.mock.calls[0]
695+
expect(String(url)).toContain('/api/v1/stacks/stk%20weird%2Fslug/family')
696+
})
697+
})

src/api/index.ts

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,12 @@ export async function rotateResource(id: string): Promise<{ ok: true; connection
320320
}
321321

322322
// ─── Stacks / deployments ───
323-
// GET /api/v1/stacks exists on the agent API but returns a thinner shape than
324-
// the dashboard's DashboardStack (no env, no url, no last_deploy_at, no
325-
// build_duration_s yet). We adapt what's available and leave optional fields
326-
// undefined — the UI handles missing fields gracefully. Until POST /deploy/new
327-
// ships in Phase 1, expect this to return an empty list for most teams.
323+
// GET /api/v1/stacks returns one row per stack including the real env
324+
// (production / staging / dev / ...) and parent_stack_id linkage. We adapt
325+
// the shape into DashboardStack and leave still-missing fields (url,
326+
// last_deploy_at, build_duration_s) undefined — the UI handles missing
327+
// fields gracefully. Until POST /deploy/new ships in Phase 1, expect this
328+
// to return an empty list for most teams.
328329
type StacksListResp = {
329330
ok: boolean
330331
items?: Array<{
@@ -333,6 +334,8 @@ type StacksListResp = {
333334
status?: string
334335
tier?: string
335336
namespace?: string
337+
env?: string
338+
parent_stack_id?: string
336339
created_at?: string
337340
}>
338341
total?: number
@@ -349,7 +352,11 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[];
349352
url: null,
350353
created_at: s.created_at ?? '',
351354
team_id: '',
352-
env: 'production',
355+
// env defaults to 'production' for legacy stacks pre-dating migration
356+
// 015. The API never returns null for env (the column has NOT NULL
357+
// DEFAULT 'production'), so the ?? branch is only exercised when the
358+
// backend predates env-aware deployments entirely.
359+
env: (s.env as DashboardStack['env']) ?? 'production',
353360
tier: (s.tier as DashboardStack['tier']) ?? 'free',
354361
}))
355362
return { ok: true, items, total: r.total ?? items.length }
@@ -360,6 +367,90 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[];
360367
}
361368
}
362369

370+
// ─── Stack family — env-sibling grid ─────────────────────────────────────
371+
// GET /api/v1/stacks/:slug/family returns root + every direct child as a
372+
// flat list (root first) so the dashboard can render "production · staging
373+
// · dev" cards side-by-side without doing N round-trips. Pro+ only — the
374+
// agent API returns 402 with agent_action for hobby/free teams; we surface
375+
// that with a tagged failure so the UI shows the existing PromoteUpsell
376+
// instead of trying to render an empty grid.
377+
378+
export type StackFamilyMember = {
379+
slug: string
380+
name: string
381+
env: string
382+
status: DashboardStack['status']
383+
tier: DashboardStack['tier']
384+
url: string
385+
is_root: boolean
386+
parent_stack_id: string
387+
last_deploy_at: string
388+
created_at: string
389+
}
390+
391+
type StackFamilyResp = {
392+
ok: boolean
393+
slug?: string
394+
family?: Array<{
395+
slug?: string
396+
name?: string
397+
env?: string
398+
status?: string
399+
tier?: string
400+
url?: string
401+
is_root?: boolean
402+
parent_stack_id?: string
403+
last_deploy_at?: string
404+
created_at?: string
405+
}>
406+
total?: number
407+
}
408+
409+
/**
410+
* Fetch the env-sibling family for a stack. Returns:
411+
* { ok: true, family, slug } — Pro+ team, family fetched
412+
* { ok: false, reason: 'upgrade_required' } — hobby/free, 402 from API
413+
* { ok: false, reason: 'not_found' } — slug missing or another team's
414+
* { ok: false, reason: 'unknown' } — transient failure
415+
*
416+
* The discriminated-union return shape lets the calling UI choose between
417+
* rendering the env grid, the PromoteUpsell card, or an error state without
418+
* leaking APIError into the page component.
419+
*/
420+
export async function fetchStackFamily(
421+
slug: string,
422+
): Promise<
423+
| { ok: true; slug: string; family: StackFamilyMember[]; total: number }
424+
| { ok: false; reason: 'upgrade_required' | 'not_found' | 'unknown' }
425+
> {
426+
try {
427+
const r = await call<StackFamilyResp>(`/api/v1/stacks/${encodeURIComponent(slug)}/family`)
428+
const family: StackFamilyMember[] = (r.family ?? []).map((m) => ({
429+
slug: m.slug ?? '',
430+
name: m.name ?? '',
431+
env: m.env ?? 'production',
432+
status: (m.status as DashboardStack['status']) ?? 'building',
433+
tier: (m.tier as DashboardStack['tier']) ?? 'free',
434+
url: m.url ?? '',
435+
is_root: m.is_root ?? false,
436+
parent_stack_id: m.parent_stack_id ?? '',
437+
last_deploy_at: m.last_deploy_at ?? '',
438+
created_at: m.created_at ?? '',
439+
}))
440+
return { ok: true, slug: r.slug ?? slug, family, total: r.total ?? family.length }
441+
} catch (err) {
442+
// APIError exposes status; treat 402 as the explicit upgrade signal and
443+
// 404 as not-yet-promoted (the slug exists but the team can't see it),
444+
// and lump everything else into 'unknown' so the UI keeps showing the
445+
// single-env fallback. Inspect status defensively because non-APIError
446+
// throwables (network failures, jsdom) reach here too.
447+
const status = (err as { status?: number })?.status
448+
if (status === 402) return { ok: false as const, reason: 'upgrade_required' }
449+
if (status === 404) return { ok: false as const, reason: 'not_found' }
450+
return { ok: false as const, reason: 'unknown' }
451+
}
452+
}
453+
363454
// §10.21: no live GET /api/v1/stacks/:slug yet. Derive the detail from
364455
// listStacks() so the dashboard stops fabricating stack metadata. Returns
365456
// `stack: null` honestly when the slug isn't found instead of silently

0 commit comments

Comments
 (0)