Skip to content

Commit 1611d19

Browse files
fix(dashboard): wave-4 P2s — unlimited bars, deploy count, conn tile, changelog (#94)
- UsageBar: treat limit < 0 as "unlimited" (∞ label, neutral bar) instead of rendering a literal "-1"; call sites pass raw -1 and drop the per-call workaround. - OverviewPage + useDashboardCtx: source the deployments count from GET /api/v1/deployments (api.listDeployments) — deployments are a separate table, never resource_type==='deploy' rows in /api/v1/resources, so the old filter was always 0. - OverviewPage connections tile: drop the connections_in_use numerator the API never emits; show the aggregate per-tier connection ceiling and skip -1 (unlimited) when summing so it can't corrupt the denominator. - ChangelogPage: add factual entries for 2026-05-15 (Pro storage bump + annual pricing), 2026-05-16 (tier enforcement + billing resilience), and 2026-05-17 (P0/P1 bug-hunt fixes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> - public/llms.txt (synced copy): same upgrade_url->upgrade + pgvector fixes applied to content/llms.txt (manual sync per CLAUDE.md).
1 parent c096dd2 commit 1611d19

7 files changed

Lines changed: 83 additions & 19 deletions

File tree

public/llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ On a prompt like that, the agent should: `POST /db/new` for Postgres, write the
1414

1515
## API endpoints
1616

17-
All accept `POST` against `https://api.instanode.dev`. No authentication header required for the anonymous tier, but every request body **must** include `name` (see below). Response shape: `{ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt, upgrade_url}` for db/cache/nosql/queue — where `upgrade_url` is the human-readable claim link (e.g. `https://instanode.dev/start?t=<jwt>`) and `upgrade_jwt` is the raw JWT for programmatic use. `/webhook/new` returns `receive_url` (no internal_url). `/storage/new` adds `endpoint`, `access_key_id`, `secret_access_key`, `prefix` alongside `connection_url`.
17+
All accept `POST` against `https://api.instanode.dev`. No authentication header required for the anonymous tier, but every request body **must** include `name` (see below). Response shape: `{ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt, upgrade}` for db/cache/nosql/queue — where `upgrade` is the human-readable claim link (e.g. `https://api.instanode.dev/start?t=<jwt>`) and `upgrade_jwt` is the raw JWT for programmatic use. `/webhook/new` returns `receive_url` (no internal_url). `/storage/new` adds `endpoint`, `access_key_id`, `secret_access_key`, `prefix` alongside `connection_url`.
1818

1919
### Required `name` field (all provisioning endpoints)
2020

@@ -28,7 +28,7 @@ Every provisioning endpoint — `/db/new`, `/cache/new`, `/nosql/new`, `/queue/n
2828

2929
Pick a descriptive name per resource (e.g. `"prod-db"`, `"sessions-cache"`, `"events-queue"`) so the user can tell resources apart later.
3030

31-
- **`POST /db/new`** — Postgres database. Requires `name`. `pgvector` pre-installed (HNSW + IVFFlat indexes work). Returns `connection_url` in the form `postgres://USER:PASS@HOST:PORT/DB`. Use any standard Postgres client / driver.
31+
- **`POST /db/new`** — Postgres database. Requires `name`. Returns `connection_url` in the form `postgres://USER:PASS@HOST:PORT/DB`. Use any standard Postgres client / driver. (For pgvector / HNSW + IVFFlat similarity search, use the separate `POST /vector/new` endpoint.)
3232
- **`POST /cache/new`** — Redis. Requires `name`. Per-token ACL'd user + namespaced keyspace. Returns `connection_url` in the form `redis://:PASS@HOST:PORT/DB`.
3333
- **`POST /nosql/new`** — MongoDB. Requires `name`. Per-token user scoped to a single database. Returns a `mongodb://...` connection URL. The per-token connection budget is documented in the response `limits.connections` field (e.g. anonymous = 2). The underlying shared-tenant pod admits up to 20 simultaneous connections across all tokens, so plan agents to stay well below their per-token allocation under burst.
3434
- **`POST /queue/new`** — NATS JetStream. Requires `name`. Returns `connection_url` (`nats://pool-<uuid>@host:4222`) with per-account subject isolation baked into the URL. No separate creds file — pass the connection_url directly to any NATS client. Durable streams, request/reply, pub/sub.

src/components/Common.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ export function Sparkline({
324324
}
325325

326326
// ------------- usage bar -------------
327+
// The agent API emits `limit = -1` for unlimited (team-tier) quotas. A bare
328+
// `-1` would render a literal "-1" in the label and a NaN/negative ratio for
329+
// the bar fill, so `limit < 0` is treated as "unlimited": the fill stays
330+
// neutral (no usage ratio is meaningful against an unbounded limit) and the
331+
// label shows the used value against an ∞ symbol.
327332
export function UsageBar({
328333
used,
329334
limit,
@@ -333,7 +338,8 @@ export function UsageBar({
333338
limit: number
334339
format?: (used: string, limit: string) => string
335340
}) {
336-
const ratio = limit > 0 ? Math.min(used / limit, 1) : 0
341+
const unlimited = limit < 0
342+
const ratio = !unlimited && limit > 0 ? Math.min(used / limit, 1) : 0
337343
const cls = ratio > 0.95 ? 'fill danger' : ratio > 0.8 ? 'fill warn' : 'fill'
338344
const fmt = (n: number) => {
339345
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} GB`
@@ -346,7 +352,7 @@ export function UsageBar({
346352
<span className="bar">
347353
<span className={cls} style={{ width: `${ratio * 100}%` }} />
348354
</span>
349-
<span className="num">{format(fmt(used), fmt(limit))}</span>
355+
<span className="num">{format(fmt(used), unlimited ? '∞' : fmt(limit))}</span>
350356
</div>
351357
)
352358
}

src/hooks/useDashboardCtx.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,14 @@ async function refreshMe() {
113113

114114
async function refreshCounts() {
115115
try {
116-
const [r, v] = await Promise.all([
116+
// Deployments live in their own table (GET /api/v1/deployments) — they
117+
// are NOT rows in the `resources` list (resource_type === 'deploy' never
118+
// appears there), so the sidebar deployments count must be sourced from
119+
// api.listDeployments(), not a filter over `resources`.
120+
const [r, v, d] = await Promise.all([
117121
api.listResources().catch(() => ({ items: [], total: 0 })),
118122
api.listVault(state.env).catch(() => ({ entries: [] })),
123+
api.listDeployments(state.env).catch(() => ({ ok: true as const, items: [], total: 0 })),
119124
])
120125
// Merge envs from resources too — surfaces real env names.
121126
const fromAPI = new Set(state.envs)
@@ -130,7 +135,7 @@ async function refreshCounts() {
130135
resources: items,
131136
counts: {
132137
resources: filtered.length,
133-
deployments: filtered.filter((x) => x.resource_type === 'deploy').length,
138+
deployments: ((d as any).items ?? []).length,
134139
vault: ((v as any).entries ?? []).length,
135140
team: 1, // no /team/members endpoint; placeholder
136141
},

src/pages/ChangelogPage.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,37 @@ interface ChangelogEntry {
3737
* each bullet single-line, no marketing fluff — the audience is a
3838
* procurement reviewer or an on-call engineer checking what changed. */
3939
const ENTRIES: ChangelogEntry[] = [
40+
{
41+
date: '2026-05-17',
42+
title: 'Bug-hunt remediation — P0/P1 fixes',
43+
bullets: [
44+
'Hardened POST /claim against account-takeover: a claim now requires the session it claims into and no longer mints a token for a pre-existing email.',
45+
'Large deploy tarballs are read in full (io.ReadAll) — fixed a truncation bug where big multipart uploads built on a partly-zero buffer.',
46+
'Redeploys now re-resolve vault:// env references, so vault-backed apps no longer break on redeploy.',
47+
'Customer-deploy NetworkPolicy egress now covers the production DOKS pod/service CIDRs (was hardcoded to the dev cluster ranges).',
48+
],
49+
},
50+
{
51+
date: '2026-05-16',
52+
title: 'Tier enforcement + billing resilience',
53+
bullets: [
54+
'Provisioning responses now redact secret-bearing env values (credential URLs, *_KEY/_SECRET/_TOKEN keys); the dashboard masks them behind a reveal toggle.',
55+
'Storage-quota enforcement now does a real provisioner-side revoke (Postgres REVOKE CONNECT, Redis ACL disable, Mongo role revoke) with auto-unsuspend when usage drops.',
56+
'Plan upgrades now elevate deployments and stacks alongside resources and clear their anonymous 24h TTL — a paid app is no longer expired by the lifecycle worker.',
57+
'Billing reconciler added: a 15-minute poll against Razorpay closes any missed-webhook gap in either direction.',
58+
'Dedicated Redis is capped at provision time with a per-tier maxmemory; the entitlement reconciler re-applies the cap on tier changes.',
59+
],
60+
},
61+
{
62+
date: '2026-05-15',
63+
title: 'Pro storage bump + annual pricing',
64+
bullets: [
65+
'Pro-tier storage raised to 10 GB Postgres / 512 MB Redis / 5 GB MongoDB — a material limits increase across all Pro subscriptions.',
66+
'Annual billing added for Hobby, Hobby Plus, Pro, and Team: same limits as the monthly plans, billed yearly at a discount.',
67+
'Free, Hobby Plus, and Growth tiers reconciled across the pricing page, billing page, and API documentation so every surface quotes the same numbers.',
68+
'Default provisioning environment is now "development" — a call that omits `env` lands in the lowest-stakes bucket instead of merging with production state.',
69+
],
70+
},
4071
{
4172
date: '2026-05-14',
4273
title: 'Trust + marketing accuracy pass (W12)',

src/pages/OverviewPage.tier-limit.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock('../api', async () => {
2424
...actual,
2525
listResources: vi.fn().mockResolvedValue({ ok: true, items: [], total: 0 }),
2626
fetchActivity: vi.fn().mockResolvedValue({ ok: true, items: [] }),
27+
listDeployments: vi.fn().mockResolvedValue({ ok: true, items: [], total: 0 }),
2728
}
2829
})
2930

src/pages/OverviewPage.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { QuotaWallBanner } from '../components/QuotaWallBanner'
88
import { UpgradeButton } from '../components/UpgradeButton'
99
import * as api from '../api'
10-
import type { Resource, ActivityItem } from '../api'
10+
import type { Resource, ActivityItem, DashboardDeployment } from '../api'
1111
import { useDashboardCtx } from '../hooks/useDashboardCtx'
1212

1313
// Storage limit per tier in GB, used for the Overview usage chart.
@@ -67,6 +67,10 @@ function stripHtmlTags(s: string | null | undefined): string {
6767
export function OverviewPage() {
6868
const [resources, setResources] = useState<Resource[]>([])
6969
const [activity, setActivity] = useState<ActivityItem[]>([])
70+
// Deployments live in their own table served by GET /api/v1/deployments —
71+
// they are NOT rows in `resources` (resource_type === 'deploy' never
72+
// appears there), so the deployments tile must source them separately.
73+
const [deployments, setDeployments] = useState<DashboardDeployment[]>([])
7074
const [loading, setLoading] = useState(true)
7175
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null)
7276
const ctx = useDashboardCtx()
@@ -135,10 +139,17 @@ export function OverviewPage() {
135139

136140
useEffect(() => {
137141
let alive = true
138-
Promise.all([api.listResources(ctx.env), api.fetchActivity()]).then(([r, a]) => {
142+
Promise.all([
143+
api.listResources(ctx.env),
144+
api.fetchActivity(),
145+
// Deployments are a separate table — fail-soft to [] so a deployments
146+
// outage doesn't blank the resources/activity tiles.
147+
api.listDeployments(ctx.env).catch(() => ({ ok: true as const, items: [], total: 0 })),
148+
]).then(([r, a, d]) => {
139149
if (!alive) return
140150
setResources(r.items)
141151
setActivity(a.items)
152+
setDeployments(d.items)
142153
setLoading(false)
143154
})
144155
return () => {
@@ -152,19 +163,27 @@ export function OverviewPage() {
152163
// FIX-K (2026-05-16): plans.yaml uses binary MiB; use *1024 (was *1000).
153164
const tierLimitMB = tierLimitGB * 1024
154165
const storagePct = tierLimitMB > 0 ? Math.round((totalStorageMB / tierLimitMB) * 100) : 0
155-
const conn = resources.reduce((s, r) => s + (r.connections_in_use ?? 0), 0)
156-
const connLim = resources.reduce((s, r) => s + (r.connections_limit ?? 0), 0)
166+
// Connections tile: the API never emits `connections_in_use`, so a numerator
167+
// sourced from it is always 0 — show the per-tier connection ceiling only.
168+
// `-1` (team/growth unlimited) is excluded from the sum so it can't corrupt
169+
// the denominator; if any resource is unlimited the tile reports ∞.
170+
const connUnlimited = resources.some((r) => (r.connections_limit ?? 0) < 0)
171+
const connLim = resources.reduce(
172+
(s, r) => s + ((r.connections_limit ?? 0) > 0 ? (r.connections_limit as number) : 0),
173+
0,
174+
)
157175
const recent = [...resources].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)).slice(0, 4)
158176

159177
const now = Date.now()
160178
const newThisWeek = resources.filter((r) => now - +new Date(r.created_at) <= SEVEN_DAYS_MS).length
161-
const deploys = resources.filter((r) => r.resource_type === 'deploy')
162-
const deployCount = deploys.length
163-
const deployHealthy = deploys.filter((r) => r.status === 'active').length
179+
// Deployments are a separate table (GET /api/v1/deployments) — never rows in
180+
// `resources`. Source the count + health from the dedicated fetch.
181+
const deployCount = deployments.length
182+
const deployHealthy = deployments.filter((d) => d.status === 'running').length
164183
const webhookCount = resources.filter((r) => r.resource_type === 'webhook').length
165184

166185
const storageSub = resources.length === 0 ? tier : `${storagePct}% of ${tier} tier`
167-
const connSub = connLim === 0 ? '' : `${conn}/${connLim} active`
186+
const connSub = connUnlimited ? 'unlimited' : connLim === 0 ? '' : `${connLim} max`
168187
const deploySub = deployCount === 0 ? 'none yet' : `${deployHealthy}/${deployCount} healthy`
169188
const vaultSub = vaultCount === 0 ? '' : `scoped to ${env}`
170189

@@ -187,7 +206,10 @@ export function OverviewPage() {
187206
stats/series is live. */}
188207
<Stat k="resources" v={loading ? '—' : resources.length.toString()} d={newThisWeek === 0 ? '—' : `+${newThisWeek} this week`} series={undefined} />
189208
<Stat k={`storage / ${tierLimitGB} GB`} v={loading ? '—' : (totalStorageMB / 1000).toFixed(1)} unit="GB" d={storageSub} dCls="dim" series={undefined} />
190-
<Stat k={`conn / ${connLim}`} v={loading ? '—' : conn.toString()} d={connSub} dCls="dim" series={undefined} />
209+
{/* Connections tile shows the aggregate per-tier connection ceiling.
210+
The API does not emit a live in-use count, so a numerator would
211+
always read 0 — show the ceiling honestly instead. */}
212+
<Stat k="connection limit" v={loading ? '—' : connUnlimited ? '∞' : connLim.toString()} d={connSub} dCls="dim" series={undefined} />
191213
<Stat k="deployments" v={loading ? '—' : deployCount.toString()} d={deploySub} series={undefined} />
192214
<Stat k="webhooks · 24h" v={loading ? '—' : webhookCount.toString()} d="" series={undefined} />
193215
<Stat k="vault entries" v={vaultCount.toString()} d={vaultSub} dCls="dim" series={undefined} />
@@ -242,8 +264,7 @@ export function OverviewPage() {
242264
<EnvPill env={r.env} />
243265
<UsageBar
244266
used={Math.round(r.storage_bytes / 1_000_000)}
245-
limit={r.storage_limit_bytes === -1 ? 0 : Math.round(r.storage_limit_bytes / 1_000_000)}
246-
format={(a, b) => r.storage_limit_bytes === -1 ? `${a} / ∞` : `${a} / ${b}`}
267+
limit={r.storage_limit_bytes < 0 ? -1 : Math.round(r.storage_limit_bytes / 1_000_000)}
247268
/>
248269
<RelTime at={r.created_at} />
249270
<button className="res-action" aria-label="actions"></button>

src/pages/ResourcesPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,8 @@ export function ResourcesPage() {
228228
<EnvPill env={r.env} />
229229
<UsageBar
230230
used={Math.round(r.storage_bytes / 1_000_000)}
231-
limit={r.storage_limit_bytes === -1 ? 0 : Math.round(r.storage_limit_bytes / 1_000_000)}
232-
format={(a, b) => r.storage_limit_bytes === -1 ? `${a} / ∞ MB` : `${a} / ${b} MB`}
231+
limit={r.storage_limit_bytes < 0 ? -1 : Math.round(r.storage_limit_bytes / 1_000_000)}
232+
format={(a, b) => `${a} / ${b} MB`}
233233
/>
234234
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-dim)' }}>
235235
{r.connections_in_use ?? '—'} / {r.connections_limit == null ? '—' : r.connections_limit === -1 ? '∞' : r.connections_limit}

0 commit comments

Comments
 (0)