diff --git a/contracts/openapi/core-hub.openapi.json b/contracts/openapi/core-hub.openapi.json index 6b77bbd..0a20ed6 100644 --- a/contracts/openapi/core-hub.openapi.json +++ b/contracts/openapi/core-hub.openapi.json @@ -1656,6 +1656,55 @@ ] } }, + "/console": { + "get": { + "operationId": "operator_console_console_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Operator Console", + "tags": [ + "operator-console" + ] + } + }, "/healthz": { "get": { "operationId": "healthz_healthz_get", diff --git a/docs/specs/ui-operator-console.md b/docs/specs/ui-operator-console.md index d97ae56..1d00ace 100644 --- a/docs/specs/ui-operator-console.md +++ b/docs/specs/ui-operator-console.md @@ -76,6 +76,19 @@ The first useful prototype should implement read-heavy surfaces before mutation- Use whynot-design quiet, document-like operational language: mostly neutral surfaces, strong typography hierarchy through labels and tables, no marketing hero, no decorative imagery, no gradients, no animation beyond basic hover/focus feedback. The console should feel like a control room notebook rather than a product landing page. + +## First Prototype + +The first prototype is a protected server-rendered route at `/console`. It uses the existing FastAPI runtime, inline whynot-aligned tokens/classes, and the current SQLAlchemy read model to show: + +- readiness gates; +- hub registry rows; +- migration run ledger rows; +- non-secret API consumer metadata; +- recent interaction events. + +The route requires the same bearer-token authentication as protected `/api/v2` resources. It intentionally renders only API key prefixes and non-secret lifecycle metadata. + ## Open Implementation Choice The preferred first implementation is a thin FastAPI-served HTML/HTMX or static custom-element shell that consumes `/api/v2` and later Core Hub-native read endpoints. A heavier SPA should wait until the API and workflows prove the extra runtime is useful. diff --git a/src/core_hub/app.py b/src/core_hub/app.py index 0077acf..934cdee 100644 --- a/src/core_hub/app.py +++ b/src/core_hub/app.py @@ -7,6 +7,7 @@ import core_hub.models # noqa: F401 from core_hub import __version__ from core_hub.api.v2 import router as api_v2_router from core_hub.config import get_settings +from core_hub.console import router as console_router from core_hub.db import Base, get_engine from core_hub.schemas import HealthResponse, ReadinessResponse @@ -45,6 +46,7 @@ def create_app() -> FastAPI: return ReadinessResponse(service="core-hub", status=status, checks=checks) app.include_router(api_v2_router) + app.include_router(console_router) return app diff --git a/src/core_hub/console.py b/src/core_hub/console.py new file mode 100644 index 0000000..c6c04db --- /dev/null +++ b/src/core_hub/console.py @@ -0,0 +1,290 @@ +# ruff: noqa: E501 +from html import escape +from typing import Annotated, Any + +from fastapi import APIRouter, Depends +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core_hub.db import session_scope +from core_hub.models import ( + ApiConsumer, + Hub, + HubCapabilityManifest, + InteractionEvent, + MigrationRun, + Widget, +) +from core_hub.security import require_api_token + +router = APIRouter(tags=["operator-console"]) +TokenDependency = Annotated[str, Depends(require_api_token)] +SessionDependency = Annotated[AsyncSession, Depends(session_scope)] + + +CONSOLE_CSS = """ +:root { + --ink: #0a0a0a; + --ink-2: #1f1f1f; + --ink-3: #5c5c5c; + --ink-4: #8a8a8a; + --ink-5: #b5b5b3; + --line: #e5e5e2; + --line-strong: #c9c9c5; + --line-soft: #f0f0ec; + --paper: #ffffff; + --paper-2: #fafaf7; + --paper-3: #f4f4ef; + --hi: #ffe14a; + --ff-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --ff-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --r-2: 4px; +} +* { box-sizing: border-box; } +html { font-family: var(--ff-sans); color: var(--ink); background: var(--paper); } +body { margin: 0; } +a { color: inherit; text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; } +code { font-family: var(--ff-mono); font-size: 0.92em; background: var(--paper-3); padding: 1px 5px; border-radius: 2px; } +.wn-topnav { height: 56px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-5); background: var(--paper); position: sticky; top: 0; z-index: 2; } +.wn-topnav__brand { display: flex; align-items: baseline; gap: var(--sp-2); font-weight: 600; } +.wn-topnav__brand-slug { font: 500 12px/1 var(--ff-mono); color: var(--ink-3); letter-spacing: 0.04em; } +.wn-topnav__right { font: 500 11px/1 var(--ff-mono); color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.08em; } +.console-shell { min-height: calc(100vh - 56px); display: grid; grid-template-columns: 248px minmax(0, 1fr); } +.wn-sidebar { border-right: 1px solid var(--line); background: var(--paper-2); padding: var(--sp-5); position: sticky; top: 56px; height: calc(100vh - 56px); } +.wn-sidebar__group { display: grid; gap: var(--sp-2); margin-bottom: var(--sp-5); } +.wn-sidebar__group-label, .wn-eyebrow { font: 500 11px/1.2 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); } +.wn-sidebar__item { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 0; font: 500 13px/1.35 var(--ff-sans); text-decoration: none; border-bottom: 1px solid transparent; } +.wn-sidebar__item:hover { border-bottom-color: var(--line-strong); } +.wn-sidebar__count { font: 500 11px/1 var(--ff-mono); color: var(--ink-4); } +.console-main { padding: var(--sp-6); min-width: 0; } +.wn-page-header { display: grid; gap: var(--sp-3); margin-bottom: var(--sp-6); } +.wn-page-header__row { display: flex; justify-content: space-between; gap: var(--sp-5); align-items: end; } +.wn-page-header__title { font: 600 36px/1.05 var(--ff-sans); margin: 0; letter-spacing: 0; } +.wn-page-header__lede { margin: 0; max-width: 760px; color: var(--ink-3); line-height: 1.55; } +.wn-btn { display: inline-flex; align-items: center; border: 1px solid var(--ink); border-radius: var(--r-2); padding: 9px 14px; font: 500 13px/1.2 var(--ff-sans); text-decoration: none; background: var(--ink); color: var(--paper); } +.kpi-grid { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-6); } +.wn-card { background: var(--paper); border: 1px solid var(--line); border-radius: var(--r-2); padding: var(--sp-4); } +.stat-card { min-height: 96px; display: grid; align-content: space-between; gap: var(--sp-3); } +.stat-card strong { font: 600 30px/1 var(--ff-sans); } +.stat-card span { font: 500 11px/1.2 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); } +.console-section { margin-bottom: var(--sp-6); } +.section-head { display: flex; justify-content: space-between; gap: var(--sp-5); align-items: baseline; margin-bottom: var(--sp-3); } +.section-head h2 { font: 600 20px/1.2 var(--ff-sans); margin: 0; letter-spacing: 0; } +.section-head p { margin: 0; color: var(--ink-3); font-size: 13px; } +.table-wrap { border: 1px solid var(--line); border-radius: var(--r-2); overflow-x: auto; background: var(--paper); } +table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 680px; } +th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; } +th { font: 500 11px/1.2 var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); background: var(--paper-2); } +tr:last-child td { border-bottom: 0; } +.wn-tag { display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 5px 9px; font: 500 10px/1 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; color: var(--ink-3); } +.wn-tag--active, .wn-tag--done, .wn-tag--imported { background: var(--ink); border-color: var(--ink); color: var(--paper); } +.wn-tag--wait, .wn-tag--blocked, .wn-tag--degraded { background: var(--paper-3); color: var(--ink-2); } +.wn-tag--warn, .wn-tag--draft, .wn-tag--dry-run { background: var(--hi); border-color: transparent; color: #1a1500; } +.wn-empty-state { border: 1px dashed var(--line-strong); border-radius: var(--r-2); padding: var(--sp-5); color: var(--ink-3); font-size: 14px; } +.gate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); } +.gate { border: 1px solid var(--line); border-radius: var(--r-2); padding: var(--sp-4); display: grid; gap: var(--sp-2); } +.gate b { font-size: 14px; } +.gate span { color: var(--ink-3); font-size: 13px; line-height: 1.45; } +@media (max-width: 980px) { + .console-shell { grid-template-columns: 1fr; } + .wn-sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); } + .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .gate-list { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .wn-topnav { padding: 0 var(--sp-4); } + .console-main { padding: var(--sp-4); } + .wn-page-header__row { display: grid; } + .wn-page-header__title { font-size: 28px; } + .kpi-grid { grid-template-columns: 1fr; } +} +""" + + +def text(value: Any) -> str: + if value is None or value == "": + return "-" + return escape(str(value)) + + +def short_id(value: str | None) -> str: + if not value: + return "-" + return escape(value[:8]) + + +def tag(value: str | None) -> str: + status = str(value or "unknown") + css_status = escape(status.lower().replace("_", "-").replace(" ", "-")) + return f'{escape(status)}' + + +def table_or_empty(headers: list[str], rows: list[list[str]], empty: str) -> str: + if not rows: + return f'
{escape(empty)}
' + head = "".join(f"{escape(header)}" for header in headers) + body = "".join("" + "".join(f"{cell}" for cell in row) + "" for row in rows) + return f'
{head}{body}
' + + +async def fetch_rows( + session: AsyncSession, model: Any, order_by: Any | None = None, limit: int = 12 +) -> list[Any]: + statement = select(model) + if order_by is not None: + statement = statement.order_by(order_by) + statement = statement.limit(limit) + result = await session.execute(statement) + return list(result.scalars()) + + +@router.get("/console", response_class=HTMLResponse) +async def operator_console(_: TokenDependency, session: SessionDependency) -> HTMLResponse: + hubs = await fetch_rows(session, Hub, Hub.slug) + manifests = await fetch_rows( + session, HubCapabilityManifest, HubCapabilityManifest.created_at.desc() + ) + widgets = await fetch_rows(session, Widget, Widget.name) + events = await fetch_rows( + session, InteractionEvent, InteractionEvent.created_at.desc(), limit=8 + ) + consumers = await fetch_rows(session, ApiConsumer, ApiConsumer.name) + migrations = await fetch_rows(session, MigrationRun, MigrationRun.created_at.desc(), limit=8) + + active_manifests = sum(1 for item in manifests if item.status == "active") + stats = [ + ("Hubs", len(hubs)), + ("Active manifests", active_manifests), + ("Widgets", len(widgets)), + ("Events", len(events)), + ("Consumers", len(consumers)), + ("Migration runs", len(migrations)), + ] + + registry_rows = [ + [ + f"{text(hub.slug)}", + text(hub.name), + text(hub.domain), + text(hub.hub_kind), + tag(hub.status), + ] + for hub in hubs + ] + migration_rows = [ + [ + f"{short_id(run.id)}", + text(run.source), + text(run.schema_version), + f"{short_id(run.bundle_sha256)}", + tag("dry-run" if run.dry_run else run.status), + ] + for run in migrations + ] + consumer_rows = [ + [ + f"{text(consumer.slug)}", + text(consumer.name), + text(consumer.key_prefix), + text(consumer.rate_limit_per_minute), + tag(consumer.status), + ] + for consumer in consumers + ] + event_rows = [ + [ + f"{short_id(event.id)}", + text(event.event_type), + f"{short_id(event.widget_id)}", + text(event.view_context), + ] + for event in events + ] + + stat_cards = "".join( + f'
{escape(label)}{value}
' + for label, value in stats + ) + + html = f""" + + + + + Core Hub operator console + + + +
+
Core Hub/ operator
+
protected console
+
+
+ +
+
+ INFOTECH / CORE-HUB +
+

Operator console

+ OpenAPI +
+

Registry, migration, access, and event state from the Core Hub replacement runtime.

+
+ +
{stat_cards}
+ +
+

Readiness gates

Current blocker map

+
+
{tag("blocked")} ops-hub smokeLocal smoke passed. Closure waits on deployed runtime and approved credential routing.
+
{tag("blocked")} staging importImporter is ready. Closure waits on approved Inter-Hub export bundle and staging DB.
+
{tag("blocked")} activity-core smokeWaits on deployed Core Hub runtime or explicit transition fallback decision.
+
{tag("active")} console prototypeProtected read-only console surface is active.
+
+
+ +
+

Hub registry

Hubs visible to the protected runtime

+ {table_or_empty(["Slug", "Name", "Domain", "Kind", "Status"], registry_rows, "No hubs registered yet.")} +
+ +
+

Migration runs

Import evidence and bundle identity

+ {table_or_empty(["Run", "Source", "Schema", "Bundle", "Status"], migration_rows, "No migration runs recorded yet.")} +
+ +
+

Access

Non-secret API consumer metadata

+ {table_or_empty(["Slug", "Name", "Key prefix", "Rate/min", "Status"], consumer_rows, "No API consumers registered yet.")} +
+ +
+

Recent events

Latest interaction evidence

+ {table_or_empty(["Event", "Type", "Widget", "Context"], event_rows, "No interaction events recorded yet.")} +
+
+
+ +""" + return HTMLResponse(html) diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..c061de1 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,86 @@ +OPERATOR_HEADERS = {"Authorization": "Bearer operator-token"} + + +def seed_console_data(client): + hub = client.post( + "/api/v2/hubs", + headers=OPERATOR_HEADERS, + json={ + "slug": "ops-hub", + "name": "Ops Hub", + "domain": "ops.coulomb.social", + "hubKind": "domain", + "hubFamily": "vsm", + "vsmFunction": "OPS", + "vsmSystem": "1", + }, + ).json() + manifest = client.post( + "/api/v2/hub-capability-manifests", + headers=OPERATOR_HEADERS, + json={"hubId": hub["id"], "manifestVersion": "1.0"}, + ).json() + client.post( + f"/api/v2/hub-capability-manifests/{manifest['id']}/activate", + headers=OPERATOR_HEADERS, + ) + consumer = client.post( + "/api/v2/api-consumers", + headers=OPERATOR_HEADERS, + json={ + "name": "ops-hub", + "description": "API consumer for the VSM Operations hub", + "hubCapabilityManifestId": manifest["id"], + "rateLimitPerMinute": 120, + }, + ).json() + key_payload = client.post( + f"/api/v2/api-consumers/{consumer['id']}/api-keys", + headers=OPERATOR_HEADERS, + json={"scopes": "framework:read hub:ops-hub:write"}, + ).json() + runtime_headers = {"Authorization": f"Bearer {key_payload['fullKey']}"} + widget = client.post( + "/api/v2/widgets", + headers=runtime_headers, + json={ + "hubId": hub["id"], + "name": "Readiness", + "widgetType": "ops-readiness-gate", + "capabilityRef": "ops:readiness", + "viewContext": "ops-hub/readiness", + "policyScope": "ops-registry", + }, + ).json() + client.post( + "/api/v2/interaction-events", + headers=runtime_headers, + json={ + "widgetId": widget["id"], + "eventType": "ops-endpoint-verified", + "metadata": {"expectedStatus": 401}, + }, + ) + return key_payload + + +def test_console_requires_operator_auth(client): + response = client.get("/console") + + assert response.status_code == 401 + + +def test_console_renders_operational_state_without_full_key(client): + key_payload = seed_console_data(client) + + response = client.get("/console", headers=OPERATOR_HEADERS) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + body = response.text + assert "Operator console" in body + assert "ops-hub" in body + assert "ops-endpoint-verified" in body + assert key_payload["apiKey"]["keyPrefix"] in body + assert key_payload["fullKey"] not in body + assert "fullKey" not in body diff --git a/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md b/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md index 26e9cdd..f03cde7 100644 --- a/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md +++ b/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md @@ -42,12 +42,12 @@ Result 2026-06-27: Added `docs/specs/whynot-ui-adapter.md`, mapping whynot-desig ```task id: CORE-WP-0006-T03 -status: wait +status: done priority: medium state_hub_task_id: "6da92878-f0a4-4ff0-8b46-4629c646a7d5" ``` -Build first operator console prototype after the FastAPI foundation exposes stable read endpoints. +Result 2026-06-27: Added protected `/console` prototype in FastAPI with whynot-aligned tokens/classes, readiness gates, hub registry, migration runs, non-secret API consumer metadata, recent events, and tests covering auth plus full-key non-disclosure. ## Add Visual Checks @@ -58,4 +58,4 @@ priority: low state_hub_task_id: "12628328-6699-4abf-aa95-c0a07542f3e9" ``` -Add Playwright visual checks once real UI surfaces exist. +Add Playwright visual checks for `/console` across desktop and mobile, including non-overlap checks and protected-state screenshot handling.