mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +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-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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user