mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +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