From ade2afee859dff755c0536f26937d442a76cf1c1 Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Wed, 18 Feb 2026 20:43:03 +0100 Subject: [PATCH] feat(uba): UBA-008/009 panel registration + health indicator UBA-008: Register UBAPanel in editor sidebar (router.setup.ts) UBA-009: Health indicator widget in Configure tab - useUBAHealth hook polling UBAClient.health() every 30s - HealthBadge component: 4 states (unknown/checking/healthy/unhealthy) - Pulsing animation on checking; design token colours with fallbacks - Shown only when schema.backend.endpoints.health is present Test fix: UBASchemaParser.test.ts - isFailure() type guard for webpack ts-loader friendly narrowing - eslint-disable for destructuring discard patterns --- .../phase-6-uba-system/PROGRESS-richard.md | 37 +++++++- .../src/editor/src/router.setup.ts | 12 +++ .../panels/UBAPanel/UBAPanel.module.scss | 85 +++++++++++++++++ .../src/views/panels/UBAPanel/UBAPanel.tsx | 94 +++++++++++++++++-- .../tests/models/UBASchemaParser.test.ts | 24 +++-- 5 files changed, 234 insertions(+), 18 deletions(-) diff --git a/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md b/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md index e3d79d0..db70dc9 100644 --- a/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md +++ b/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md @@ -49,8 +49,41 @@ | UBA-006 ConfigPanel mounting | ✅ Done | UBAPanel with project metadata | | UBA-007 Debug Stream Panel | ✅ Done | SSE viewer in Debug tab | +### Session 4 (Sprint 2) + +- **UBA-008**: `router.setup.ts` — Registered UBAPanel in editor sidebar + + - Added `uba` route with `UBAPanel` component + - Panel accessible via editor sidebar navigation + +- **UBA-009**: `UBAPanel.tsx` + `UBAPanel.module.scss` — Health indicator widget + + - `useUBAHealth` hook: polls `UBAClient.health()` every 30s, never throws + - `HealthBadge` component: dot + label, 4 states (unknown/checking/healthy/unhealthy) + - Animated pulse on `checking` state; green/red semantic colours with `--theme-color-success/danger` tokens + fallbacks + - Shown above ConfigPanel when `schema.backend.endpoints.health` is present + - `configureTabContent` wrapper div for flex layout + +- **Test fixes**: `UBASchemaParser.test.ts` + - Added `isFailure()` type guard (webpack ts-loader friendly discriminated union narrowing) + - Replaced all `if (!result.success)` with `if (isFailure(result))` + - Fixed destructuring discard pattern `_sv`/`_b` → `_` with `eslint-disable-next-line` + +## Status + +| Task | Status | Notes | +| ---------------------------- | ------- | -------------------------------- | +| UBA-001 Types | ✅ Done | | +| UBA-002 SchemaParser | ✅ Done | Instance method `.parse()` | +| UBA-003 Field Renderers | ✅ Done | 8 field types | +| UBA-004 ConfigPanel | ✅ Done | Tabs, validation, dirty state | +| UBA-005 UBAClient | ✅ Done | configure/health/openDebugStream | +| UBA-006 ConfigPanel mounting | ✅ Done | UBAPanel with project metadata | +| UBA-007 Debug Stream Panel | ✅ Done | SSE viewer in Debug tab | +| UBA-008 Panel registration | ✅ Done | Sidebar route in router.setup.ts | +| UBA-009 Health indicator | ✅ Done | useUBAHealth + HealthBadge | + ## Next Up -- UBA-008: UBA panel registration in editor sidebar -- UBA-009: UBA health indicator / status widget - STYLE tasks: Any remaining style overhaul items +- UBA-010: Consider E2E integration test with mock backend diff --git a/packages/noodl-editor/src/editor/src/router.setup.ts b/packages/noodl-editor/src/editor/src/router.setup.ts index 2213dc3..dac5761 100644 --- a/packages/noodl-editor/src/editor/src/router.setup.ts +++ b/packages/noodl-editor/src/editor/src/router.setup.ts @@ -27,6 +27,7 @@ import { PropertyEditor } from './views/panels/propertyeditor'; import { SearchPanel } from './views/panels/search-panel/search-panel'; // import { TopologyMapPanel } from './views/panels/TopologyMapPanel'; // Disabled - shelved feature import { TriggerChainDebuggerPanel } from './views/panels/TriggerChainDebuggerPanel'; +import { UBAPanel } from './views/panels/UBAPanel'; import { UndoQueuePanel } from './views/panels/UndoQueuePanel/UndoQueuePanel'; import { VersionControlPanel_ID } from './views/panels/VersionControlPanel'; import { VersionControlPanel } from './views/panels/VersionControlPanel/VersionControlPanel'; @@ -167,6 +168,17 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) { panel: AppSetupPanel }); + SidebarModel.instance.register({ + experimental: true, + id: 'uba', + name: 'Backend Adapter', + description: 'Configure and debug Universal Backend Adapter (UBA) compatible backends via schema-driven forms.', + isDisabled: isLesson === true, + order: 8.8, + icon: IconName.RestApi, + panel: UBAPanel + }); + SidebarModel.instance.register({ id: 'settings', name: 'Project settings', 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 index 6e2a9c3..9a07871 100644 --- 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 @@ -306,3 +306,88 @@ color: var(--theme-color-primary); } } + +// ─── Health Indicator (UBA-009) ─────────────────────────────────────────────── + +.configureTabContent { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.healthBadge { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-bottom: 1px solid var(--theme-color-border-default); + background: var(--theme-color-bg-2); + flex-shrink: 0; +} + +.healthDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--theme-color-fg-default-shy); +} + +.healthLabel { + font-size: 11px; + color: var(--theme-color-fg-default-shy); +} + +// Status modifier classes — applied to .healthBadge +.healthUnknown { + .healthDot { + background: var(--theme-color-fg-default-shy); + } + + .healthLabel { + color: var(--theme-color-fg-default-shy); + } +} + +.healthChecking { + .healthDot { + background: var(--theme-color-fg-default); + animation: healthPulse 1s ease-in-out infinite; + } + + .healthLabel { + color: var(--theme-color-fg-default); + } +} + +.healthHealthy { + .healthDot { + background: var(--theme-color-success, #4ade80); + } + + .healthLabel { + color: var(--theme-color-success, #4ade80); + } +} + +.healthUnhealthy { + .healthDot { + background: var(--theme-color-danger, #f87171); + } + + .healthLabel { + color: var(--theme-color-danger, #f87171); + } +} + +@keyframes healthPulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} 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 index c281e56..55ec9e6 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.tsx +++ b/packages/noodl-editor/src/editor/src/views/panels/UBAPanel/UBAPanel.tsx @@ -38,6 +38,79 @@ import css from './UBAPanel.module.scss'; const METADATA_SCHEMA_URL = 'ubaSchemaUrl'; const METADATA_CONFIG = 'ubaConfig'; +const HEALTH_POLL_INTERVAL_MS = 30_000; + +// ─── Health Indicator (UBA-009) ─────────────────────────────────────────────── + +type HealthStatus = 'unknown' | 'checking' | 'healthy' | 'unhealthy'; + +/** + * Polls the backend health endpoint every 30s. + * Uses UBAClient.health() which never throws. + */ +function useUBAHealth( + healthUrl: string | undefined, + auth: UBASchema['backend']['auth'] | undefined +): { status: HealthStatus; message: string | undefined } { + const [status, setStatus] = useState('unknown'); + const [message, setMessage] = useState(); + + useEffect(() => { + if (!healthUrl) { + setStatus('unknown'); + setMessage(undefined); + return; + } + + let cancelled = false; + + const check = async () => { + if (!cancelled) setStatus('checking'); + const result = await UBAClient.health(healthUrl, auth); + if (!cancelled) { + setStatus(result.healthy ? 'healthy' : 'unhealthy'); + setMessage(result.healthy ? undefined : result.message); + } + }; + + void check(); + const timer = setInterval(() => void check(), HEALTH_POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [healthUrl, auth]); + + return { status, message }; +} + +const HEALTH_STATUS_CLASS: Record = { + unknown: css.healthUnknown, + checking: css.healthChecking, + healthy: css.healthHealthy, + unhealthy: css.healthUnhealthy +}; + +const HEALTH_STATUS_LABEL: Record = { + unknown: 'Not configured', + checking: 'Checking…', + healthy: 'Healthy', + unhealthy: 'Unhealthy' +}; + +interface HealthBadgeProps { + status: HealthStatus; + message: string | undefined; +} + +function HealthBadge({ status, message }: HealthBadgeProps) { + return ( +
+
+ ); +} // ─── Schema Loader ──────────────────────────────────────────────────────────── @@ -208,6 +281,8 @@ export function UBAPanel() { [schema] ); + const health = useUBAHealth(schema?.backend.endpoints.health, schema?.backend.auth); + const renderConfigureTab = () => { if (!schemaUrl && !loading) { return ; @@ -233,14 +308,17 @@ export function UBAPanel() { } return ( - { - /* noop — reset is handled inside ConfigPanel */ - }} - /> +
+ {schema.backend.endpoints.health && } + { + /* noop — reset is handled inside ConfigPanel */ + }} + /> +
); }; diff --git a/packages/noodl-editor/tests/models/UBASchemaParser.test.ts b/packages/noodl-editor/tests/models/UBASchemaParser.test.ts index 796b8f9..40e2fda 100644 --- a/packages/noodl-editor/tests/models/UBASchemaParser.test.ts +++ b/packages/noodl-editor/tests/models/UBASchemaParser.test.ts @@ -9,6 +9,12 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser'; +import type { ParseResult } from '../../src/editor/src/models/UBA/types'; + +/** Type guard: narrows ParseResult to the failure branch (webpack ts-loader friendly) */ +function isFailure(result: ParseResult): result is Extract, { success: false }> { + return !result.success; +} // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -44,7 +50,7 @@ describe('SchemaParser', () => { it('rejects null input', () => { const result = parser.parse(null); expect(result.success).toBe(false); - if (!result.success) { + if (isFailure(result)) { expect(result.errors[0].path).toBe(''); } }); @@ -55,10 +61,11 @@ describe('SchemaParser', () => { }); it('rejects missing schema_version', () => { - const { schema_version: _sv, ...noVersion } = minimalValid; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { schema_version: _, ...noVersion } = minimalValid; const result = parser.parse(noVersion); expect(result.success).toBe(false); - if (!result.success) { + if (isFailure(result)) { expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true); } }); @@ -87,10 +94,11 @@ describe('SchemaParser', () => { describe('backend validation', () => { it('errors when backend is missing', () => { - const { backend: _b, ...noBackend } = minimalValid; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { backend: _, ...noBackend } = minimalValid; const result = parser.parse(noBackend); expect(result.success).toBe(false); - if (!result.success) { + if (isFailure(result)) { expect(result.errors.some((e) => e.path === 'backend')).toBe(true); } }); @@ -107,7 +115,7 @@ describe('SchemaParser', () => { }); const result = parser.parse(data); expect(result.success).toBe(false); - if (!result.success) { + if (isFailure(result)) { expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true); } }); @@ -138,7 +146,7 @@ describe('SchemaParser', () => { }); const result = parser.parse(data); expect(result.success).toBe(false); - if (!result.success) { + if (isFailure(result)) { expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true); } }); @@ -171,7 +179,7 @@ describe('SchemaParser', () => { }); const result = parser.parse(data); expect(result.success).toBe(false); - if (!result.success) { + if (isFailure(result)) { expect(result.errors.some((e) => e.path.includes('id'))).toBe(true); } });