Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 f6248ae6a8 fix(esb): hide the table ESB section on backends without the ESB module (#8)
Without qqq-esb the /esb/table route does not exist and the default
root-SPA not-found handler answers 200 with index.html. getEsbForTable
now returns null for any 2xx body that is not an ESB table object
instead of throwing, so the Developer view no longer retries and shows a
"Something went wrong" toast. Adds a page test against the app query
client (HTML 200: no section, no toast).

Part of QRun-IO/qqq#739

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:33:27 -05:00
james.maes 9824b7d60d feat(esb): ESB section in table developer view (#8)
Shows publications and subscribing triggers with counters, queue depth and dead letters; hidden on 403/404.

Part of QRun-IO/qqq#739
2026-09-25 19:24:22 -05:00
james.maes c95f059857 feat(esb): ESB endpoint types, table API client and mocks (#8)
Types mirror the /qqq/v1/esb contract; getEsbForTable returns null on 403/404.

Part of QRun-IO/qqq#739
2026-09-25 19:24:16 -05:00
14 changed files with 1158 additions and 1 deletions

No files matched your search

@@ -0,0 +1,129 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the table Developer view's ESB section wiring
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider } from '@/lib/context/q-context'
import { queryClient, queryKeys } from '@/lib/query-client'
import { server } from '@/mocks/node'
import TableDeveloperViewPage from './page'
const { params } = vi.hoisted(() => ({ params: { slug: 'order' } }))
vi.mock('next/navigation', () => ({
useParams: () => params,
usePathname: () => `/app/${params.slug}/dev`,
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('sonner', async (importOriginal) => ({
...(await importOriginal<typeof import('sonner')>()),
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
}))
/**
* Renders the page with a query client.
*
* @param client - The query client; a fresh one without retries by default.
* @returns The query client, for inspecting query state.
*/
function renderPage(
client: QueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
): QueryClient {
render(
<QueryClientProvider client={client}>
<QContextProvider>
<TableDeveloperViewPage />
</QContextProvider>
</QueryClientProvider>
)
return client
}
/**
* Waits until the table's metadata has rendered and its ESB request has settled.
*
* @param client - The page's query client.
* @param tableName - The table shown.
*/
async function settled(client: QueryClient, tableName: string): Promise<void> {
await screen.findByText('Fields')
await waitFor(() =>
expect(client.getQueryState(queryKeys.esbTable(tableName))?.status).toBe('success')
)
}
describe('TableDeveloperViewPage ESB section', () => {
beforeEach(() => {
params.slug = 'order'
vi.clearAllMocks()
})
afterEach(() => {
queryClient.clear()
})
it('shows the ESB section returned for the table', async () => {
renderPage()
expect(
await screen.findByRole('heading', { name: 'Enterprise Service Bus' })
).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Publications' })).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Subscribers' })).toBeInTheDocument()
})
it('omits the section when the user may not read the table', async () => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.json({ error: 'Permission denied.' }, { status: 403 })
)
)
const client = renderPage()
await settled(client, 'order')
expect(
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
).not.toBeInTheDocument()
})
it('omits the section when the table has no ESB metadata', async () => {
params.slug = 'person'
const client = renderPage()
await settled(client, 'person')
expect(
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
).not.toBeInTheDocument()
})
it('omits the section without a toast when a backend without the ESB module answers with its SPA page', async () => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.html('<!doctype html><html><body>SPA</body></html>')
)
)
const client = renderPage(queryClient)
await settled(client, 'order')
expect(
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
).not.toBeInTheDocument()
expect(toast.error).not.toHaveBeenCalled()
})
})
+13 -1
View File
@@ -15,7 +15,8 @@
*/
/**
* @file TableDeveloperView page — shows raw table metadata as formatted JSON for debugging field definitions and permissions.
* @file TableDeveloperView page — shows raw table metadata as formatted JSON for debugging field definitions and permissions,
* and the table's ESB publications and subscribers when it has any.
*/
'use client'
@@ -27,7 +28,9 @@ import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadTableMetaData } from '@/lib/api/metadata'
import { getEsbForTable } from '@/lib/api/esb'
import { queryKeys } from '@/lib/query-client'
import { EsbSection } from '@/components/esb/EsbSection'
/**
* Renders a developer debug view for a QQQ table, showing summary statistics
@@ -35,6 +38,8 @@ import { queryKeys } from '@/lib/query-client'
*
* @returns A composed page that assembles:
* - A 4-column stats grid (`<MetaStat>` cards: field count, section count, permissions, primary key)
* - An `<EsbSection>` with the table's ESB publications and subscribers, omitted when the
* table has none to show (403, 404, or a backend without the ESB module)
* - A collapsible `<JsonBlock>` panel rendering the full table metadata as formatted JSON
* - A loading spinner while metadata is fetching, and a destructive error panel on failure
*/
@@ -53,6 +58,11 @@ export default function TableDeveloperViewPage() {
staleTime: 1000 * 60 * 5,
})
const { data: esb } = useQuery({
queryKey: queryKeys.esbTable(slug),
queryFn: () => getEsbForTable(slug),
})
return (
<div className="space-y-6" data-qqq-id={`table-dev-${slug}`}>
<div className="flex items-center gap-3">
@@ -101,6 +111,8 @@ export default function TableDeveloperViewPage() {
<MetaStat label="Primary Key" value={data.primaryKeyField ?? '—'} />
</div>
<EsbSection data={esb ?? null} />
{/* Raw JSON */}
<JsonBlock label="Full Table Metadata" value={data} defaultOpen={false} />
</div>
+80
View File
@@ -0,0 +1,80 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EsbCounters — compact list of a destination's or trigger's per-node ESB counters.
*/
import React from 'react'
import type { EsbCounter } from '@/types'
import { formatDateTime } from '@/lib/utils/datetime-utils'
/** Which side of a destination the counters describe. */
export type EsbCountersKind = 'publish' | 'consume'
/** Numeric counts shown for each kind, with their labels. */
const COUNTS: Record<EsbCountersKind, ReadonlyArray<readonly [keyof EsbCounter, string]>> = {
publish: [
['published', 'Published'],
['publishFailures', 'Publish failures'],
],
consume: [
['consumed', 'Consumed'],
['succeeded', 'Succeeded'],
['failed', 'Failed'],
['retried', 'Retried'],
['deadLettered', 'Dead-lettered'],
['inFlight', 'In flight'],
],
}
/**
* Renders counters as a wrapping description list: publish counts for a publication,
* consume counts and processing times for a trigger, then last activity and last error.
*
* @param props - Component props.
* @param props.counters - The counter snapshot.
* @param props.kind - `publish` for a publication's destination, `consume` for a trigger.
* @returns The counters as `<dl>` term/value pairs.
*/
export function EsbCounters({ counters, kind }: { counters: EsbCounter; kind: EsbCountersKind }) {
const items = COUNTS[kind].map(([key, label]) => [label, String(counters[key])])
if (kind === 'consume') {
items.push(
['Avg time', `${Math.round(counters.avgMs)} ms`],
['Max time', `${Math.round(counters.maxMs)} ms`]
)
}
items.push(['Last activity', formatDateTime(counters.lastActivity) ?? '—'])
return (
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{items.map(([label, value]) => (
<div key={label} className="flex gap-1">
<dt className="text-muted-foreground">{label}</dt>
<dd className="font-medium tabular-nums text-foreground">{value}</dd>
</div>
))}
{counters.lastError && (
<div className="flex basis-full gap-1">
<dt className="text-muted-foreground">Last error</dt>
<dd className="break-words text-destructive">{counters.lastError}</dd>
</div>
)}
</dl>
)
}
+135
View File
@@ -0,0 +1,135 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the table Developer-view ESB section
import React from 'react'
import { act, render, screen, within } from '@testing-library/react'
import { axe } from 'jest-axe'
import { describe, expect, it } from 'vitest'
import { orderEsb } from '@/mocks/fixtures/esb'
import { EsbSection } from './EsbSection'
/**
* Reads the value of one counter inside a row.
*
* @param scope - The row (or cell) holding the counters.
* @param label - The counter's label.
* @returns The counter's displayed value.
*/
function counter(scope: HTMLElement, label: string): string | null | undefined {
return within(scope).getByText(label, { selector: 'dt' }).nextElementSibling?.textContent
}
/**
* Returns the body rows of the table with the given accessible name.
*
* @param name - The table's accessible name.
* @returns Its rows, excluding the header row.
*/
function bodyRows(name: string): HTMLElement[] {
return within(screen.getByRole('table', { name })).getAllByRole('row').slice(1)
}
describe('EsbSection', () => {
it('renders nothing when the table has no ESB section', () => {
const { container } = render(<EsbSection data={null} />)
expect(container).toBeEmptyDOMElement()
})
it('lists publications with destination, type, events, counters and queue depth', () => {
render(<EsbSection data={orderEsb} />)
expect(screen.getByRole('heading', { name: 'Enterprise Service Bus' })).toBeInTheDocument()
const [topic, queue] = bodyRows('Publications')
expect(bodyRows('Publications')).toHaveLength(2)
const topicCells = within(topic).getAllByRole('cell')
expect(topicCells[0]).toHaveTextContent('orderEvents')
expect(topicCells[1]).toHaveTextContent('Topic')
expect(topicCells[2]).toHaveTextContent('INSERT, UPDATE, DELETE')
expect(counter(topic, 'Published')).toBe('128')
expect(counter(topic, 'Publish failures')).toBe('2')
const queueCells = within(queue).getAllByRole('cell')
expect(queueCells[0]).toHaveTextContent('orderFulfillment')
expect(queueCells[1]).toHaveTextContent('Queue')
expect(queueCells[2]).toHaveTextContent('INSERT')
expect(counter(queue, 'Published')).toBe('40')
expect(queueCells[4]).toHaveTextContent('7')
})
it('shows "—" for queue depth when the broker reports no queue info', () => {
render(<EsbSection data={orderEsb} />)
const [topic, queue] = bodyRows('Publications')
expect(within(topic).getAllByRole('cell')[4]).toHaveTextContent(/^—$/)
expect(within(queue).getAllByRole('cell')[4]).toHaveTextContent(/^7$/)
})
it('lists subscribers with process link, state, counters and dead-letter count', () => {
render(<EsbSection data={orderEsb} />)
const [fulfill, cancel] = bodyRows('Subscribers')
expect(bodyRows('Subscribers')).toHaveLength(2)
const fulfillCells = within(fulfill).getAllByRole('cell')
expect(within(fulfillCells[0]).getByRole('link', { name: 'Fulfill Order' })).toHaveAttribute(
'href',
'/app/fulfillOrder'
)
expect(fulfillCells[0]).toHaveTextContent('Single, concurrency 2, 3 attempts')
expect(fulfillCells[1]).toHaveTextContent('orderFulfillment')
expect(fulfillCells[2]).toHaveTextContent('Running')
expect(counter(fulfill, 'Consumed')).toBe('33')
expect(counter(fulfill, 'Succeeded')).toBe('30')
expect(counter(fulfill, 'Failed')).toBe('3')
expect(counter(fulfill, 'Retried')).toBe('4')
expect(counter(fulfill, 'Dead-lettered')).toBe('1')
expect(counter(fulfill, 'In flight')).toBe('1')
expect(counter(fulfill, 'Avg time')).toBe('120 ms')
expect(counter(fulfill, 'Max time')).toBe('950 ms')
expect(counter(fulfill, 'Last error')).toBe('Warehouse API timed out')
expect(fulfillCells[4]).toHaveTextContent(/^1$/)
const cancelCells = within(cancel).getAllByRole('cell')
expect(within(cancelCells[0]).getByRole('link', { name: 'Cancel Order' })).toHaveAttribute(
'href',
'/app/cancelOrder'
)
expect(cancelCells[1]).toHaveTextContent('orderEvents')
expect(cancelCells[2]).toHaveTextContent('Paused')
expect(within(cancel).queryByText('Last error')).not.toBeInTheDocument()
expect(cancelCells[4]).toHaveTextContent(/^—$/)
})
it('says so when there are no publications or subscribers', () => {
render(<EsbSection data={{ ...orderEsb, publications: [], subscribers: [] }} />)
expect(screen.queryByRole('table')).not.toBeInTheDocument()
expect(screen.getByText('This table does not publish to any destination.')).toBeInTheDocument()
expect(
screen.getByText('No processes you can access are triggered by these destinations.')
).toBeInTheDocument()
})
it('has no accessibility violations', async () => {
const { container } = render(<EsbSection data={orderEsb} />)
// next/link updates its prefetch state while axe runs; act() flushes it.
let results: Awaited<ReturnType<typeof axe>> | undefined
await act(async () => {
results = await axe(container)
})
expect(results).toHaveNoViolations()
})
})
+151
View File
@@ -0,0 +1,151 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EsbSection — the ESB part of a table's Developer view: the destinations the table
* publishes to and the processes those destinations trigger, with per-node counters.
*/
import React, { useId } from 'react'
import { Network } from 'lucide-react'
import type { EsbDestinationType, EsbTableResponse } from '@/types'
import { EsbCounters } from './EsbCounters'
import { EsbTriggerRow } from './EsbTriggerRow'
/** Label for each destination type. */
const DESTINATION_TYPES: Record<EsbDestinationType, string> = { QUEUE: 'Queue', TOPIC: 'Topic' }
/** Props for {@link EsbSection}. */
interface EsbSectionProps {
/** The table's ESB data, or `null` when the table has none or the user may not see it. */
data: EsbTableResponse | null
}
/**
* Renders a table's ESB publications and subscribers, or nothing when `data` is `null`.
*
* @param props - Component props.
* @param props.data - Response of `GET /esb/table/{table}`, or `null`.
* @returns A section with a Publications table (destination, type, events, counters, queue
* depth) and a Subscribers table (process, destination, state, counters, dead letters),
* each replaced by a sentence when empty; or `null`.
*/
export function EsbSection({ data }: EsbSectionProps) {
const headingId = useId()
if (!data) return null
return (
<section
aria-labelledby={headingId}
className="space-y-4 rounded-xl border border-border bg-card p-4"
data-qqq-id={`esb-section-${data.table}`}
>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
<h3 id={headingId} className="text-lg font-semibold text-foreground">
Enterprise Service Bus
</h3>
</div>
<EsbList
title="Publications"
columns={['Destination', 'Type', 'Events', 'Counters', 'Queue depth']}
empty="This table does not publish to any destination."
>
{data.publications.map(({ destination, events }) => (
<tr
key={destination.name}
className="border-b border-border/50 align-top last:border-0"
data-qqq-id={`esb-publication-${destination.name}`}
>
<td className="px-2 py-2 font-mono text-xs text-foreground">{destination.name}</td>
<td className="px-2 py-2 text-foreground">
{DESTINATION_TYPES[destination.type] ?? destination.type}
</td>
<td className="px-2 py-2 text-foreground">{events.join(', ')}</td>
<td className="px-2 py-2">
<EsbCounters counters={destination.counters} kind="publish" />
</td>
<td className="px-2 py-2 tabular-nums text-foreground">
{destination.queueInfo ? destination.queueInfo.messageCount : '—'}
</td>
</tr>
))}
</EsbList>
<EsbList
title="Subscribers"
columns={['Process', 'Destination', 'State', 'Counters', 'Dead letters']}
empty="No processes you can access are triggered by these destinations."
>
{data.subscribers.map((trigger) => (
<EsbTriggerRow key={trigger.name} trigger={trigger} />
))}
</EsbList>
</section>
)
}
/**
* A titled table of ESB rows, or a sentence when there are none.
*
* @param props - Component props.
* @param props.title - Heading, also the table's accessible name.
* @param props.columns - Column headings.
* @param props.empty - Sentence shown when there are no rows.
* @param props.children - The table rows.
* @returns The heading followed by the table or the empty sentence.
*/
function EsbList({
title,
columns,
empty,
children,
}: {
title: string
columns: string[]
empty: string
children: React.ReactNode[]
}) {
return (
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">{title}</h4>
{children.length === 0 ? (
<p className="text-sm text-muted-foreground">{empty}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label={title}>
<thead>
<tr className="border-b border-border text-left">
{columns.map((column) => (
<th
key={column}
scope="col"
className="whitespace-nowrap px-2 py-1.5 font-medium text-muted-foreground"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
)}
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EsbTriggerRow — one table row for a process triggered by an ESB destination.
*/
import React from 'react'
import Link from 'next/link'
import type { EsbTrigger, EsbTriggerMode, EsbTriggerState } from '@/types'
import { CHIP_COLOR_CLASSES, type ChipColor } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { EsbCounters } from './EsbCounters'
/** Label and chip color for each trigger state. */
const STATES: Record<EsbTriggerState, { label: string; color: ChipColor }> = {
RUNNING: { label: 'Running', color: 'success' },
PAUSED: { label: 'Paused', color: 'warning' },
CONNECTING: { label: 'Connecting', color: 'info' },
STOPPED: { label: 'Stopped', color: 'default' },
}
/** Label for each trigger mode. */
const MODES: Record<EsbTriggerMode, string> = { SINGLE: 'Single', BATCH: 'Batch' }
/**
* Renders a trigger as a row of five cells: the process (linked, with its mode, concurrency
* and attempts), the destination it consumes, its state, its counters, and the number of
* messages in its dead-letter queue (`—` when the broker cannot report it).
*
* @param props - Component props.
* @param props.trigger - The trigger to show.
* @returns A `<tr>` for a table with Process, Destination, State, Counters and Dead letters columns.
*/
export function EsbTriggerRow({ trigger }: { trigger: EsbTrigger }) {
const state = STATES[trigger.state] ?? { label: trigger.state, color: 'default' }
const attempts = `${trigger.maxAttempts} ${trigger.maxAttempts === 1 ? 'attempt' : 'attempts'}`
return (
<tr
className="border-b border-border/50 align-top last:border-0"
data-qqq-id={`esb-trigger-${trigger.name}`}
>
<td className="px-2 py-2">
<Link
href={`/app/${encodeURIComponent(trigger.processName)}`}
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
>
{trigger.processLabel}
</Link>
<p className="text-xs text-muted-foreground">
{MODES[trigger.mode] ?? trigger.mode}, concurrency {trigger.concurrency}, {attempts}
</p>
</td>
<td className="px-2 py-2 font-mono text-xs text-foreground">{trigger.destination.name}</td>
<td className="px-2 py-2">
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
CHIP_COLOR_CLASSES[state.color]
)}
>
{state.label}
</span>
</td>
<td className="px-2 py-2">
<EsbCounters counters={trigger.counters} kind="consume" />
</td>
<td className="px-2 py-2 tabular-nums text-foreground">
{trigger.deadLetter.messageCount ?? '—'}
</td>
</tr>
)
}
+90
View File
@@ -0,0 +1,90 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the ESB API client
import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { orderEsb } from '@/mocks/fixtures/esb'
import { server } from '@/mocks/node'
import { getEsbForTable } from './esb'
describe('getEsbForTable', () => {
it('returns the table ESB section from GET /qqq/v1/esb/table/{table}', async () => {
let path = ''
server.use(
http.get('/qqq/v1/esb/table/:table', ({ request }) => {
path = new URL(request.url).pathname
return HttpResponse.json(orderEsb)
})
)
await expect(getEsbForTable('order')).resolves.toEqual(orderEsb)
expect(path).toBe('/qqq/v1/esb/table/order')
})
it('URL-encodes the table name', async () => {
let path = ''
server.use(
http.get('/qqq/v1/esb/table/:table', ({ request }) => {
path = new URL(request.url).pathname
return HttpResponse.json({ ...orderEsb, table: 'my table' })
})
)
await getEsbForTable('my table')
expect(path).toBe('/qqq/v1/esb/table/my%20table')
})
it.each([403, 404])('returns null on %i', async (status) => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.json({ error: 'Permission denied.' }, { status })
)
)
await expect(getEsbForTable('order')).resolves.toBeNull()
})
it('rethrows other failures with the backend message', async () => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.json({ error: 'Broker management API unavailable' }, { status: 500 })
)
)
await expect(getEsbForTable('order')).rejects.toThrow('Broker management API unavailable')
})
it('returns null for the SPA index page a backend without the ESB module answers', async () => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.html('<!doctype html><html><body>SPA</body></html>')
)
)
await expect(getEsbForTable('order')).resolves.toBeNull()
})
it.each([
['an empty body', ''],
['a JSON object without publications and subscribers', { error: 'unexpected' }],
['a JSON array', []],
])('returns null for %s', async (_label, body) => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
typeof body === 'string' ? HttpResponse.text(body) : HttpResponse.json(body)
)
)
await expect(getEsbForTable('order')).resolves.toBeNull()
})
})
+59
View File
@@ -0,0 +1,59 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file ESB API — permission-checked `/qqq/v1/esb` endpoints served by the `qqq-esb` module.
*/
import type { EsbTableResponse } from '@/types'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient from './client'
/**
* Loads a table's ESB publications and subscribing triggers from `GET /esb/table/{table}`.
*
* With the `qqq-esb` module installed, the backend answers 403 when the user may not read the
* table and 404 when the table has no ESB metadata. Without the module the route does not exist,
* and the default Javalin root-SPA not-found handler answers 200 with the SPA's `index.html`.
* All three mean there is nothing to show, so none of them is an error.
*
* @param tableName - Backend table name.
* @returns The table's ESB section, or `null` on 403, on 404, or when a 2xx body is not an ESB table object.
* @throws The request error for any other failure.
*/
export async function getEsbForTable(tableName: string): Promise<EsbTableResponse | null> {
let data: unknown
try {
data = await apiClient.get<unknown>(`/esb/table/${encodeURIComponent(tableName)}`)
} catch (error) {
const status = getErrorStatusCode(error)
if (status === 403 || status === 404) return null
throw error
}
return isEsbTableResponse(data) ? data : null
}
/**
* Whether a response body has the shape of an ESB table response.
*
* @param data - The parsed response body.
* @returns True for an object with `publications` and `subscribers` arrays.
*/
function isEsbTableResponse(data: unknown): data is EsbTableResponse {
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
const body = data as Partial<Record<keyof EsbTableResponse, unknown>>
return Array.isArray(body.publications) && Array.isArray(body.subscribers)
}
+16
View File
@@ -176,6 +176,7 @@ export const queryClient = new QueryClient({
* - `['qqq', 'widgets', widgetName, ...]` — widget data queries
* - `['qqq', 'possibleValues', ...]` — possible-value / enum queries
* - `['qqq', 'search', ...]` — global search queries
* - `['qqq', 'esb', ...]` — ESB publications, triggers and counters
*/
export const queryKeys = {
/**
@@ -335,6 +336,21 @@ export const queryKeys = {
audits: (tableName: string, primaryKey: string | number) =>
[...queryKeys.tableRecord(tableName, primaryKey), 'audits'] as const,
// ESB
/**
* Key for all ESB queries.
*
* @returns The ESB namespace query key tuple.
*/
esb: () => [...queryKeys.all(), 'esb'] as const,
/**
* Key for a table's ESB publications and subscribers.
*
* @param tableName - Backend table name.
* @returns The table ESB query key tuple.
*/
esbTable: (tableName: string) => [...queryKeys.esb(), 'table', tableName] as const,
// Search
/**
* Key for all global search queries.
+127
View File
@@ -0,0 +1,127 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file ESB fixture data — `GET /qqq/v1/esb/table/order` for the demo `order` table: a topic
* without broker management data, a managed queue, and one trigger on each.
*/
import type { EsbCapabilities, EsbCounter, EsbDestination, EsbTableResponse } from '@/types'
/**
* Builds a counter snapshot with every count at zero.
*
* @param overrides - Counts that differ from zero.
* @returns A complete counter snapshot.
*/
function counter(overrides: Partial<EsbCounter> = {}): EsbCounter {
return {
published: 0,
publishFailures: 0,
consumed: 0,
succeeded: 0,
failed: 0,
retried: 0,
deadLettered: 0,
inFlight: 0,
lastActivity: null,
avgMs: 0,
maxMs: 0,
lastError: null,
...overrides,
}
}
const artemisCapabilities: EsbCapabilities = {
browse: true,
pauseQueue: true,
purge: true,
deleteSelected: true,
deleteOlderThan: true,
move: true,
}
const orderEvents: EsbDestination = {
name: 'orderEvents',
type: 'TOPIC',
provider: 'artemis',
brokerName: 'orderEvents',
counters: counter({ published: 128, publishFailures: 2, lastActivity: '2026-09-25T12:00:00Z' }),
queueInfo: null,
capabilities: artemisCapabilities,
}
const orderFulfillment: EsbDestination = {
name: 'orderFulfillment',
type: 'QUEUE',
provider: 'artemis',
brokerName: 'orderFulfillment',
counters: counter({ published: 40, lastActivity: '2026-09-25T11:58:30Z' }),
queueInfo: { messageCount: 7, consumerCount: 2, paused: false },
capabilities: artemisCapabilities,
}
export const orderEsb: EsbTableResponse = {
table: 'order',
publications: [
{ destination: orderEvents, events: ['INSERT', 'UPDATE', 'DELETE'] },
{ destination: orderFulfillment, events: ['INSERT'] },
],
subscribers: [
{
name: 'fulfillOrder.orderFulfillment',
processName: 'fulfillOrder',
processLabel: 'Fulfill Order',
destination: orderFulfillment,
mode: 'SINGLE',
concurrency: 2,
maxAttempts: 3,
state: 'RUNNING',
counters: counter({
consumed: 33,
succeeded: 30,
failed: 3,
retried: 4,
deadLettered: 1,
inFlight: 1,
lastActivity: '2026-09-25T11:58:31Z',
avgMs: 120,
maxMs: 950,
lastError: 'Warehouse API timed out',
}),
deadLetter: { brokerName: 'orderFulfillment.dlq', messageCount: 1 },
},
{
name: 'cancelOrder.orderEvents',
processName: 'cancelOrder',
processLabel: 'Cancel Order',
destination: orderEvents,
mode: 'BATCH',
concurrency: 1,
maxAttempts: 5,
state: 'PAUSED',
counters: counter({
consumed: 12,
succeeded: 12,
lastActivity: '2026-09-25T10:15:00Z',
avgMs: 45,
maxMs: 80,
}),
deadLetter: { brokerName: 'orderEvents.cancelOrder.orderEvents.dlq', messageCount: null },
},
],
permissions: { canOperate: false, canDelete: false },
}
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file MSW handlers for the ESB endpoints (`GET /qqq/v1/esb/table/:tableName`).
*/
import { http, HttpResponse } from 'msw'
import type { EsbTableResponse } from '@/types'
import { orderEsb } from '../fixtures/esb'
const BASE = '/qqq/v1'
const esbTables: Record<string, EsbTableResponse> = {
order: orderEsb,
}
export const esbHandlers = [
// GET /esb/table/:tableName — 404 for tables without ESB metadata, as the backend answers
http.get(`${BASE}/esb/table/:tableName`, ({ params }) => {
const { tableName } = params as { tableName: string }
const data = esbTables[tableName]
if (!data) {
return HttpResponse.json(
{ error: `Table '${tableName}' has no ESB metadata` },
{ status: 404 }
)
}
return HttpResponse.json(data)
}),
]
+2
View File
@@ -24,6 +24,7 @@ import { tableHandlers } from './tables'
import { possibleValuesHandlers } from './possible-values'
import { processHandlers } from './processes'
import { widgetHandlers } from './widgets'
import { esbHandlers } from './esb'
export const handlers = [
...authHandlers,
@@ -33,4 +34,5 @@ export const handlers = [
...tableHandlers,
...processHandlers,
...widgetHandlers,
...esbHandlers,
]
+198
View File
@@ -0,0 +1,198 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file ESB types — mirror the `/qqq/v1/esb` endpoint contract served by the `qqq-esb` module
* (QRun-IO/qqq#739). Field names and shapes match the JSON exactly.
*/
/** Per-node activity counters for a destination or trigger. */
export interface EsbCounter {
published: number
publishFailures: number
consumed: number
succeeded: number
failed: number
retried: number
deadLettered: number
inFlight: number
/** ISO-8601 instant of the last activity, or `null` when there has been none. */
lastActivity: string | null
avgMs: number
maxMs: number
lastError: string | null
}
/** Whether a destination is a point-to-point queue or a publish/subscribe topic. */
export type EsbDestinationType = 'QUEUE' | 'TOPIC'
/** Broker-reported queue state, available when the provider has a management URL. */
export interface EsbQueueInfo {
messageCount: number
consumerCount: number
paused: boolean
}
/** Management actions the destination's broker adapter supports. */
export interface EsbCapabilities {
browse: boolean
pauseQueue: boolean
purge: boolean
deleteSelected: boolean
deleteOlderThan: boolean
move: boolean
}
/** A queue or topic configured in QQQ metadata. */
export interface EsbDestination {
name: string
type: EsbDestinationType
/** Name of the ESB provider the destination lives on. */
provider: string
/** Broker-side destination name. */
brokerName: string
counters: EsbCounter
/** Broker queue state, or `null` when the broker has no management API configured. */
queueInfo: EsbQueueInfo | null
capabilities: EsbCapabilities
}
/** How a trigger hands messages to its process. */
export type EsbTriggerMode = 'SINGLE' | 'BATCH'
/** Runtime state of a trigger's consumers on this node. */
export type EsbTriggerState = 'RUNNING' | 'PAUSED' | 'CONNECTING' | 'STOPPED'
/** A trigger's dead-letter queue. */
export interface EsbDeadLetter {
brokerName: string
/** Messages waiting in the dead-letter queue, or `null` when the broker cannot report it. */
messageCount: number | null
}
/** A process triggered by messages on a destination. */
export interface EsbTrigger {
/** `<processName>.<destinationName>`. */
name: string
processName: string
processLabel: string
destination: EsbDestination
mode: EsbTriggerMode
concurrency: number
maxAttempts: number
state: EsbTriggerState
counters: EsbCounter
deadLetter: EsbDeadLetter
}
/** What the current user may do with the ESB objects in a response. */
export interface EsbPermissions {
canOperate: boolean
canDelete: boolean
}
/** Record events a table can publish. */
export type EsbTableEvent = 'INSERT' | 'UPDATE' | 'DELETE'
/** Lifecycle events a process can publish. */
export type EsbProcessEvent = 'STARTED' | 'COMPLETED' | 'FAILED'
/** A destination a table or process publishes to, and the events it sends there. */
export interface EsbPublication<E extends string = string> {
destination: EsbDestination
events: E[]
}
/** `GET /qqq/v1/esb/table/{table}`. */
export interface EsbTableResponse {
table: string
publications: EsbPublication<EsbTableEvent>[]
/** Triggers consuming this table's destinations, limited to processes the user can access. */
subscribers: EsbTrigger[]
permissions: EsbPermissions
}
/** `GET /qqq/v1/esb/process/{process}`. */
export interface EsbProcessResponse {
process: string
publications: EsbPublication<EsbProcessEvent>[]
triggers: EsbTrigger[]
permissions: EsbPermissions
}
/** Supported broker types. */
export type EsbProviderType = 'ACTIVEMQ_ARTEMIS' | 'RABBITMQ'
/** A configured broker connection. */
export interface EsbProvider {
name: string
type: EsbProviderType
connected: boolean
managementEnabled: boolean
}
/** A table or process that publishes to a destination. */
export interface EsbPublisher {
kind: 'TABLE' | 'PROCESS'
name: string
events: string[]
}
/** A destination with everything that publishes to and consumes from it. */
export interface EsbOverviewDestination extends EsbDestination {
publishers: EsbPublisher[]
triggers: EsbTrigger[]
}
/** `GET /qqq/v1/esb/overview`. */
export interface EsbOverviewResponse {
providers: EsbProvider[]
destinations: EsbOverviewDestination[]
permissions: EsbPermissions
}
/** A CloudEvents 1.0 structured event as QQQ publishes it. */
export interface EsbCloudEvent {
specversion: string
id: string
source: string
type: string
time?: string
subject?: string
datacontenttype?: string
data?: unknown
/** Extension attribute: `id` of the event whose triggered run produced this one. */
qqqcausationid?: string
}
/** One message browsed from a queue or dead-letter queue. */
export interface EsbMessage {
messageId: string
timestamp: string
deliveryCount: number
/** The parsed CloudEvent, or `null` when the body is not one. */
event: EsbCloudEvent | null
/** The message body, truncated to 10,000 characters. */
rawBody: string
/** JMS properties, including `qqqError` and related properties on dead letters. */
properties: Record<string, unknown>
}
/** `GET /qqq/v1/esb/deadLetters/{trigger}` and `GET /qqq/v1/esb/messages/{destination}`. */
export interface EsbMessagePage {
messages: EsbMessage[]
hasMore: boolean
}
+26
View File
@@ -96,3 +96,29 @@ export type {
BlockData,
BlockWidgetData,
} from './widgets'
export type {
EsbCounter,
EsbDestinationType,
EsbQueueInfo,
EsbCapabilities,
EsbDestination,
EsbTriggerMode,
EsbTriggerState,
EsbDeadLetter,
EsbTrigger,
EsbPermissions,
EsbTableEvent,
EsbProcessEvent,
EsbPublication,
EsbTableResponse,
EsbProcessResponse,
EsbProviderType,
EsbProvider,
EsbPublisher,
EsbOverviewDestination,
EsbOverviewResponse,
EsbCloudEvent,
EsbMessage,
EsbMessagePage,
} from './esb'