mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
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:
@@ -49,8 +49,41 @@
|
|||||||
| UBA-006 ConfigPanel mounting | ✅ Done | UBAPanel with project metadata |
|
| UBA-006 ConfigPanel mounting | ✅ Done | UBAPanel with project metadata |
|
||||||
| UBA-007 Debug Stream Panel | ✅ Done | SSE viewer in Debug tab |
|
| 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
|
## Next Up
|
||||||
|
|
||||||
- UBA-008: UBA panel registration in editor sidebar
|
|
||||||
- UBA-009: UBA health indicator / status widget
|
|
||||||
- STYLE tasks: Any remaining style overhaul items
|
- STYLE tasks: Any remaining style overhaul items
|
||||||
|
- UBA-010: Consider E2E integration test with mock backend
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { PropertyEditor } from './views/panels/propertyeditor';
|
|||||||
import { SearchPanel } from './views/panels/search-panel/search-panel';
|
import { SearchPanel } from './views/panels/search-panel/search-panel';
|
||||||
// import { TopologyMapPanel } from './views/panels/TopologyMapPanel'; // Disabled - shelved feature
|
// import { TopologyMapPanel } from './views/panels/TopologyMapPanel'; // Disabled - shelved feature
|
||||||
import { TriggerChainDebuggerPanel } from './views/panels/TriggerChainDebuggerPanel';
|
import { TriggerChainDebuggerPanel } from './views/panels/TriggerChainDebuggerPanel';
|
||||||
|
import { UBAPanel } from './views/panels/UBAPanel';
|
||||||
import { UndoQueuePanel } from './views/panels/UndoQueuePanel/UndoQueuePanel';
|
import { UndoQueuePanel } from './views/panels/UndoQueuePanel/UndoQueuePanel';
|
||||||
import { VersionControlPanel_ID } from './views/panels/VersionControlPanel';
|
import { VersionControlPanel_ID } from './views/panels/VersionControlPanel';
|
||||||
import { VersionControlPanel } from './views/panels/VersionControlPanel/VersionControlPanel';
|
import { VersionControlPanel } from './views/panels/VersionControlPanel/VersionControlPanel';
|
||||||
@@ -167,6 +168,17 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
|||||||
panel: AppSetupPanel
|
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({
|
SidebarModel.instance.register({
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
name: 'Project settings',
|
name: 'Project settings',
|
||||||
|
|||||||
@@ -306,3 +306,88 @@
|
|||||||
color: var(--theme-color-primary);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,79 @@ import css from './UBAPanel.module.scss';
|
|||||||
|
|
||||||
const METADATA_SCHEMA_URL = 'ubaSchemaUrl';
|
const METADATA_SCHEMA_URL = 'ubaSchemaUrl';
|
||||||
const METADATA_CONFIG = 'ubaConfig';
|
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 ────────────────────────────────────────────────────────────
|
// ─── Schema Loader ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -208,6 +281,8 @@ export function UBAPanel() {
|
|||||||
[schema]
|
[schema]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const health = useUBAHealth(schema?.backend.endpoints.health, schema?.backend.auth);
|
||||||
|
|
||||||
const renderConfigureTab = () => {
|
const renderConfigureTab = () => {
|
||||||
if (!schemaUrl && !loading) {
|
if (!schemaUrl && !loading) {
|
||||||
return <SchemaLoader onLoad={loadSchema} loading={loading} error={loadError} />;
|
return <SchemaLoader onLoad={loadSchema} loading={loading} error={loadError} />;
|
||||||
@@ -233,6 +308,8 @@ export function UBAPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={css.configureTabContent}>
|
||||||
|
{schema.backend.endpoints.health && <HealthBadge status={health.status} message={health.message} />}
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
schema={schema}
|
schema={schema}
|
||||||
initialValues={getSavedConfig()}
|
initialValues={getSavedConfig()}
|
||||||
@@ -241,6 +318,7 @@ export function UBAPanel() {
|
|||||||
/* noop — reset is handled inside ConfigPanel */
|
/* noop — reset is handled inside ConfigPanel */
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser';
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -44,7 +50,7 @@ describe('SchemaParser', () => {
|
|||||||
it('rejects null input', () => {
|
it('rejects null input', () => {
|
||||||
const result = parser.parse(null);
|
const result = parser.parse(null);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (isFailure(result)) {
|
||||||
expect(result.errors[0].path).toBe('');
|
expect(result.errors[0].path).toBe('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -55,10 +61,11 @@ describe('SchemaParser', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects missing schema_version', () => {
|
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);
|
const result = parser.parse(noVersion);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (isFailure(result)) {
|
||||||
expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true);
|
expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -87,10 +94,11 @@ describe('SchemaParser', () => {
|
|||||||
|
|
||||||
describe('backend validation', () => {
|
describe('backend validation', () => {
|
||||||
it('errors when backend is missing', () => {
|
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);
|
const result = parser.parse(noBackend);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (isFailure(result)) {
|
||||||
expect(result.errors.some((e) => e.path === 'backend')).toBe(true);
|
expect(result.errors.some((e) => e.path === 'backend')).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -107,7 +115,7 @@ describe('SchemaParser', () => {
|
|||||||
});
|
});
|
||||||
const result = parser.parse(data);
|
const result = parser.parse(data);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (isFailure(result)) {
|
||||||
expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true);
|
expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -138,7 +146,7 @@ describe('SchemaParser', () => {
|
|||||||
});
|
});
|
||||||
const result = parser.parse(data);
|
const result = parser.parse(data);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (isFailure(result)) {
|
||||||
expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true);
|
expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -171,7 +179,7 @@ describe('SchemaParser', () => {
|
|||||||
});
|
});
|
||||||
const result = parser.parse(data);
|
const result = parser.parse(data);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (isFailure(result)) {
|
||||||
expect(result.errors.some((e) => e.path.includes('id'))).toBe(true);
|
expect(result.errors.some((e) => e.path.includes('id'))).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user