Compare commits

..
13 Commits
Author SHA1 Message Date
praisonai-triage-agent[bot] a1723b5432 Merge pull request #353 from MervinPraison/claude/issue-341-20261111-footer
fix: dashboard footer connection indicator uses liveness instead of deep health
2026-11-11 19:35:43 +00:00
praisonai-triage-agent[bot] a8ae997fb0 Merge pull request #352 from MervinPraison/claude/issue-339-20261111
fix: shell header branding as a content-sized row, not a flex column
2026-11-11 19:35:41 +00:00
praisonai-triage-agent[bot] f1ace7a3ae Merge pull request #350 from MervinPraison/claude/issue-338-20261111
fix: set_custom_js registrations before dashboard is ready (fixes #338)
2026-11-11 19:35:27 +00:00
praisonai-triage-agent[bot] 3d20f78529 Merge pull request #351 from MervinPraison/claude/issue-341-20261111
fix: bound provider health check in /health/ready and cold /api/health
2026-11-11 19:35:26 +00:00
praisonai-triage-agent[bot] f34aac552a Merge pull request #349 from MervinPraison/claude/issue-334-20261111
fix: align extensibility example docs with set_custom_js
2026-11-11 17:39:00 +00:00
MervinPraisonandClaude Opus 5.5 88683e7263 fix: ignore non-dict provider health in readiness to avoid 500
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:51:52 +00:00
MervinPraisonandClaude Opus 5.5 05ea62bf1c fix: run cleanup of superseded view renders in selectPage (#338)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:48:02 +00:00
MervinPraisonandClaude Opus 5.5 d3a7116225 fix: drop DevTools-paste instruction from example 29 fallback page (fixes #334)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:27:12 +00:00
MervinPraisonandClaude Opus 5.5 341aed5ab8 fix: dashboard footer connection indicator uses /api/health/live (fixes #341)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:21:41 +00:00
MervinPraisonandClaude Opus 5.5 1be805fef6 fix: lay out shell header as a content-sized row above the page (fixes #339)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:16:36 +00:00
MervinPraisonandClaude Opus 5.5 a62b38ed2f fix: bound provider health in readiness / cold /api/health (fixes #341)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:16:25 +00:00
MervinPraisonandClaude Opus 5.5 0939339b11 fix: queue early set_custom_js registrations and re-render active page on late registerView (fixes #338)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:15:18 +00:00
MervinPraisonandClaude Opus 5.5 2add8a7687 fix: align extensibility example loading docs with set_custom_js (fixes #334)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:58 +00:00
9 changed files with 232 additions and 40 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ curl http://localhost:8082/health/live
### Readiness Check: `/health/ready`
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection. The provider check runs concurrently and is bounded by the same 0.5s timeout, so a slow provider cannot hang the response (or a cold `/api/health`).
**Use for:**
- Kubernetes readiness probes
@@ -1,4 +1,4 @@
# Example 28 — Full Extensibility Showcase
# Example 29 — Full Extensibility Showcase
Demonstrates **every** extension point in PraisonAIUI.
@@ -45,6 +45,9 @@ The `timeline` component and the `custom-view` page are client-side extensions.
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
> **Tip:** if a custom view renders its own heading, call
> `aiui.set_dashboard(page_header=False)` to hide the default page title/description header.
## Testing the form action
1. Navigate to **Contact Form**
+4 -5
View File
@@ -1,4 +1,4 @@
"""Example 28 — Full Extensibility Showcase.
"""Example 29 — Full Extensibility Showcase.
Demonstrates EVERY extension point in PraisonAIUI:
@@ -203,10 +203,9 @@ async def custom_view_fallback():
aiui.callout(
title="Plugin not loaded",
content=(
"This page is meant to be rendered client-side. "
"Open DevTools → Console and paste the contents of "
"plugin.js (in this example folder) to "
"activate the custom view."
"This page is meant to be rendered client-side by "
"plugin.js, which app.py loads via aiui.set_custom_js(). "
"If you see this, check that /custom.js loaded."
),
variant="warning",
),
@@ -5,9 +5,11 @@
* 1. window.aiui.registerComponent(type, renderFn) — adds a new component type
* 2. window.aiui.registerView(pageId, render, cleanup) — adds a custom page view
*
* To load this file, paste its contents into the browser DevTools Console
* after the page loads. (A Python-side API to inject custom JS is a planned
* enhancement — see 28-full-extensibility/README.md.)
* Loaded automatically by app.py, which serves it at /custom.js:
*
* aiui.set_custom_js(Path(__file__).with_name("plugin.js"))
*
* See 29-full-extensibility/README.md.
*/
(function () {
+31 -9
View File
@@ -276,9 +276,11 @@ def set_custom_js(path: str | Path) -> None:
Reads the file at ``path`` and serves it at ``/custom.js``. A
``<script src="/custom.js">`` tag is injected into the host HTML
*after* the plugin loader, so ``window.aiui`` and all registry APIs
(``registerView``, ``registerComponent``) are ready when your code
runs.
*after* the plugin loader. ``window.aiui`` registry APIs
(``registerView``, ``registerComponent``, ``registerSlot``,
``registerSurfaceRenderer``) can be called directly at load time:
calls made before the dashboard module is ready are queued and
replayed, and a view registered for the active page re-renders it.
Use this to ship client-side extensions from your Python app without
modifying the package bundle.
@@ -806,7 +808,18 @@ def _custom_js_tag(cache_bust: int) -> str:
"""Return a <script src="/custom.js"> tag if set_custom_js() was called."""
if _custom_js is None:
return ""
return f'<script src="/custom.js?v={cache_bust}"></script>'
return _CUSTOM_JS_QUEUE + f'<script src="/custom.js?v={cache_bust}"></script>'
# Registration stubs for /custom.js: the dashboard module loads
# asynchronously, so until it defines the real registry APIs these stubs
# queue calls on ``window.aiui.__queue``; dashboard.js replays them.
_CUSTOM_JS_QUEUE = (
"<script>(function(a){a.__queue=a.__queue||[];"
"['registerView','registerComponent','registerSlot','registerSurfaceRenderer']"
".forEach(function(n){if(!a[n])a[n]=function(){a.__queue.push([n,arguments]);};});"
"})(window.aiui=window.aiui||{});</script>"
)
async def _serve_custom_js(request: Request) -> Response:
@@ -1095,11 +1108,18 @@ async def health_ready(request: Request) -> JSONResponse:
provider = get_provider()
provider_info = {"name": type(provider).__name__}
try:
provider_health = await provider.health()
provider_info.update(provider_health)
except Exception:
pass
async def _safe_provider() -> Dict[str, Any]:
# Bounded like feature checks so a slow provider cannot hang readiness
# (or the cold /api/health dashboard request that delegates here).
try:
result = await asyncio.wait_for(provider.health(), timeout=0.5)
except Exception:
return {}
return result if isinstance(result, dict) else {}
# Start the provider check now so it runs concurrently with feature checks.
provider_task = asyncio.ensure_future(_safe_provider())
features_health = {}
sdk_gaps: list = []
@@ -1138,6 +1158,8 @@ async def health_ready(request: Request) -> JSONResponse:
except Exception:
pass
provider_info.update(await provider_task)
payload: Dict[str, Any] = {
"status": "ok",
"timestamp": datetime.utcnow().isoformat(),
@@ -241,6 +241,10 @@ const aiuiSdk = {
window.aiui = window.aiui || {};
window.aiui.registerView = function(pageId, renderFn, cleanupFn) {
VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null };
// Late registration for the page already on screen: re-render it.
if (pageId === activePageId && document.getElementById('db-main-content')) {
selectPage(pageId);
}
};
window.aiui.views = VIEW_REGISTRY;
window.aiui.registerComponent = function(type, renderFn) {
@@ -291,6 +295,14 @@ const DASHBOARD_STYLE = `
/* Root becomes the flex container */
#root { display: flex; min-height: 100vh; }
/* With a shell:header slot: sidebar | (header row sized to content, page panel below). */
#root.db-active:has(> #db-shell-header-slot) {
display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
height: 100vh; min-height: 0;
}
#root.db-active:has(> #db-shell-header-slot) > .db-sidebar { grid-column: 1; grid-row: 1 / -1; }
#db-shell-header-slot { grid-column: 2; grid-row: 1; min-width: 0; padding: 8px 40px 0; }
#root.db-active:has(> #db-shell-header-slot) > .db-main { grid-column: 2; grid-row: 2; height: auto; min-height: 0; }
/* Sidebar */
.db-sidebar {
@@ -471,9 +483,8 @@ const DASHBOARD_STYLE = `
.db-sidebar.db-sidebar-open { transform: translateX(0); visibility: visible; transition: transform 0.2s ease; }
.db-mobile-overlay.db-visible { display: block; }
.db-main { padding-top: 48px; }
/* The shell header slot sits in the same flex row; give it its own line. */
#root.db-active { flex-wrap: wrap; }
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
/* Sidebar is fixed here; the header row sits below the fixed mobile bar. */
#db-shell-header-slot { margin-top: 48px; padding: 8px 16px 0; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
@media (max-width: 600px) {
@@ -1972,6 +1983,12 @@ function showDashboardInitError(message) {
banner.textContent = message;
}
// A render superseded by a newer selectPage never became active, so run its
// own cleanup to release intervals/listeners it set up.
function _supersededCleanup(fn) {
if (fn) { try { fn(); } catch (e) {} }
}
async function selectPage(pageId) {
// Cleanup previous view if it has a cleanup function
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
@@ -2042,9 +2059,11 @@ async function selectPage(pageId) {
// Priority 1: Custom registered view
if (VIEW_REGISTRY[pageId]) {
const view = VIEW_REGISTRY[pageId];
try {
await VIEW_REGISTRY[pageId].render(container);
_activeCleanup = VIEW_REGISTRY[pageId].cleanup || null;
await view.render(container);
if (!container.isConnected) { _supersededCleanup(view.cleanup); return; } // superseded by a newer selectPage
_activeCleanup = view.cleanup || null;
rendered = true;
} catch (e) { console.warn(`View '${pageId}' render error:`, e); }
}
@@ -2055,17 +2074,21 @@ async function selectPage(pageId) {
const viewUrl = BUILTIN_VIEWS[pageId] + '?v=' + Date.now();
const mod = await import(viewUrl);
if (mod.render) {
if (!container.isConnected) return; // superseded by a newer selectPage
await mod.render(container);
if (!container.isConnected) { _supersededCleanup(mod.cleanup); return; }
_activeCleanup = mod.cleanup || null;
rendered = true;
// Cache for future use
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
// Cache for future use (never overwrite a view registered meanwhile)
if (!VIEW_REGISTRY[pageId]) {
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
}
}
} catch (e) { console.warn(`Built-in view '${pageId}' import error:`, e); }
}
// Priority 3: Generic JSON viewer fallback
if (!rendered) {
if (!rendered && container.isConnected) {
loadGenericViewer(page, container);
}
@@ -3517,23 +3540,21 @@ window.aiui.registerSlot('shell:header', () => {
window.aiui.registerSlot('shell:sidebar:footer', () => {
const el = document.createElement('div');
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
fetch('/api/health').then((r) => r.json()).then((d) => {
// Connection indicator uses side-effect-free liveness (#341). Deep
// feature/SDK-gap diagnostics remain at /api/health (Debug view).
fetch('/api/health/live').then((r) => r.json()).then((d) => {
const ok = d && (d.status === 'ok' || d.status === 'healthy');
const dot = el.querySelector('.db-health-dot');
const label = el.querySelector('.db-health-label');
const gaps = (d && d.sdk_gaps) || [];
if (dot) dot.style.color = ok && !gaps.length ? '#22c55e' : (gaps.length ? '#f59e0b' : '#ef4444');
if (label) {
if (gaps.length) {
label.textContent = `SDK gaps (${gaps.length})`;
label.title = gaps.map((g) => `${g.feature}: ${g.message}`).join('\n');
} else {
label.textContent = ok ? (d.integrated ? 'Integrated' : 'Connected') : 'Degraded';
label.title = '';
}
}
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
return el;
});
// Replay registrations queued by the /custom.js stubs (see server.py).
(window.aiui.__queue || []).splice(0).forEach(([name, args]) => {
try { window.aiui[name](...args); } catch (e) { console.warn(`[AIUI] queued ${name} failed:`, e); }
});
export { init, onContentChange };
+43
View File
@@ -122,3 +122,46 @@ class TestCustomJsServing:
"custom.js must be injected AFTER plugin-loader.js so window.aiui "
"APIs are available when custom.js runs"
)
# ── Registration lifecycle (issue #338) ───────────────────────────
_DASHBOARD_JS = (
Path(srv.__file__).parent / "templates" / "frontend" / "plugins" / "dashboard.js"
)
class TestCustomJsRegistrationLifecycle:
def test_queue_stub_injected_before_custom_js(self, tmp_js):
"""Registry stubs must exist before /custom.js runs (dashboard loads async)."""
srv.set_custom_js(tmp_js)
srv.set_style("dashboard")
html = TestClient(srv.create_app()).get("/").text
stub_idx = html.find("__queue")
assert stub_idx != -1, "registration queue stub must be injected"
assert stub_idx < html.find("/custom.js")
for name in ("registerView", "registerComponent", "registerSlot",
"registerSurfaceRenderer"):
assert name in html[stub_idx:html.find("/custom.js")]
def test_no_queue_stub_when_unset(self, client):
assert "__queue" not in client.get("/").text
def test_dashboard_replays_queued_registrations(self):
src = _DASHBOARD_JS.read_text(encoding="utf-8")
replay_idx = src.find("window.aiui.__queue")
assert replay_idx != -1, "dashboard.js must replay queued registrations"
# Replay must run after `let activePageId` is initialised (no TDZ error).
assert replay_idx > src.find("let activePageId")
def test_late_register_view_rerenders_active_page(self):
src = _DASHBOARD_JS.read_text(encoding="utf-8")
start = src.find("window.aiui.registerView = function")
body = src[start:src.find("\n};", start)]
assert "activePageId" in body and "selectPage(pageId)" in body
def test_superseded_render_runs_its_cleanup(self):
"""A render replaced by a newer selectPage must still be cleaned up."""
src = _DASHBOARD_JS.read_text(encoding="utf-8")
assert "_supersededCleanup(view.cleanup)" in src
assert "_supersededCleanup(mod.cleanup)" in src
+10 -1
View File
@@ -139,10 +139,19 @@ def test_replay_page_uses_own_svg_mark_not_emoji_heading():
def test_dashboard_mobile_shell_gives_content_the_width():
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert ".db-sidebar { position: fixed !important;" in dash
assert "#db-shell-header-slot { flex: 0 0 100%;" in dash
assert "#db-shell-header-slot { margin-top: 48px;" in dash
assert ".db-main { padding: 64px 16px 16px; }" in dash
def test_shell_header_is_a_content_sized_row_not_a_column():
"""#339: the header slot must not take a flex column or stretch on mobile."""
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "#root.db-active:has(> #db-shell-header-slot) {" in dash
assert "grid-template-rows: auto minmax(0, 1fr);" in dash
assert "#db-shell-header-slot { grid-column: 2; grid-row: 1;" in dash
assert "#root.db-active { flex-wrap: wrap; }" not in dash
def test_theme_defaults_to_system():
view = VIEW.read_text(encoding="utf-8")
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
+93
View File
@@ -25,6 +25,8 @@ def client():
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
_srv._health_cache = {}
_srv._health_cache_time = 0
# Snapshot and clear the global feature registry so a feature leaked by an
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
# /health and flake the deep-health timing/cache assertions under the full
@@ -203,6 +205,97 @@ class TestHealthEndpoint:
assert second.json().get("cached") is True
assert duration < 0.1
def test_slow_provider_does_not_block_readiness(self, client):
"""Cold /api/health and /health/ready bound a slow provider (#341)."""
import asyncio
import time
import praisonaiui.server as _srv
class _SlowProvider:
async def health(self):
await asyncio.sleep(5)
return {"status": "ok"}
prev_provider = _srv._provider
_srv._provider = _SlowProvider()
_srv._health_cache = {}
_srv._health_cache_time = 0
try:
for path in ("/api/health", "/health/ready"):
start = time.perf_counter()
response = client.get(path)
duration = time.perf_counter() - start
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["provider"]["name"] == "_SlowProvider"
assert duration < 2.0
finally:
_srv._provider = prev_provider
_srv._health_cache = {}
_srv._health_cache_time = 0
def test_non_dict_provider_health_does_not_500_readiness(self, client):
"""A provider returning a non-dict from health() must not break /health/ready."""
import praisonaiui.server as _srv
class _NoneProvider:
async def health(self):
return None
prev_provider = _srv._provider
_srv._provider = _NoneProvider()
try:
response = client.get("/health/ready")
assert response.status_code == 200
assert response.json()["provider"] == {"name": "_NoneProvider"}
finally:
_srv._provider = prev_provider
class TestFooterLiveness:
"""Dashboard footer uses side-effect-free liveness (#341)."""
def test_live_makes_no_provider_or_feature_calls(self, client):
import praisonaiui.server as _srv
from praisonaiui.features import _features
calls = {"provider": 0, "feature": 0}
class _Provider:
async def health(self):
calls["provider"] += 1
return {}
class _Feature:
async def health(self):
calls["feature"] += 1
return {"healthy": True}
original = _srv._provider
_srv._provider = _Provider()
_features["__count__"] = _Feature()
try:
assert client.get("/api/health/live").status_code == 200
assert calls == {"provider": 0, "feature": 0}
finally:
_srv._provider = original
_features.pop("__count__", None)
def test_sidebar_footer_uses_liveness_endpoint(self):
from pathlib import Path
import praisonaiui
js = (
Path(praisonaiui.__file__).parent
/ "templates/frontend/plugins/dashboard.js"
).read_text(encoding="utf-8")
footer = js[js.index("shell:sidebar:footer"):]
assert "fetch('/api/health/live')" in footer
assert "fetch('/api/health')" not in footer
class TestProviderHealthEndpoint:
"""Tests for the provider health JSON route (#151)."""