diff --git a/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/DebugStreamView.tsx b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/DebugStreamView.tsx new file mode 100644 index 0000000..46b3d45 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/DebugStreamView.tsx @@ -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([]); + const [status, setStatus] = useState('disconnected'); + const [statusMsg, setStatusMsg] = useState(''); + const [autoScroll, setAutoScroll] = useState(true); + + const handleRef = useRef(null); + const logRef = useRef(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 ( +
+ {/* Toolbar */} +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.module.scss b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.module.scss new file mode 100644 index 0000000..6e2a9c3 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.module.scss @@ -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); + } +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.tsx new file mode 100644 index 0000000..c281e56 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.tsx @@ -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 ( +
+

+ Paste the URL or local path to your backend's UBA schema JSON to get started. +

+
+ setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && url.trim()) onLoad(url.trim()); + }} + disabled={loading} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + +// ─── useUBASchema ───────────────────────────────────────────────────────────── + +/** + * Manages schema URL storage + fetching + parsing from project metadata. + */ +function useUBASchema() { + const [schemaUrl, setSchemaUrl] = useState(null); + const [schema, setSchema] = useState(null); + const [loadError, setLoadError] = useState(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 => { + const project = ProjectModel.instance; + if (!project) return {}; + return (project.getMetaData(METADATA_CONFIG) as Record) ?? {}; + }, []); + + // Save config to project metadata AND push to backend + const handleSave = useCallback( + async (values: Record) => { + 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 ; + } + + if (loading) { + return
Loading schema…
; + } + + if (loadError) { + return ( +
+

Failed to load schema: {loadError}

+ +
+ ); + } + + if (!schema) { + return
No schema loaded.
; + } + + return ( + { + /* noop — reset is handled inside ConfigPanel */ + }} + /> + ); + }; + + const renderDebugTab = () => { + if (!schema?.backend.endpoints.debug_stream) { + return ( +
+ {schema + ? 'This backend does not expose a debug stream endpoint.' + : 'Load a schema first to use debug stream.'} +
+ ); + } + + return ; + }; + + return ( + + + + ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/index.ts b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/index.ts new file mode 100644 index 0000000..829f711 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/index.ts @@ -0,0 +1,2 @@ +export { UBAPanel } from './UBAPanel'; +export { DebugStreamView } from './DebugStreamView';