Skip to content

Commit 0b62a22

Browse files
Merge branch 'main' into dependabot/github_actions/actions-e98f365c3b
2 parents fd8862f + a3a879f commit 0b62a22

14 files changed

Lines changed: 203 additions & 129 deletions

public/llms.txt

Lines changed: 9 additions & 7 deletions
Large diffs are not rendered by default.

src/components/PricingGrid.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,9 @@ export const PRICING_GRID_TIERS: TierDefinition[] = [
186186
{ text: 'unlimited deployments · 50 custom domains' },
187187
{ text: 'unlimited vault entries · multi-env' },
188188
{ text: '90-day backups · self-serve restore' },
189-
{ text: 'RBAC + audit log · SSO / SAML' },
190-
{ text: '99.9% SLA' },
189+
{ text: 'RBAC + audit log' },
190+
{ text: 'SSO / SAML (coming soon)' },
191+
{ text: '99.9% SLA (coming soon)' },
191192
],
192193
},
193194
]

src/hooks/useDashboardCtx.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,26 @@ describe('useDashboardCtx', () => {
5353
await waitFor(() => expect(result.current.me).not.toBeNull())
5454
expect(result.current.me?.user.email).toBe('a@b.com')
5555
await waitFor(() => expect(result.current.billing?.status).toBe('active'))
56-
// production filter → 1 of the 2 resources; deployments 1; vault 1.
57-
expect(result.current.counts.resources).toBe(1)
56+
// No env filter (global switcher removed) → both resources count; the
57+
// sidebar lists all envs. Deployments 1; vault 1 (still per-env).
58+
expect(result.current.counts.resources).toBe(2)
5859
expect(result.current.counts.deployments).toBe(1)
5960
expect(result.current.counts.vault).toBe(1)
60-
// env merged from resource list.
61+
// env names still merged from the resource list (for VaultPage tabs).
6162
expect(result.current.envs).toContain('staging')
6263
})
6364

