feat(sprint-2): UBA-006 UBAPanel + UBA-007 DebugStreamView

UBA-006 — UBAPanel (views/panels/UBAPanel):
- useUBASchema hook: loads schema URL from project metadata, fetches + parses
- SchemaLoader UI: URL input with Enter/click + error banner
- ConfigPanel wired with onSave: stores in project metadata + UBAClient.configure()
- useEventListener for importComplete/instanceHasChanged

UBA-007 — DebugStreamView:
- SSE viewer via UBAClient.openDebugStream()
- Connect/Disconnect toggle, Clear, auto-scroll + manual override
- Max 500 events (oldest dropped), per-type colour coding
- Jump to latest sticky button

UBAPanel.module.scss: all design tokens, no hardcoded colors

Tech note: TS doesn't narrow ParseResult<T> discriminated unions inside
async IIFEs — explicit cast with inline FailResult type as workaround.
This commit is contained in:
Richard Osborne
2026-02-18 20:12:33 +01:00
parent 7bd9b4c3e6
commit ed16302812
4 changed files with 788 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
/**
* UBA-007: DebugStreamView
*
* SSE-based live debug log viewer for UBA backends.
* Connects to the backend's debug_stream endpoint via UBAClient.openDebugStream()
* and renders a scrollable, auto-scrolling event log.
*
* Features:
* - Connect / Disconnect toggle button
* - Auto-scroll to bottom on new events (can be overridden by manual scroll)
* - Max 500 events in memory (oldest are dropped)
* - Clear button to reset the log
* - Per-event type colour coding (log/info/warn/error)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AuthConfig } from '@noodl-models/UBA/types';
import { UBAClient, type DebugEvent, type DebugStreamHandle } from '../../../services/UBA/UBAClient';
import css from './UBAPanel.module.scss';
const MAX_EVENTS = 500;
export interface DebugStreamViewProps {
endpoint: string;
auth?: AuthConfig;
credentials?: { token?: string; username?: string; password?: string };
}
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
function eventTypeClass(type: string): string {
switch (type) {
case 'error':
return css.eventError;
case 'warn':
return css.eventWarn;
case 'info':
return css.eventInfo;
case 'metric':
return css.eventMetric;
default:
return css.eventLog;
}
}
function formatEventData(data: unknown): string {
if (typeof data === 'string') return data;
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
export function DebugStreamView({ endpoint, auth, credentials }: DebugStreamViewProps) {
const [events, setEvents] = useState<DebugEvent[]>([]);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [statusMsg, setStatusMsg] = useState<string>('');
const [autoScroll, setAutoScroll] = useState(true);
const handleRef = useRef<DebugStreamHandle | null>(null);
const logRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new events arrive
useEffect(() => {
if (autoScroll && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [events, autoScroll]);
const connect = useCallback(() => {
if (handleRef.current) return; // already connected
setStatus('connecting');
setStatusMsg('');
handleRef.current = UBAClient.openDebugStream(
endpoint,
{
onOpen: () => {
setStatus('connected');
setStatusMsg('');
},
onEvent: (event) => {
setEvents((prev) => {
const next = [...prev, event];
return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
});
},
onError: (err) => {
setStatus('error');
setStatusMsg(err.message);
handleRef.current = null;
}
},
auth,
credentials
);
}, [endpoint, auth, credentials]);
const disconnect = useCallback(() => {
handleRef.current?.close();
handleRef.current = null;
setStatus('disconnected');
setStatusMsg('');
}, []);
// Clean up on unmount or endpoint change
useEffect(() => {
return () => {
handleRef.current?.close();
handleRef.current = null;
};
}, [endpoint]);
const handleScrollLog = useCallback(() => {
const el = logRef.current;
if (!el) return;
// If user scrolled up more than 40px from bottom, disable auto-scroll
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setAutoScroll(distFromBottom < 40);
}, []);
const isConnected = status === 'connected';
return (
<div className={css.debugStream}>
{/* Toolbar */}
<div className={css.debugToolbar}>
<span className={`${css.statusDot} ${css[`statusDot_${status}`]}`} aria-hidden="true" />
<span className={css.statusLabel}>
{status === 'connecting'
? 'Connecting…'
: status === 'connected'
? 'Live'
: status === 'error'
? 'Error'
: 'Disconnected'}
</span>
{statusMsg && <span className={css.statusDetail}>{statusMsg}</span>}
<div className={css.debugToolbarSpacer} />
<button type="button" className={css.clearBtn} onClick={() => setEvents([])} disabled={events.length === 0}>
Clear
</button>
<button
type="button"
className={isConnected ? css.disconnectBtn : css.connectBtn}
onClick={isConnected ? disconnect : connect}
disabled={status === 'connecting'}
>
{isConnected ? 'Disconnect' : 'Connect'}
</button>
</div>
{/* Event log */}
<div ref={logRef} className={css.debugLog} onScroll={handleScrollLog}>
{events.length === 0 ? (
<div className={css.debugEmpty}>
{isConnected ? 'Waiting for events…' : 'Connect to start receiving events.'}
</div>
) : (
events.map((event, i) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className={`${css.debugEvent} ${eventTypeClass(event.type)}`}
>
<span className={css.debugEventTime}>
{event.receivedAt.toLocaleTimeString(undefined, { hour12: false })}
</span>
<span className={css.debugEventType}>{event.type.toUpperCase()}</span>
<pre className={css.debugEventData}>{formatEventData(event.data)}</pre>
</div>
))
)}
</div>
{/* Auto-scroll indicator */}
{!autoScroll && (
<button
type="button"
className={css.scrollToBottomBtn}
onClick={() => {
setAutoScroll(true);
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}}
>
Jump to latest
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
// UBAPanel + DebugStreamView styles
// All colors use CSS design tokens — no hardcoded values
// ─── Schema Loader ────────────────────────────────────────────────────────────
.schemaLoader {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.schemaLoaderHint {
margin: 0;
font-size: 12px;
color: var(--theme-color-fg-default-shy);
line-height: 1.5;
}
.schemaLoaderRow {
display: flex;
gap: 8px;
}
.schemaLoaderInput {
flex: 1;
height: 28px;
padding: 0 8px;
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 12px;
font-family: monospace;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
&:disabled {
opacity: 0.5;
}
}
.schemaLoaderBtn {
height: 28px;
padding: 0 12px;
background: var(--theme-color-primary);
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.schemaLoaderError {
margin: 0;
font-size: 11px;
color: var(--theme-color-danger, #e05454);
}
// ─── Status / Error states ────────────────────────────────────────────────────
.statusMsg {
padding: 24px 16px;
font-size: 12px;
color: var(--theme-color-fg-default-shy);
text-align: center;
}
.errorState {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.errorMsg {
margin: 0;
font-size: 12px;
color: var(--theme-color-danger, #e05454);
}
.clearBtn {
align-self: flex-start;
height: 24px;
padding: 0 10px;
background: transparent;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 11px;
cursor: pointer;
&:hover {
border-color: var(--theme-color-fg-default);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
// ─── Debug Stream ─────────────────────────────────────────────────────────────
.debugStream {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.debugToolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--theme-color-border-default);
flex-shrink: 0;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.statusDot_connected {
background: var(--theme-color-success, #4ade80);
}
&.statusDot_connecting {
background: var(--theme-color-warning, #facc15);
animation: pulse 1s infinite;
}
&.statusDot_error {
background: var(--theme-color-danger, #e05454);
}
&.statusDot_disconnected {
background: var(--theme-color-fg-default-shy);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.statusLabel {
font-size: 11px;
color: var(--theme-color-fg-default);
}
.statusDetail {
font-size: 11px;
color: var(--theme-color-fg-default-shy);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.debugToolbarSpacer {
flex: 1;
}
.connectBtn {
height: 22px;
padding: 0 10px;
background: var(--theme-color-primary);
color: #fff;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.disconnectBtn {
height: 22px;
padding: 0 10px;
background: transparent;
border: 1px solid var(--theme-color-border-default);
color: var(--theme-color-fg-default);
border-radius: 4px;
font-size: 11px;
cursor: pointer;
&:hover {
border-color: var(--theme-color-danger, #e05454);
color: var(--theme-color-danger, #e05454);
}
}
.debugLog {
flex: 1;
overflow-y: auto;
padding: 4px 0;
min-height: 0;
font-family: monospace;
font-size: 11px;
}
.debugEmpty {
padding: 24px 16px;
text-align: center;
color: var(--theme-color-fg-default-shy);
}
.debugEvent {
display: grid;
grid-template-columns: 70px 52px 1fr;
gap: 6px;
padding: 2px 10px;
align-items: baseline;
border-bottom: 1px solid transparent;
&:hover {
background: var(--theme-color-bg-3);
}
}
.debugEventTime {
color: var(--theme-color-fg-default-shy);
white-space: nowrap;
}
.debugEventType {
font-size: 10px;
font-weight: 600;
text-align: right;
padding-right: 4px;
letter-spacing: 0.03em;
}
.debugEventData {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--theme-color-fg-default);
font-size: 11px;
line-height: 1.4;
}
// Event type colours
.eventLog .debugEventType {
color: var(--theme-color-fg-default-shy);
}
.eventInfo .debugEventType {
color: var(--theme-color-primary);
}
.eventWarn .debugEventType {
color: var(--theme-color-warning, #facc15);
}
.eventError {
background: color-mix(in srgb, var(--theme-color-danger, #e05454) 8%, transparent);
.debugEventType {
color: var(--theme-color-danger, #e05454);
}
}
.eventMetric .debugEventType {
color: var(--theme-color-success, #4ade80);
}
.scrollToBottomBtn {
position: sticky;
bottom: 8px;
align-self: center;
margin: 4px auto;
height: 24px;
padding: 0 12px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 12px;
color: var(--theme-color-fg-default);
font-size: 11px;
cursor: pointer;
z-index: 1;
&:hover {
border-color: var(--theme-color-primary);
color: var(--theme-color-primary);
}
}

View File

@@ -0,0 +1,278 @@
/**
* UBA-006 + UBA-007: UBAPanel
*
* Editor-side panel for the Universal Backend Adapter system.
* Provides two tabs:
* - Configure: Schema-driven config form backed by project metadata + UBAClient
* - Debug: Live SSE event stream from the backend's debug_stream endpoint
*
* Schema discovery flow:
* 1. Read `ubaSchemaUrl` from project metadata
* 2. If absent → show SchemaLoader UI (URL input field)
* 3. Fetch + parse the schema with SchemaParser
* 4. On parse success → render ConfigPanel
*
* Config persistence flow:
* 1. Load `ubaConfig` from project metadata as initialValues
* 2. ConfigPanel.onSave → store in metadata + POST via UBAClient.configure()
*
* Project metadata keys:
* - 'ubaSchemaUrl' — URL or local path to the UBA schema JSON
* - 'ubaConfig' — saved config values (nested object)
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useCallback, useEffect, useState } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { SchemaParser } from '@noodl-models/UBA/SchemaParser';
import type { UBASchema } from '@noodl-models/UBA/types';
import { Tabs, TabsVariant } from '@noodl-core-ui/components/layout/Tabs';
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
import { UBAClient } from '../../../services/UBA/UBAClient';
import { ConfigPanel } from '../../UBA/ConfigPanel';
import { DebugStreamView } from './DebugStreamView';
import css from './UBAPanel.module.scss';
const METADATA_SCHEMA_URL = 'ubaSchemaUrl';
const METADATA_CONFIG = 'ubaConfig';
// ─── Schema Loader ────────────────────────────────────────────────────────────
interface SchemaLoaderProps {
onLoad: (url: string) => void;
loading: boolean;
error: string | null;
}
function SchemaLoader({ onLoad, loading, error }: SchemaLoaderProps) {
const [url, setUrl] = useState('');
return (
<div className={css.schemaLoader}>
<p className={css.schemaLoaderHint}>
Paste the URL or local path to your backend&apos;s UBA schema JSON to get started.
</p>
<div className={css.schemaLoaderRow}>
<input
className={css.schemaLoaderInput}
type="url"
placeholder="http://localhost:3210/uba-schema.json"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && url.trim()) onLoad(url.trim());
}}
disabled={loading}
/>
<button
type="button"
className={css.schemaLoaderBtn}
onClick={() => url.trim() && onLoad(url.trim())}
disabled={loading || !url.trim()}
>
{loading ? 'Loading…' : 'Load'}
</button>
</div>
{error && (
<p className={css.schemaLoaderError} role="alert">
{error}
</p>
)}
</div>
);
}
// ─── useUBASchema ─────────────────────────────────────────────────────────────
/**
* Manages schema URL storage + fetching + parsing from project metadata.
*/
function useUBASchema() {
const [schemaUrl, setSchemaUrl] = useState<string | null>(null);
const [schema, setSchema] = useState<UBASchema | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Reload when project changes
const reload = useCallback(() => {
const project = ProjectModel.instance;
if (!project) {
setSchemaUrl(null);
setSchema(null);
return;
}
const savedUrl = project.getMetaData(METADATA_SCHEMA_URL) as string | null;
setSchemaUrl(savedUrl ?? null);
}, []);
useEffect(() => {
reload();
}, [reload]);
useEventListener(ProjectModel.instance, 'importComplete', reload);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useEventListener(ProjectModel.instance as any, 'instanceHasChanged', reload);
// Fetch + parse schema when URL changes
useEffect(() => {
if (!schemaUrl) {
setSchema(null);
return;
}
let cancelled = false;
setLoading(true);
setLoadError(null);
(async () => {
try {
const res = await fetch(schemaUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const raw = await res.json();
const parseResult = new SchemaParser().parse(raw);
if (parseResult.success) {
if (!cancelled) {
setSchema(parseResult.data);
setLoadError(null);
}
} else {
// Explicit cast: TS doesn't narrow discriminated unions inside async IIFEs
type FailResult = { success: false; errors: Array<{ path: string; message: string }> };
const fail = parseResult as FailResult;
throw new Error(fail.errors.map((e) => `${e.path}: ${e.message}`).join('; ') || 'Schema parse failed');
}
} catch (err) {
if (!cancelled) {
setLoadError(err instanceof Error ? err.message : String(err));
setSchema(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [schemaUrl]);
const loadSchema = useCallback((url: string) => {
const project = ProjectModel.instance;
if (project) {
project.setMetaData(METADATA_SCHEMA_URL, url);
}
setSchemaUrl(url);
}, []);
const clearSchema = useCallback(() => {
const project = ProjectModel.instance;
if (project) {
project.setMetaData(METADATA_SCHEMA_URL, null);
}
setSchemaUrl(null);
setSchema(null);
setLoadError(null);
}, []);
return { schemaUrl, schema, loadError, loading, loadSchema, clearSchema };
}
// ─── UBAPanel ────────────────────────────────────────────────────────────────
export function UBAPanel() {
const { schemaUrl, schema, loadError, loading, loadSchema, clearSchema } = useUBASchema();
// Load saved config from project metadata
const getSavedConfig = useCallback((): Record<string, unknown> => {
const project = ProjectModel.instance;
if (!project) return {};
return (project.getMetaData(METADATA_CONFIG) as Record<string, unknown>) ?? {};
}, []);
// Save config to project metadata AND push to backend
const handleSave = useCallback(
async (values: Record<string, unknown>) => {
const project = ProjectModel.instance;
if (project) {
project.setMetaData(METADATA_CONFIG, values);
}
// POST to backend if schema is loaded
if (schema) {
await UBAClient.configure(schema.backend.endpoints.config, values, schema.backend.auth);
}
},
[schema]
);
const renderConfigureTab = () => {
if (!schemaUrl && !loading) {
return <SchemaLoader onLoad={loadSchema} loading={loading} error={loadError} />;
}
if (loading) {
return <div className={css.statusMsg}>Loading schema</div>;
}
if (loadError) {
return (
<div className={css.errorState}>
<p className={css.errorMsg}>Failed to load schema: {loadError}</p>
<button type="button" className={css.clearBtn} onClick={clearSchema}>
Try a different URL
</button>
</div>
);
}
if (!schema) {
return <div className={css.statusMsg}>No schema loaded.</div>;
}
return (
<ConfigPanel
schema={schema}
initialValues={getSavedConfig()}
onSave={handleSave}
onReset={() => {
/* noop — reset is handled inside ConfigPanel */
}}
/>
);
};
const renderDebugTab = () => {
if (!schema?.backend.endpoints.debug_stream) {
return (
<div className={css.statusMsg}>
{schema
? 'This backend does not expose a debug stream endpoint.'
: 'Load a schema first to use debug stream.'}
</div>
);
}
return <DebugStreamView endpoint={schema.backend.endpoints.debug_stream} auth={schema.backend.auth} />;
};
return (
<BasePanel title="Backend Adapter">
<Tabs
variant={TabsVariant.Sidebar}
tabs={[
{
label: 'Configure',
content: renderConfigureTab()
},
{
label: 'Debug',
content: renderDebugTab()
}
]}
/>
</BasePanel>
);
}

View File

@@ -0,0 +1,2 @@
export { UBAPanel } from './UBAPanel';
export { DebugStreamView } from './DebugStreamView';