Skip to content

Commit e86b1fe

Browse files
jsell-rhclaude
andauthored
feat(ambient-ui): status bar with connection context switching (#1626)
## Summary - **Status bar**: persistent bottom-of-page bar showing API server connection status and URL - **Connection context switching**: single-click URL → popover with URL + token fields to connect to remote API servers - **Popover UX**: labeled fields, collapsible Authentication section, token visibility toggle, Connect/Restore Default buttons - **Custom context indicator**: blue plug icon + "Custom Server" label when overriding default - **Cache invalidation**: `removeQueries` + `refetchQueries` ensures fresh data after context switch - **Navigation**: redirects to `/` on context change to avoid stale entity references Spec updated with "Status Bar" and "Connection Context Switching" requirements. ## Architecture - `src/lib/runtime-config.ts` — shared module for runtime-mutable API server URL + custom token - `src/app/api/config/route.ts` — GET/PUT/DELETE for connection context (token stored server-side, never exposed) - BFF proxy reads from runtime config: custom token > SSO JWT > dev-mode token - `src/hooks/use-connection-status.ts` — polls `/api/healthz` every 10s ## Test plan - [x] 171 tests pass, 0 type errors - [ ] Default state: green dot + URL + chevron in status bar - [ ] Click URL → popover with Connection form - [ ] Enter remote URL + JWT token → click Connect → data loads from remote - [ ] Blue plug icon + "Custom Server" shown in status bar - [ ] Click URL again → popover shows "Restore Default" banner - [ ] Restore Default → reverts to local, navigates to / - [ ] Hard refresh preserves custom context (server-side storage) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Persistent bottom Status Bar showing live connection state and allowing editing of API server URL and optional bearer token. * Popover-based UI for editing connection context and restoring defaults; connection changes trigger refresh/navigation as needed. * Periodic connection health polling with last-checked timestamps. * **Tests** * Added tests covering connection status behaviors. * **Documentation** * Updated spec to describe Status Bar behavior and custom-context UX. * **Chores** * Added popover UI dependency. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7040604 commit e86b1fe

13 files changed

Lines changed: 954 additions & 58 deletions

File tree

components/ambient-ui/package-lock.json

Lines changed: 94 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/ambient-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@radix-ui/react-avatar": "^1.1.10",
1616
"@radix-ui/react-dialog": "^1.1.15",
1717
"@radix-ui/react-dropdown-menu": "^2.1.16",
18+
"@radix-ui/react-popover": "^1.1.15",
1819
"@radix-ui/react-select": "^2.2.6",
1920
"@radix-ui/react-separator": "^1.1.4",
2021
"@radix-ui/react-slot": "^1.2.4",

components/ambient-ui/src/app/(dashboard)/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { usePathname } from 'next/navigation'
44
import { AppSidebar } from '@/components/app-sidebar'
55
import { NavHeader } from '@/components/nav-header'
6+
import { StatusBar } from '@/components/status-bar'
67
import { useProject } from '@/queries/use-projects'
78
import {
89
SidebarInset,
@@ -38,7 +39,8 @@ export default function DashboardLayout({
3839
projectName={project?.name ?? null}
3940
pageName={pageName}
4041
/>
41-
<div className="flex-1 p-6">{children}</div>
42+
<div className="flex-1 p-6 pb-10">{children}</div>
43+
<StatusBar />
4244
</SidebarInset>
4345
</SidebarProvider>
4446
)

components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { resolveAccessToken, buildProxyHeaders } from "@/lib/auth"
2-
import { API_SERVER_URL } from "@/lib/config"
2+
import { getRuntimeConfig } from "@/lib/runtime-config"
33

44
export const runtime = "nodejs"
55
export const dynamic = "force-dynamic"
@@ -14,11 +14,22 @@ async function proxyRequest(
1414
if (path.some(s => s === ".." || s === ".")) {
1515
return Response.json({ error: "invalid_path" }, { status: 400 })
1616
}
17+
18+
const config = await getRuntimeConfig()
19+
const apiServerUrl = config.apiServerUrl
20+
1721
const pathStr = path.map(s => encodeURIComponent(s)).join("/")
18-
const url = new URL(`/api/ambient/v1/${pathStr}`, API_SERVER_URL)
22+
const url = new URL(`/api/ambient/v1/${pathStr}`, apiServerUrl)
1923
url.search = new URL(request.url).search
2024

21-
const accessToken = await resolveAccessToken(request)
25+
// Auth priority: custom token > SSO/oauth token > dev-mode token
26+
let accessToken: string | undefined
27+
if (config.customToken) {
28+
accessToken = config.customToken
29+
} else {
30+
accessToken = await resolveAccessToken(request)
31+
}
32+
2233
if (!accessToken) {
2334
return Response.json({ error: "Unauthorized" }, { status: 401 })
2435
}
@@ -28,7 +39,7 @@ async function proxyRequest(
2839
// oauth-proxy provides an OpenShift OAuth token (different auth system).
2940
// Strip the Authorization header until auth systems are unified — the
3041
// BFF's oauth-proxy already authenticates the user.
31-
if (accessToken.startsWith("sha256~")) {
42+
if (!config.customToken && accessToken.startsWith("sha256~")) {
3243
delete headers["Authorization"]
3344
}
3445

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { getRuntimeConfig, setCustomContext, resetContext } from '@/lib/runtime-config'
2+
3+
export const runtime = 'nodejs'
4+
export const dynamic = 'force-dynamic'
5+
6+
export async function GET() {
7+
const config = await getRuntimeConfig()
8+
return Response.json({
9+
apiServerUrl: config.apiServerUrl,
10+
customToken: config.customToken !== null,
11+
isCustomContext: config.isCustomContext,
12+
defaultApiServerUrl: config.defaultApiServerUrl,
13+
})
14+
}
15+
16+
export async function PUT(request: Request) {
17+
let body: unknown
18+
try {
19+
body = await request.json()
20+
} catch {
21+
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
22+
}
23+
24+
if (typeof body !== 'object' || body === null) {
25+
return Response.json(
26+
{ error: 'Request body must be a JSON object' },
27+
{ status: 400 },
28+
)
29+
}
30+
31+
const parsed = body as Record<string, unknown>
32+
const url = parsed.apiServerUrl
33+
const token = parsed.customToken
34+
35+
if (url !== undefined && typeof url !== 'string') {
36+
return Response.json(
37+
{ error: 'apiServerUrl must be a string' },
38+
{ status: 400 },
39+
)
40+
}
41+
42+
if (token !== undefined && token !== null && typeof token !== 'string') {
43+
return Response.json(
44+
{ error: 'customToken must be a string or null' },
45+
{ status: 400 },
46+
)
47+
}
48+
49+
if (typeof url === 'string' && !url.startsWith('http://') && !url.startsWith('https://')) {
50+
return Response.json(
51+
{ error: 'apiServerUrl must start with http:// or https://' },
52+
{ status: 400 },
53+
)
54+
}
55+
56+
if (url === undefined && token === undefined) {
57+
return Response.json(
58+
{ error: 'Request body must include apiServerUrl or customToken' },
59+
{ status: 400 },
60+
)
61+
}
62+
63+
await setCustomContext(
64+
typeof url === 'string' ? url : undefined,
65+
token === null ? null : typeof token === 'string' ? (token || null) : undefined,
66+
)
67+
68+
const config = await getRuntimeConfig()
69+
return Response.json({
70+
apiServerUrl: config.apiServerUrl,
71+
customToken: config.customToken !== null,
72+
isCustomContext: config.isCustomContext,
73+
defaultApiServerUrl: config.defaultApiServerUrl,
74+
})
75+
}
76+
77+
export async function DELETE() {
78+
await resetContext()
79+
const config = await getRuntimeConfig()
80+
return Response.json({
81+
apiServerUrl: config.apiServerUrl,
82+
customToken: config.customToken !== null,
83+
isCustomContext: config.isCustomContext,
84+
defaultApiServerUrl: config.defaultApiServerUrl,
85+
})
86+
}

components/ambient-ui/src/components/nav-header.tsx

Lines changed: 42 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -25,59 +25,52 @@ export function NavHeader({ projectId, projectName, pageName, sessionName }: Nav
2525
<SidebarTrigger />
2626
<Separator orientation="vertical" className="mx-1 h-5" />
2727

28-
<div className="flex flex-1 items-center justify-between">
29-
<Breadcrumb>
30-
<BreadcrumbList>
31-
<BreadcrumbItem>
32-
<BreadcrumbLink asChild>
33-
<Link href="/">
34-
<span className="font-semibold">Ambient</span>
35-
</Link>
36-
</BreadcrumbLink>
37-
</BreadcrumbItem>
28+
<Breadcrumb>
29+
<BreadcrumbList>
30+
<BreadcrumbItem>
31+
<BreadcrumbLink asChild>
32+
<Link href="/">
33+
<span className="font-semibold">Ambient</span>
34+
</Link>
35+
</BreadcrumbLink>
36+
</BreadcrumbItem>
3837

39-
{projectId && (
40-
<>
41-
<BreadcrumbSeparator />
42-
<BreadcrumbItem>
38+
{projectId && (
39+
<>
40+
<BreadcrumbSeparator />
41+
<BreadcrumbItem>
42+
<BreadcrumbLink asChild>
43+
<Link href={`/${projectId}/fleet`}>{projectName ?? projectId}</Link>
44+
</BreadcrumbLink>
45+
</BreadcrumbItem>
46+
</>
47+
)}
48+
49+
{pageName && (
50+
<>
51+
<BreadcrumbSeparator />
52+
<BreadcrumbItem>
53+
{sessionName ? (
4354
<BreadcrumbLink asChild>
44-
<Link href={`/${projectId}/fleet`}>{projectName ?? projectId}</Link>
55+
<Link href={`/${projectId}/fleet`}>{pageName}</Link>
4556
</BreadcrumbLink>
46-
</BreadcrumbItem>
47-
</>
48-
)}
49-
50-
{pageName && (
51-
<>
52-
<BreadcrumbSeparator />
53-
<BreadcrumbItem>
54-
{sessionName ? (
55-
<BreadcrumbLink asChild>
56-
<Link href={`/${projectId}/fleet`}>{pageName}</Link>
57-
</BreadcrumbLink>
58-
) : (
59-
<BreadcrumbPage>{pageName}</BreadcrumbPage>
60-
)}
61-
</BreadcrumbItem>
62-
</>
63-
)}
64-
65-
{sessionName && (
66-
<>
67-
<BreadcrumbSeparator />
68-
<BreadcrumbItem>
69-
<BreadcrumbPage>{sessionName}</BreadcrumbPage>
70-
</BreadcrumbItem>
71-
</>
72-
)}
73-
</BreadcrumbList>
74-
</Breadcrumb>
57+
) : (
58+
<BreadcrumbPage>{pageName}</BreadcrumbPage>
59+
)}
60+
</BreadcrumbItem>
61+
</>
62+
)}
7563

76-
<div className="flex items-center gap-2">
77-
<span className="inline-block size-2.5 rounded-full bg-status-success-foreground" aria-label="Cluster connected" />
78-
<span className="text-xs text-muted-foreground">Connected</span>
79-
</div>
80-
</div>
64+
{sessionName && (
65+
<>
66+
<BreadcrumbSeparator />
67+
<BreadcrumbItem>
68+
<BreadcrumbPage>{sessionName}</BreadcrumbPage>
69+
</BreadcrumbItem>
70+
</>
71+
)}
72+
</BreadcrumbList>
73+
</Breadcrumb>
8174
</header>
8275
)
8376
}

0 commit comments

Comments
 (0)