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<T>() type guard for webpack ts-loader friendly narrowing
  - eslint-disable for destructuring discard patterns
This commit is contained in:
Richard Osborne
2026-02-18 20:43:03 +01:00
parent 6403341bcc
commit ade2afee85
5 changed files with 234 additions and 18 deletions

View File

@@ -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<T>()` 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

View File

@@ -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',

View File

@@ -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;
}
}

View File

@@ -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<HealthStatus>('unknown');
const [message, setMessage] = useState<string | undefined>();
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<HealthStatus, string> = {
unknown: css.healthUnknown,
checking: css.healthChecking,
healthy: css.healthHealthy,
unhealthy: css.healthUnhealthy
};
const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = {
unknown: 'Not configured',
checking: 'Checking…',
healthy: 'Healthy',
unhealthy: 'Unhealthy'
};
interface HealthBadgeProps {
status: HealthStatus;
message: string | undefined;
}
function HealthBadge({ status, message }: HealthBadgeProps) {
return (
<div className={`${css.healthBadge} ${HEALTH_STATUS_CLASS[status]}`} title={message ?? HEALTH_STATUS_LABEL[status]}>
<span className={css.healthDot} aria-hidden="true" />
<span className={css.healthLabel}>{HEALTH_STATUS_LABEL[status]}</span>
</div>
);
}
// ─── 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 <SchemaLoader onLoad={loadSchema} loading={loading} error={loadError} />;
@@ -233,14 +308,17 @@ export function UBAPanel() {
}
return (
<ConfigPanel
schema={schema}
initialValues={getSavedConfig()}
onSave={handleSave}
onReset={() => {
/* noop — reset is handled inside ConfigPanel */
}}
/>
<div className={css.configureTabContent}>
{schema.backend.endpoints.health && <HealthBadge status={health.status} message={health.message} />}
<ConfigPanel
schema={schema}
initialValues={getSavedConfig()}
onSave={handleSave}
onReset={() => {
/* noop — reset is handled inside ConfigPanel */
}}
/>
</div>
);
};

View File

@@ -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<T>(result: ParseResult<T>): result is Extract<ParseResult<T>, { 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);
}
});