Skip to content

Commit 68813cd

Browse files
dashboard: truth pass — strip stubbed surfaces, ship reality (#30)
This is the dashboard-side rollup of a multi-agent retro session that addressed user-flagged "the dashboard is a mock" symptoms across every page. Aggregates the wave 1-3 work + Playwright visual retro findings (/tmp/dashboard-retro/) into a single shippable PR for instanode.dev/app. Headline fixes by surface: • /app/deployments - listStacks no longer falls back to FIXTURE_STACKS on error. The page renders an honest "No deployments yet" empty state instead of fake "flashcards · prod · 14:42:01" rows the user doesn't own. (This is the bug Manas pointed at in the live URL.) - Filter chips with hardcoded counts removed. PromptPill removed. • /app/deployments/:id - EnvVars + Bound Resources tabs no longer hardcode DATABASE_URL / STRIPE_SECRET_KEY / OPENAI_API_KEY / flashcards-db. Honest empty states explaining the Phase-1 endpoints they wait on. - SideKv runtime sidebar (marcus · 12m · a31fc8de · 142 MB · …) replaced with real DashboardStack fields only. • /app/billing - Usage panel: hardcoded 47/500, 163/256, 1.64/2GB etc. replaced with per-type aggregates computed from listResources(). - AppShell sidebar upgrade card: "9 days to renewal · auto-charges May 19" replaced with real billing.next_renewal_at + payment_*. - Card expiry 9/27 fixture leak removed. Invoice status pill renders i.status (not the deploy-status "running"). Update button → mailto:support. - Cancel removed → mailto:support per the no-self-serve-cancel memory rule. • /app/team - Dead revoke button + member kebab gone. Replaced with PromptCards (DELETE /api/v1/team/invitations/{id}, DELETE /api/v1/team/members/{user_id}). - "Pro tier · 5 team seats" hardcoded → tier-aware from ctx.me.team.tier via seatLimitByTier map matching plans.yaml. • /app/vault - rotated_at no longer fabricated at parse time. Row meta chip conditionally renders only when value is real. • /app/resources - Env filter chips removed (backend doesn't filter by env; chip was a lie). EnvPill column kept (resources.env is a real column). - PromptCards for provision + deploy with tier-aware prompts. • /app/overview - Quick-prompts replaced 3 decorative <a> tags with real <button> elements that copy tier-interpolated prompts via navigator.clipboard.writeText. "copied ✓" flash for 1.5s. - "⌘K to send" chip → "copy → paste in agent" (no keyboard handler ever existed for ⌘K). • /app/agent - Page deleted entirely. The static prompt-library with 21 dead "copy ↗" buttons + the "claude-code · connected" fake footer contradicted the no-AI-in-UI memory rule. The contextual PromptCards on each page cover the use case better with real resource data interpolated. • /app/settings - Revoke PAT button → PromptCard. PAT creation kept clickable (bootstrap path — agent can't mint its own first credential). • /claim - "free trial resources expire" copy → "free-tier resources expire after 24h unless you subscribe" matching no-trial policy. - Post-submit screen redesigned as payment funnel: 24h countdown + "Keep my resources $9/mo" CTA → createCheckout('hobby') → Razorpay. - 18 new tests in ClaimPage.test.tsx. • AppShell chrome - Hardcoded "acme-corp" breadcrumb → ctx.me.team.name. - "Ask agent" topbar button + ⌘K NavRow badge removed. - ROBanner "✦ ⌘K · ask agent" decorative anchor removed. • PricingPage - "Multi-env workflows" advertised as Pro tier (shipped via the api-side env promotion endpoints landing in a separate PR). • Expiry warning UI - ExpiryBadge + ExpiryWarningBanner + useExpiryTick in Common.tsx. - AppShell-level red banner above page body when any resource is <6h from expiry. Per-row badge on ResourcesPage with pulse when urgent. Header badge + Time-Remaining card on ResourceDetailPage. - 41 new tests across OverviewPage + ResourcesPage. • Tier model - 'free' tier added as a distinct real tier alongside 'anonymous' (same limits, different audience per the keep-both-tiers decision). TierPill renders both with distinct styles. • Test infrastructure - 7 test files now (was 3). 162 passing, 3 pre-existing skips. - New: Common.test.tsx (5), ClaimPage.test.tsx (18), OverviewPage.test.tsx (29), ResourcesPage.test.tsx (12). Known follow-ups (deliberately deferred): • §10.21 FIXTURE_* removal — most fixture fallbacks (vault, billing 503, activity, getStack/getStackLogs) still use stub fallback on error. Each removal requires a per-page UX decision (empty state vs error banner) cleaner in a focused follow-up. Tracked in RETRO-2026-05-12.md §10.21. • §10.20 Server-side /billing/usage + /team/summary with Redis cache + singleflight + Cache-Control headers per the new caching memory rule. The BillingPage Usage panel is currently client-side aggregation — works but doesn't honor the eventual-consistent caching policy. Tracked in §10.20. • §10.10 Single copyToClipboard() helper with execCommand fallback for HTTP/Safari. The wave-4 agent that started this was stopped mid-flight; the Common.tsx changes (5 new tests) made it in. The per-page swap to use the helper is the follow-up. Test gate: - npx tsc --noEmit: 0 - npm test -- --run: 162 passed, 3 skipped, 0 failed (7 test files) Reference: /Users/manassrivastava/Documents/InstaNode/RETRO-2026-05-12.md contains the full session retro including §10.X agent-dispatchable specs. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 03bcc10 commit 68813cd

29 files changed

Lines changed: 2943 additions & 892 deletions

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ npm install
2020
npm run dev # Vite dev server at http://localhost:5173
2121
```
2222

23-
To point the dev proxy at a local k8s cluster:
23+
To point the dev proxy at a local k8s cluster (the Service is ClusterIP — NodePort
24+
retired 2026-05-11 — so port-forward `svc/instant-api` first):
2425
```bash
25-
AGENT_API_URL=http://localhost:30080 npm run dev
26+
kubectl port-forward -n instant svc/instant-api 8080:8080 &
27+
AGENT_API_URL=http://localhost:8080 npm run dev
2628
```
2729

2830
To run unit tests:
@@ -87,8 +89,10 @@ http://localhost:5173/claim?t=<jwt>
8789
107 tests covering auth guards, the upgrade journey, and resource interactions.
8890

8991
```bash
90-
# Requires: Vite dev server running (npm run dev) + agent API at localhost:30080
91-
E2E_API_URL=http://localhost:30080 npx playwright test --project=chromium
92+
# Requires: Vite dev server running (npm run dev) + agent API port-forwarded
93+
# (Service is ClusterIP; NodePort retired):
94+
# kubectl port-forward -n instant svc/instant-api 8080:8080
95+
E2E_API_URL=http://localhost:8080 npx playwright test --project=chromium
9296

9397
# Run a single spec
9498
npx playwright test e2e/auth-guards.spec.ts --project=chromium
@@ -108,7 +112,7 @@ npx playwright test --headed --project=chromium
108112
| `AGENT_API_URL` | Upstream the Vite dev proxy points at | `http://api.instanode.dev` |
109113
| `VITE_API_URL` | Build-time override for the production bundle | `https://api.instanode.dev` |
110114
| `VITE_NO_PROXY` | Disables Vite proxy (set to `1` in E2E) | unset |
111-
| `E2E_API_URL` | Agent API base URL used by Playwright tests | `http://localhost:30080` |
115+
| `E2E_API_URL` | Agent API base URL used by Playwright tests (port-forward `svc/instant-api` first) | `http://localhost:8080` |
112116

113117
---
114118

scripts/retro.mjs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Standalone retro script — uses Playwright lib directly (no test runner).
2+
// Captures screenshots + DOM evidence for every dashboard route.
3+
import { chromium } from 'playwright'
4+
import fs from 'fs'
5+
6+
const OUT = '/tmp/dashboard-retro'
7+
const BASE = 'http://localhost:5173'
8+
9+
const FAKE_TEAM = '00000000-1111-2222-3333-444444444444'
10+
const FAKE_USER = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
11+
const FAKE_RESOURCES = [
12+
{ id: '11111111-aaaa-bbbb-cccc-000000000001', token: '11111111-aaaa-bbbb-cccc-000000000001',
13+
resource_type: 'postgres', name: 'flashcards-db', env: 'production', tier: 'hobby',
14+
status: 'active', storage_bytes: 47_500_000, storage_limit_bytes: 500_000_000,
15+
storage_exceeded: false, connections_in_use: 2, connections_limit: 5,
16+
created_at: '2026-04-22T18:42:11Z', team_id: FAKE_TEAM, expires_at: null },
17+
{ id: '22222222-aaaa-bbbb-cccc-000000000002', token: '22222222-aaaa-bbbb-cccc-000000000002',
18+
resource_type: 'redis', name: 'flashcards-cache', env: 'production', tier: 'hobby',
19+
status: 'active', storage_bytes: 1_200_000, storage_limit_bytes: 25_000_000,
20+
storage_exceeded: false, connections_in_use: 1, connections_limit: 5,
21+
created_at: '2026-04-22T18:42:25Z', team_id: FAKE_TEAM, expires_at: null },
22+
]
23+
24+
function json(body) {
25+
return { status: 200, contentType: 'application/json', body: JSON.stringify(body) }
26+
}
27+
28+
async function installFakes(page) {
29+
await page.route('**/auth/me', (r) => r.fulfill(json({
30+
ok: true, user_id: FAKE_USER, team_id: FAKE_TEAM,
31+
email: 'aanya@example.com', tier: 'hobby', trial_ends_at: null,
32+
})))
33+
await page.route('**/api/v1/resources', (r) => {
34+
if (r.request().method() === 'GET') {
35+
return r.fulfill(json({ ok: true, items: FAKE_RESOURCES, total: FAKE_RESOURCES.length }))
36+
}
37+
return r.continue()
38+
})
39+
for (const res of FAKE_RESOURCES) {
40+
await page.route(`**/api/v1/resources/${res.token}`, (r) =>
41+
r.fulfill(json({ ok: true, item: res })))
42+
await page.route(`**/api/v1/resources/${res.token}/credentials`, (r) =>
43+
r.fulfill(json({ ok: true, id: res.id, token: res.token,
44+
resource_type: res.resource_type, env: res.env,
45+
connection_url: res.resource_type === 'postgres'
46+
? 'postgres://usr:pw@pg.instanode.dev:5432/db'
47+
: 'redis://usr:pw@redis.instanode.dev:6379/0' })))
48+
}
49+
await page.route('**/api/v1/vault/production', (r) => r.fulfill(json({ ok: true, keys: ['RAZORPAY_KEY_SECRET','OPENAI_API_KEY'] })))
50+
await page.route('**/api/v1/vault/staging', (r) => r.fulfill(json({ ok: true, keys: [] })))
51+
await page.route('**/api/v1/vault/development', (r) => r.fulfill(json({ ok: true, keys: [] })))
52+
await page.route('**/api/v1/auth/api-keys', (r) => {
53+
if (r.request().method() === 'GET') {
54+
return r.fulfill(json({ ok: true, items: [
55+
{ id: 'k1111111-1111-1111-1111-111111111111', name: 'laptop',
56+
scopes: ['read','write'], created_at: '2026-05-01T10:00:00Z',
57+
last_used_at: '2026-05-09T18:00:00Z', revoked: false }
58+
]}))
59+
}
60+
return r.continue()
61+
})
62+
await page.route('**/api/v1/stacks', (r) => r.fulfill(json({
63+
ok: true, items: [
64+
{ id: 'stk_flashcards', slug: 'flashcards', name: 'flashcards',
65+
status: 'running', env: 'production', tier: 'hobby',
66+
url: 'https://flashcards.deployment.instanode.dev',
67+
last_deploy_at: new Date(Date.now() - 12*60_000).toISOString(),
68+
build_duration_s: 38, created_at: new Date(Date.now() - 86400_000).toISOString() },
69+
{ id: 'stk_worker', slug: 'worker', name: 'worker',
70+
status: 'building', env: 'production', tier: 'hobby',
71+
url: null, last_deploy_at: new Date(Date.now() - 5*60_000).toISOString(),
72+
build_duration_s: 14, created_at: new Date(Date.now() - 2*86400_000).toISOString() },
73+
], total: 2,
74+
})))
75+
await page.route('**/api/v1/team/members', (r) => r.fulfill(json({
76+
ok: true, members: [
77+
{ id: 'u1', email: 'aanya@example.com', role: 'owner', display_name: 'Aanya', created_at: new Date().toISOString() }
78+
]
79+
})))
80+
await page.route('**/api/v1/team/invitations', (r) => r.fulfill(json({ ok: true, invitations: [] })))
81+
await page.route('**/api/v1/billing', (r) => r.fulfill(json({
82+
ok: true, billing: {
83+
tier: 'hobby', payment_network: 'visa', payment_last4: '4242',
84+
payment_exp_month: 12, payment_exp_year: 2028,
85+
current_period_end: new Date(Date.now() + 9*86400_000).toISOString()
86+
}
87+
})))
88+
await page.route('**/api/v1/billing/invoices', (r) => r.fulfill(json({ ok: true, invoices: [] })))
89+
await page.route('**/api/v1/activity', (r) => r.fulfill(json({ ok: true, items: [] })))
90+
}
91+
92+
async function audit(page, route, slug) {
93+
const screenshot = `${OUT}/${slug}.png`
94+
try { await page.screenshot({ path: screenshot, fullPage: true, timeout: 10000 }) }
95+
catch (e) { console.log(` screenshot fail: ${e.message}`) }
96+
97+
const buttons = await page.$$eval('button', (btns) =>
98+
btns.filter(b => !b.hasAttribute('onclick') && b.getAttribute('type') !== 'submit')
99+
.map(b => (b.getAttribute('aria-label') || b.textContent || '').trim())
100+
.filter(Boolean)
101+
).catch(() => [])
102+
const anchorsNoHref = await page.$$eval('a:not([href])', (as) =>
103+
as.map(a => (a.getAttribute('aria-label') || a.textContent || '').trim()).filter(Boolean)
104+
).catch(() => [])
105+
106+
const text = await page.locator('body').innerText().catch(() => '')
107+
const count = (re) => (text.match(re) || []).length
108+
const shortcuts = {
109+
kbk: count(/K/g),
110+
kbslash: count(/\//g),
111+
sparkle: count(//g),
112+
askAgent: count(/ask agent/gi),
113+
}
114+
const hard = {
115+
acme: count(/acme-corp|acme\.dev|acme\.com/gi),
116+
aanya: count(/aanya|kavya@|marcus/gi),
117+
flashcards: count(/flashcards|render-queue|events-store|cache-sessions/g),
118+
fixtures: count(/d_xY9z2k7m|r_5tYn2k|m_2a8f10|q_cb091f|a31fc8de/g),
119+
renewal: count(/9 days to renewal|auto-charges|May 19/g),
120+
comingSoon: count(/coming soon|mocked|stubbed/gi),
121+
}
122+
const placeholders = (text.match(/\b(TODO|WIP|CHANGE_ME|FIXME|XXX|mocked|stubbed|FIXTURE)\b/g) || [])
123+
const h1 = (await page.locator('h1').first().textContent().catch(() => '')) || ''
124+
125+
console.log(`\n## ${route}`)
126+
console.log(` screenshot: ${screenshot}`)
127+
console.log(` H1: ${h1.trim().slice(0, 80)}`)
128+
console.log(` shortcuts: ⌘K=${shortcuts.kbk} ⌘/=${shortcuts.kbslash} ✦=${shortcuts.sparkle} "ask agent"=${shortcuts.askAgent}`)
129+
console.log(` hardcoded: acme=${hard.acme} aanya/kavya/marcus=${hard.aanya} fixtures=${hard.flashcards} fixture-IDs=${hard.fixtures} renewal=${hard.renewal} comingSoon=${hard.comingSoon}`)
130+
console.log(` placeholders: ${placeholders.join(', ') || '—'}`)
131+
console.log(` buttons-no-onclick: ${buttons.length}`)
132+
if (buttons.length) console.log(` sample: ${buttons.slice(0, 12).map(s => s.replace(/\s+/g,' ').slice(0,40)).join(' | ')}`)
133+
console.log(` anchors-no-href: ${anchorsNoHref.length}`)
134+
if (anchorsNoHref.length) console.log(` sample: ${anchorsNoHref.slice(0, 8).map(s => s.replace(/\s+/g,' ').slice(0,40)).join(' | ')}`)
135+
136+
return { route, screenshot, h1: h1.trim(), shortcuts, hardcoded: hard, placeholders, buttonsNoHandler: buttons, anchorsNoHref }
137+
}
138+
139+
async function main() {
140+
fs.mkdirSync(OUT, { recursive: true })
141+
const browser = await chromium.launch()
142+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
143+
await ctx.addInitScript(() => { localStorage.setItem('instanode.token', 'ink_FAKE_RETRO') })
144+
const page = await ctx.newPage()
145+
await installFakes(page)
146+
147+
const routes = [
148+
['/', 'marketing'],
149+
['/pricing', 'pricing'],
150+
['/for-agents', 'for-agents'],
151+
['/docs', 'docs'],
152+
['/blog', 'blog'],
153+
['/status', 'status'],
154+
['/use-cases', 'use-cases'],
155+
['/login', 'login'],
156+
['/claim?t=eyJhbGciOiJIUzI1NiJ9.fake.fake', 'claim'],
157+
// Re-arm the token (claim flow may clear it), then enter /app.
158+
['__reset__', null],
159+
['/app', 'app-overview'],
160+
['/app/resources', 'app-resources'],
161+
[`/app/resources/${FAKE_RESOURCES[0].id}`, 'app-resource-detail'],
162+
['/app/deployments', 'app-deployments'],
163+
['/app/deployments/stk_flashcards', 'app-deployment-detail'],
164+
['/app/billing', 'app-billing'],
165+
['/app/team', 'app-team'],
166+
['/app/vault', 'app-vault'],
167+
['/app/settings', 'app-settings'],
168+
['/app/stacks', 'app-stacks'],
169+
['/app/agent', 'app-agent'],
170+
['/app/contracts', 'app-contracts'],
171+
]
172+
const results = []
173+
for (const [route, slug] of routes) {
174+
if (route === '__reset__') continue
175+
try {
176+
const isApp = route.startsWith('/app')
177+
await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 15000 })
178+
if (isApp) {
179+
// After AuthGate runs once with no token (synchronously), re-seed and reload.
180+
const onLogin = await page.locator('h1').first().textContent().catch(() => '')
181+
if ((onLogin || '').includes('Sign in')) {
182+
await page.evaluate(() => localStorage.setItem('instanode.token', 'ink_FAKE_RETRO'))
183+
await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 15000 })
184+
}
185+
}
186+
await page.waitForTimeout(900)
187+
results.push(await audit(page, route, slug))
188+
} catch (e) {
189+
console.log(`FAIL ${route}: ${e.message}`)
190+
}
191+
}
192+
fs.writeFileSync(`${OUT}/audit.json`, JSON.stringify(results, null, 2))
193+
await ctx.close()
194+
await browser.close()
195+
console.log(`\nDONE — ${results.length} routes audited`)
196+
}
197+
198+
main().catch((e) => { console.error(e); process.exit(1) })