65+
it('deployments count falls back to 0 when listDeployments rejects (catch arm)', async () => {
66+
listDeployments.mockRejectedValue(new Error('deployments down'))
67+
const mod = await load()
68+
const { result } = renderHook(() => mod.useDashboardCtx())
69+
await waitFor(() => expect(result.current.me).not.toBeNull())
70+
// refreshCounts swallows the deployments failure → count 0, and the other
71+
// counts (resources/vault) still resolve.
72+
await waitFor(() => expect(result.current.counts.resources).toBe(2))
73+
expect(result.current.counts.deployments).toBe(0)
74+
})
75+
6476
it('does NOT bootstrap when there is no token', async () => {
6577
getToken.mockReturnValue('')
6678
const mod = await load()
@@ -87,8 +99,9 @@ describe('useDashboardCtx', () => {
8799
expect(result.current.env).toBe('staging')
88100
expect(localStorage.getItem('instanode.env')).toBe('staging')
89101
await waitFor(() => expect(listResources).toHaveBeenCalled())
90-
// staging filter → 1 resource.
91-
await waitFor(() => expect(result.current.counts.resources).toBe(1))
102+
// setEnv still re-fetches (vault is per-env), but resources count spans
103+
// all envs now (switcher removed) → both mock resources count.
104+
await waitFor(() => expect(result.current.counts.resources).toBe(2))
92105
})
93106

94107
it('setEnv is a no-op when the env is unchanged', async () => {

src/hooks/useDashboardCtx.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22
// state": the signed-in user, their team, the active env, and the live
33
// resource/vault counts shown in the sidebar.
44
//
5-
// Env scope — IMPORTANT:
6-
// The backend does NOT yet honor a multi-env filter on resources/stacks.
7-
// The only surface where `env` is genuinely backed by per-env data is
8-
// VaultPage (vault_secrets.env is real). Everywhere else, `env` is a
9-
// cosmetic display value snapshotted at provision time and surfacing
10-
// filter chips would imply a backend capability that doesn't exist yet
11-
// (env promotion is the §10.17 Pro-tier feature still in development).
5+
// Env scope — IMPORTANT (2026-06-03):
6+
// The backend DOES support per-env filtering (GET /api/v1/resources?env=,
7+
// /deployments?env=, and real per-env vault isolation). But the broader
8+
// multi-environment UX (choosing env at create time, env promotion — the
9+
// §10.17 Pro-tier feature) is unfinished, so the GLOBAL env switcher has
10+
// been removed from the chrome to avoid surfacing a half-built capability.
1211
//
13-
// Keep `env` / `envs` / `setEnv` / `addEnv` here for VaultPage and for
14-
// the sidebar's vault subtitle. Do NOT add new env-filter call sites
15-
// without also wiring a real server-side filter behind them.
12+
// Consequence: Resources / Deployments / Overview deliberately fetch
13+
// WITHOUT an env filter (all envs visible) so nothing is hidden now that
14+
// the user can't switch env globally. The ONLY surface that still scopes
15+
// by env is VaultPage, which has its OWN env tabs and where per-env
16+
// isolation is genuinely finished. `env` / `envs` / `setEnv` / `addEnv`
17+
// remain here solely for VaultPage. Do NOT re-add a global env switcher or
18+
// new env-filter call sites until the multi-env UX is finished.
1619

1720
import { useEffect, useSyncExternalStore } from 'react'
1821
import * as api from '../api'
@@ -105,7 +108,8 @@ export function setEnv(next: string) {
105108
//
106109
// Fix: align the JS regex with the api regex, enforce the 32-char
107110
// cap up front, and return early if validation fails. The caller
108-
// (EnvSwitcher) already gates `addEnv` behind a non-empty draft so
111+
// (VaultPage's new-env input — the global EnvSwitcher was removed
112+
// 2026-06-03) already gates `addEnv` behind a non-empty draft so
109113
// no UI plumbing changes are required.
110114
const ENV_REGEX = /^[a-z0-9-]{1,32}$/
111115
const ENV_MAX_LEN = 32
@@ -149,24 +153,26 @@ async function refreshCounts() {
149153
// are NOT rows in the `resources` list (resource_type === 'deploy' never
150154
// appears there), so the sidebar deployments count must be sourced from
151155
// api.listDeployments(), not a filter over `resources`.
156+
// Resources + deployments are fetched WITHOUT an env filter: the global
157+
// env switcher is hidden (multi-env UX unfinished), so the sidebar counts
158+
// must reflect every env or they'd undercount. Vault stays per-env (real).
152159
const [r, v, d] = await Promise.all([
153160
api.listResources().catch(() => ({ items: [], total: 0 })),
154161
api.listVault(state.env).catch(() => ({ entries: [] })),
155-
api.listDeployments(state.env).catch(() => ({ ok: true as const, items: [], total: 0 })),
162+
api.listDeployments().catch(() => ({ ok: true as const, items: [], total: 0 })),
156163
])
157-
// Merge envs from resources too — surfaces real env names.
164+
// Merge envs from resources too — surfaces real env names for VaultPage tabs.
158165
const fromAPI = new Set(state.envs)
159166
for (const it of (r as any).items ?? []) if (it.env) fromAPI.add(it.env)
160167
const envs = Array.from(fromAPI)
161168

162169
const items = ((r as any).items ?? []) as Resource[]
163-
const filtered = items.filter((x) => (x.env ?? 'production') === state.env)
164170
state = {
165171
...state,
166172
envs,
167173
resources: items,
168174
counts: {
169-
resources: filtered.length,
175+
resources: items.length,
170176
deployments: ((d as any).items ?? []).length,
171177
vault: ((v as any).entries ?? []).length,
172178
team: 1, // no /team/members endpoint; placeholder

src/layout/AppShell.env.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/* AppShell.env.test.tsx — environment-switcher REMOVAL guard.
2+
*
3+
* 2026-06-03: the global environment switcher was removed from the dashboard
4+
* chrome. The backend supports per-env filtering, but the broader multi-env
5+
* UX (choosing env at create time, env promotion) is unfinished, so surfacing
6+
* a global switcher advertised a half-built capability. It's hidden until the
7+
* feature is done. Per-env vault tabs remain on VaultPage (isolation is real
8+
* there).
9+
*
10+
* These tests pin that the switcher stays gone, so a future refactor can't
11+
* silently re-introduce it.
12+
*/
13+
14+
import { describe, it, expect, afterEach, vi } from 'vitest'
15+
import { render, screen, cleanup } from '@testing-library/react'
16+
import { MemoryRouter } from 'react-router-dom'
17+
18+
vi.mock('../api', async () => {
19+
const actual = await vi.importActual<typeof import('../api')>('../api')
20+
return {
21+
...actual,
22+
fetchTeamSummary: vi.fn().mockResolvedValue({ ok: true, teams: [] }),
23+
}
24+
})
25+
26+
vi.mock('../hooks/useDashboardCtx', async () => {
27+
const actual = await vi.importActual<typeof import('../hooks/useDashboardCtx')>(
28+
'../hooks/useDashboardCtx',
29+
)
30+
return {
31+
...actual,
32+
useDashboardCtx: () => ({
33+
me: {
34+
user: { id: 'u_test', email: 'aanya@acme.dev', tier: 'pro' },
35+
team: { id: 't_test', slug: 'acme', name: 'acme', tier: 'pro' },
36+
},
37+
meErr: null,
38+
meLoading: false,
39+
env: 'development',
40+
envs: ['development', 'production'],
41+
counts: { resources: 3, deployments: 2, vault: 5, team: 1 },
42+
resources: [],
43+
billing: null,
44+
billingLoading: false,
45+
}),
46+
}
47+
})
48+
49+
import { AppShell } from './AppShell'
50+
51+
afterEach(() => cleanup())
52+
53+
function renderShell(path = '/app') {
54+
return render(
55+
<MemoryRouter initialEntries={[path]}>
56+
<AppShell />
57+
</MemoryRouter>,
58+
)
59+
}
60+
61+
describe('AppShell — global env switcher is hidden (2026-06-03)', () => {
62+
it('does NOT render a global environment switcher in the chrome', () => {
63+
renderShell()
64+
expect(screen.queryByTestId('env-switcher')).toBeNull()
65+
expect(screen.queryByTestId('env-create-input')).toBeNull()
66+
})
67+
68+
it('still renders the org/team block (switcher removal did not break the sidebar)', () => {
69+
renderShell()
70+
expect(screen.getByTestId('org')).toBeTruthy()
71+
expect(screen.getByTestId('org-name')).toBeTruthy()
72+
})
73+
})
74+
75+
describe('AppShell — breadcrumbs no longer show env (count-only after switcher removal)', () => {
76+
it('resources crumb shows the count, not the env', () => {
77+
renderShell('/app/resources')
78+
expect(screen.getAllByText('3 active').length).toBeGreaterThan(0)
79+
// env name must not leak into the crumb anymore.
80+
expect(screen.queryByText(/development · /)).toBeNull()
81+
})
82+
83+
it('deployments crumb shows the count, not the env', () => {
84+
renderShell('/app/deployments')
85+
expect(screen.getAllByText('2 active').length).toBeGreaterThan(0)
86+
})
87+
88+
it('vault crumb shows the entry count, not the env', () => {
89+
renderShell('/app/vault')
90+
expect(screen.getAllByText('5 entries').length).toBeGreaterThan(0)
91+
})
92+
})

src/layout/AppShell.tsx

Lines changed: 9 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
22
import { Brand, ExpiryWarningBanner, ScopePill, useExpiryTick } from '../components/Common'
33
import { useEffect, useState, type ReactNode } from 'react'
4-
import { addEnv, setEnv, useDashboardCtx, type DashboardCtx } from '../hooks/useDashboardCtx'
4+
import { useDashboardCtx, type DashboardCtx } from '../hooks/useDashboardCtx'
55
import * as api from '../api'
66
import type { TeamSummary } from '../api'
77
import { UserMenu } from './UserMenu'
@@ -53,9 +53,9 @@ function computeMeta(routeKey: string, pathname: string, ctx: DashboardCtx): Pag
5353
function computeCrumb(routeKey: string, pathname: string, ctx: DashboardCtx): string {
5454
switch (routeKey) {
5555
case '/':
56-
return ctx.env
56+
return 'overview'
5757
case '/resources':
58-
return `${ctx.env} · ${ctx.counts.resources} active`
58+
return `${ctx.counts.resources} active`
5959
case '/resources/:id': {
6060
// Try to resolve resource_type from the loaded resource list (the
6161
// detail page id appears in the URL). Fall back to em-dash if we
@@ -66,11 +66,11 @@ function computeCrumb(routeKey: string, pathname: string, ctx: DashboardCtx): st
6666
return `resources / ${kind}`
6767
}
6868
case '/deployments':
69-
return `${ctx.env} · ${ctx.counts.deployments} active`
69+
return `${ctx.counts.deployments} active`
7070
case '/deployments/:id':
7171
return 'deployments / live'
7272
case '/vault':
73-
return `${ctx.env} · ${ctx.counts.vault} entries`
73+
return `${ctx.counts.vault} entries`
7474
case '/team':
7575
return 'members & invites'
7676
case '/billing':
@@ -209,8 +209,11 @@ export function AppShell() {
209209
<div className="av">{teamInitial}</div>
210210
<div className="org-info">
211211
<div className="org-name" data-testid="org-name">{teamSlug}</div>
212+
{/* Global env switcher intentionally removed (2026-06-03): the
213+
multi-environment UX is unfinished, so it's hidden to avoid
214+
surfacing a half-built capability. Per-env vault tabs live
215+
on VaultPage where isolation is real. */}
212216
<div className="org-env">
213-
<EnvSwitcher value={ctx.env} options={ctx.envs} />
214217
<span className="switch-hint">{tier}</span>
215218
</div>
216219
</div>
@@ -467,56 +470,3 @@ function routeIdToKey(_id: string, pathname: string): string {
467470
if (/^\/deployments\/[^/]+$/.test(stripped)) return '/deployments/:id'
468471
return stripped
469472
}
470-
471-
// ──────────────────────────────────────────────────────────────────────────
472-
// EnvSwitcher — pill-shaped <select> wired to the dashboard ctx. Selecting
473-
// "+ new env" opens a tiny inline input that creates an env locally; the
474-
// next API call carries the new env in the query string.
475-
function EnvSwitcher({ value, options }: { value: string; options: string[] }) {
476-
const [creating, setCreating] = useState(false)
477-
const [draft, setDraft] = useState('')
478-
if (creating) {
479-
return (
480-
<span className="env-pill prod" style={{ display: 'inline-flex', gap: 4, padding: '2px 6px' }}>
481-
<input
482-
autoFocus
483-
value={draft}
484-
onChange={(e) => setDraft(e.target.value)}
485-
onBlur={() => { if (draft.trim()) addEnv(draft); setCreating(false); setDraft('') }}
486-
onKeyDown={(e) => {
487-
if (e.key === 'Enter' && draft.trim()) { addEnv(draft); setCreating(false); setDraft('') }
488-
if (e.key === 'Escape') { setCreating(false); setDraft('') }
489-
}}
490-
placeholder="staging"
491-
data-testid="env-create-input"
492-
style={{
493-
background: 'transparent', border: 0, outline: 'none', color: 'var(--accent)',
494-
font: 'inherit', width: 80, fontFamily: 'var(--font-mono)', fontSize: 11,
495-
}}
496-
/>
497-
</span>
498-
)
499-
}
500-
return (
501-
<select
502-
data-testid="env-switcher"
503-
className="env-pill prod"
504-
value={value}
505-
onChange={(e) => {
506-
if (e.target.value === '__new__') setCreating(true)
507-
else setEnv(e.target.value)
508-
}}
509-
style={{
510-
appearance: 'none', WebkitAppearance: 'none', cursor: 'pointer',
511-
border: '1px solid var(--accent-glow)', background: 'var(--accent-soft)',
512-
color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 11,
513-
padding: '2px 8px', borderRadius: 4,
514-
}}
515-
>
516-
{options.map((o) => (
517-
<option key={o} value={o}>{o}</option>
518-
))}
519-
<option value="__new__" style={{ color: 'var(--violet)' }}>+ new env…</option>
520-
</select>
521-
)
522-
}

src/pages/DeploymentsPage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,14 @@ export function DeploymentsPage() {
8686
}, [items, statusFilter, sort])
8787

8888
// Source of truth: GET /api/v1/deployments (single-container apps via
89-
// POST /deploy/new). The env switcher in the sidebar drives the ?env=
90-
// query param; switching envs triggers a refetch via the dep array.
89+
// POST /deploy/new). No env filter — the global env switcher is hidden
90+
// (multi-env UX unfinished), so deployments from every env are listed.
9191
useEffect(() => {
9292
let cancelled = false
9393
setErr(null)
9494
setLoading(true)
9595
api
96-
.listDeployments(ctx.env)
96+
.listDeployments()
9797
.then((r) => {
9898
if (cancelled) return
9999
setItems(r.items)
@@ -115,7 +115,7 @@ export function DeploymentsPage() {
115115
return () => {
116116
cancelled = true
117117
}
118-
}, [ctx.env])
118+
}, [])
119119

120120
return (
121121
<>

src/pages/MarketingPage.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,15 @@ describe('MarketingPage — claim consistency (T18 P1-4 / P1-6)', () => {
9797
const text = container.textContent ?? ''
9898
// Headline says "Seven services. One bundle."
9999
expect(text).toMatch(/Seven services\. One bundle\./)
100-
// MCP tools card must NOT say "Six tools registered" (the dropped-
101-
// webhook regression). It must say "Seven" and list webhook.
102-
expect(text).not.toMatch(/Six tools registered/)
103-
expect(text).toMatch(/Seven tools registered/)
100+
// MCP tools card lists the seven provisioning tools (must still list
101+
// webhook — anti-regression for the dropped-webhook bug) AND, per the
102+
// 2026-06-03 gap fix, also surfaces the stack/deployment management tools
103+
// (the MCP server registers more than seven; "Seven tools registered" was
104+
// an understatement).
105+
expect(text).not.toMatch(/Six provisioning tools/)
106+
expect(text).toMatch(/Seven provisioning tools/)
104107
expect(text).toMatch(/webhook/)
108+
expect(text).toMatch(/list_deployments/)
105109
})
106110

107111
it("Deploy service card claims a build window consistent with content/llms.txt (~60s, not '<10s')", () => {

0 commit comments

Comments
 (0)