Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
771 changes: 771 additions & 0 deletions apps/console/src/App.css

Large diffs are not rendered by default.

27 changes: 7 additions & 20 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router'
import { AppShell, type ConsolePage } from './components/AppShell'
import { eventFixtures } from './data/events'
import type { EventFixture } from './data/types'
import type { ConsolePage } from './components/AppShell'
import { OwnerConsoleGate } from './components/OwnerConsoleGate'
import { DeliveryInspectorPage } from './pages/DeliveryInspectorPage'
import { EndpointsPage } from './pages/EndpointsPage'
import { EventStreamPage } from './pages/EventStreamPage'
Expand Down Expand Up @@ -33,36 +32,24 @@ function ConsoleLayout() {
const navigate = useNavigate()

return (
<AppShell
<OwnerConsoleGate
activePage={getActivePage(location.pathname)}
onNavigate={(page) => navigate(pathsByPage[page])}
>
<Outlet />
</AppShell>
</OwnerConsoleGate>
)
}

function EventStreamRoute() {
const navigate = useNavigate()

function inspectEvent(event: EventFixture) {
navigate(`/console/events/${event.id}`)
}

return <EventStreamPage onSelectEvent={inspectEvent} />
}

function DeliveryInspectorRoute() {
const navigate = useNavigate()
const { eventId } = useParams()

const event = eventFixtures.find((fixture) => fixture.id === eventId)

if (!event) {
if (!eventId) {
return <Navigate to="/console/events" replace />
}

return <DeliveryInspectorPage event={event} onBack={() => navigate('/console/events')} />
return <DeliveryInspectorPage eventId={eventId} onBack={() => navigate('/console/events')} />
}

function App() {
Expand All @@ -72,7 +59,7 @@ function App() {

<Route path="/console" element={<ConsoleLayout />}>
<Route index element={<OverviewPage />} />
<Route path="events" element={<EventStreamRoute />} />
<Route path="events" element={<EventStreamPage />} />
<Route path="events/:eventId" element={<DeliveryInspectorRoute />} />
<Route path="endpoints" element={<EndpointsPage />} />
<Route path="failure-lab" element={<FailureLabPage />} />
Expand Down
18 changes: 14 additions & 4 deletions apps/console/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ interface AppShellProps {
activePage: ConsolePage
children: ReactNode
onNavigate: (page: ConsolePage) => void
onLogout: () => void
}

const navigation: Array<{ page: ConsolePage; label: string }> = [
const navigation: Array<{
page: ConsolePage
label: string
}> = [
{ page: 'overview', label: 'Overview' },
{ page: 'events', label: 'Event stream' },
{ page: 'endpoints', label: 'Endpoints' },
{ page: 'failure-lab', label: 'Failure Lab' },
{ page: 'health', label: 'System health' },
]

export function AppShell({ activePage, children, onNavigate }: AppShellProps) {
export function AppShell({ activePage, children, onNavigate, onLogout }: AppShellProps) {
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
Expand Down Expand Up @@ -53,8 +57,14 @@ export function AppShell({ activePage, children, onNavigate }: AppShellProps) {
</nav>

<div className="sidebar__footer">
<span className="environment-dot" aria-hidden="true" />
Demo environment
<div className="sidebar__environment">
<span className="environment-dot" aria-hidden="true" />
Owner console
</div>

<button className="sidebar__logout" type="button" onClick={onLogout}>
Sign out
</button>
</div>
</aside>

Expand Down
172 changes: 172 additions & 0 deletions apps/console/src/components/OwnerConsoleGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from 'react'
import { createOwnerSession, getOwnerSession, logoutOwner, OwnerApiError } from '../lib/owner-api'
import { AppShell, type ConsolePage } from './AppShell'

type AuthenticationState = 'checking' | 'unauthenticated' | 'authenticated' | 'error'

interface OwnerConsoleGateProps {
activePage: ConsolePage
children: ReactNode
onNavigate: (page: ConsolePage) => void
}

export function OwnerConsoleGate({ activePage, children, onNavigate }: OwnerConsoleGateProps) {
const [state, setState] = useState<AuthenticationState>('checking')
const [token, setToken] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)

const checkSession = useCallback(async () => {
setState('checking')
setMessage(null)

try {
await getOwnerSession()
setState('authenticated')
} catch (error) {
if (error instanceof OwnerApiError && error.status === 401) {
setState('unauthenticated')
return
}

setMessage(
error instanceof Error ? error.message : 'Owner authentication could not be checked.',
)
setState('error')
}
}, [])

useEffect(() => {
const timeoutId = window.setTimeout(() => {
void checkSession()
}, 0)

return () => window.clearTimeout(timeoutId)
}, [checkSession])

async function handleLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault()

if (!token || submitting) {
return
}

setSubmitting(true)
setMessage(null)

try {
await createOwnerSession(token)

setToken('')
setState('authenticated')
} catch (error) {
setMessage(
error instanceof OwnerApiError
? error.message
: 'Owner authentication could not be completed.',
)
} finally {
setSubmitting(false)
}
}

async function handleLogout() {
try {
await logoutOwner()
} catch (error) {
if (!(error instanceof OwnerApiError && error.status === 401)) {
setMessage(
error instanceof Error ? error.message : 'The owner session could not be closed.',
)
return
}
}

setState('unauthenticated')
setToken('')
setMessage(null)
}

if (state === 'authenticated') {
return (
<AppShell
activePage={activePage}
onNavigate={onNavigate}
onLogout={() => void handleLogout()}
>
{children}
</AppShell>
)
}

if (state === 'checking') {
return (
<main className="owner-auth">
<section className="owner-auth__card" aria-live="polite">
<p className="eyebrow">Relay owner console</p>
<h1>Checking session</h1>
<p>Verifying the signed owner session.</p>
</section>
</main>
)
}

if (state === 'error') {
return (
<main className="owner-auth">
<section className="owner-auth__card" role="alert">
<p className="eyebrow">Relay owner console</p>
<h1>Console unavailable</h1>
<p>{message ?? 'Owner authentication could not be checked.'}</p>

<button className="primary-button" type="button" onClick={() => void checkSession()}>
Retry
</button>
</section>
</main>
)
}

return (
<main className="owner-auth">
<section className="owner-auth__card">
<p className="eyebrow">Relay owner console</p>
<h1>Authenticate</h1>
<p>Enter the bootstrap token configured for this Relay deployment.</p>

<form className="owner-auth__form" onSubmit={(event) => void handleLogin(event)}>
<label htmlFor="owner-bootstrap-token">Owner bootstrap token</label>

<input
id="owner-bootstrap-token"
type="password"
value={token}
autoComplete="off"
autoFocus
required
onChange={(event) => setToken(event.target.value)}
/>

{message ? (
<p className="owner-auth__error" role="alert">
{message}
</p>
) : null}

<button
className="primary-button"
type="submit"
disabled={submitting || token.length === 0}
>
{submitting ? 'Authenticating…' : 'Open console'}
</button>
</form>

<p className="owner-auth__note">
The bootstrap credential is exchanged for a short-lived signed session. This interface
does not persist the bootstrap credential.
</p>
</section>
</main>
)
}
31 changes: 0 additions & 31 deletions apps/console/src/components/StatePanel.tsx

This file was deleted.

23 changes: 17 additions & 6 deletions apps/console/src/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import type { DeliveryStatus } from '../data/types'
import type { EndpointSummary, EventSummary } from '@relay/contracts'

type OperationalStatus =
EventSummary['status'] | EndpointSummary['status'] | EndpointSummary['health'] | 'replayed'

interface StatusBadgeProps {
status: DeliveryStatus | 'healthy' | 'degraded' | 'disabled'
status: OperationalStatus
}

const labels: Record<StatusBadgeProps['status'], string> = {
const labels: Record<OperationalStatus, string> = {
queued: 'Queued',
delivered: 'Delivered',
leased: 'Leased',
retrying: 'Retrying',
delivered: 'Delivered',
exhausted: 'Exhausted',
replayed: 'Replayed',
cancelled: 'Cancelled',
mixed: 'Mixed',
no_deliveries: 'No deliveries',
pending: 'Pending',
active: 'Active',
paused: 'Paused',
disabled: 'Disabled',
healthy: 'Healthy',
degraded: 'Degraded',
disabled: 'Disabled',
unknown: 'Unknown',
replayed: 'Replayed',
}

export function StatusBadge({ status }: StatusBadgeProps) {
Expand Down
47 changes: 0 additions & 47 deletions apps/console/src/data/endpoints.ts

This file was deleted.

Loading
Loading