src/App.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,6 @@ const BillingPage = lazy(() =>
9393
const SettingsPage = lazy(() =>
9494
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
9595
)
96-
const AgentPage = lazy(() =>
97-
import('./pages/AgentPage').then((m) => ({ default: m.AgentPage })),
98-
)
9996
const ContractsPage = lazy(() =>
10097
import('./pages/ContractsPage').then((m) => ({ default: m.ContractsPage })),
10198
)
@@ -199,7 +196,6 @@ export function AppRoutes() {
199196
<Route path="team" element={<TeamPage />} />
200197
<Route path="billing" element={<BillingPage />} />
201198
<Route path="settings" element={<SettingsPage />} />
202-
<Route path="agent" element={<AgentPage />} />
203199
<Route path="contracts" element={<ContractsPage />} />
204200
</Route>
205201

src/api/index.test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -497,15 +497,14 @@ describe('fetchMe()', () => {
497497
window.history.pushState({}, '', '/')
498498
})
499499

500-
it('falls back to FIXTURE shapes on a 500', async () => {
501-
const m = installFetch()
502-
m.mockResolvedValueOnce(jsonResponse(
503-
{ error: 'boom' },
504-
{ status: 500 },
505-
))
506-
const r = await fetchMe()
507-
// fixture user id is u_aanya — proves we hit the fallback.
508-
expect(r.user.id).toBe('u_aanya')
500+
it('propagates errors on 5xx instead of silently serving a fixture identity (§10.21.1)', async () => {
501+
// Previously fetchMe() fell back to FIXTURE_USER on 500 so the chrome
502+
// silently rendered "acme-corp / aanya@acme.dev" mock data when the
503+
// backend was down. Removed — errors propagate; useDashboardCtx
504+
// records meErr and chrome shows the workspace placeholder.
505+
const m = installFetch()
506+
m.mockResolvedValueOnce(jsonResponse({ error: 'boom' }, { status: 500 }))
507+
await expect(fetchMe()).rejects.toBeDefined()
509508
})
510509
})
511510

0 commit comments

Comments
 (0)