mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UBAPanel } from './UBAPanel';
|
||||
export { DebugStreamView } from './DebugStreamView';
|
||||
Reference in New Issue
Block a user