Add protected operator console prototype

This commit is contained in:
2026-06-27 14:11:56 +02:00
parent fbcee008da
commit 32930043ff
6 changed files with 443 additions and 3 deletions

View File

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

View File

@@ -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.

View File

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

290
src/core_hub/console.py Normal file
View File

@@ -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'<span class="wn-tag wn-tag--{css_status}">{escape(status)}</span>'
def table_or_empty(headers: list[str], rows: list[list[str]], empty: str) -> str:
if not rows:
return f'<div class="wn-empty-state">{escape(empty)}</div>'
head = "".join(f"<th>{escape(header)}</th>" for header in headers)
body = "".join("<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>" for row in rows)
return f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>'
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"<code>{text(hub.slug)}</code>",
text(hub.name),
text(hub.domain),
text(hub.hub_kind),
tag(hub.status),
]
for hub in hubs
]
migration_rows = [
[
f"<code>{short_id(run.id)}</code>",
text(run.source),
text(run.schema_version),
f"<code>{short_id(run.bundle_sha256)}</code>",
tag("dry-run" if run.dry_run else run.status),
]
for run in migrations
]
consumer_rows = [
[
f"<code>{text(consumer.slug)}</code>",
text(consumer.name),
text(consumer.key_prefix),
text(consumer.rate_limit_per_minute),
tag(consumer.status),
]
for consumer in consumers
]
event_rows = [
[
f"<code>{short_id(event.id)}</code>",
text(event.event_type),
f"<code>{short_id(event.widget_id)}</code>",
text(event.view_context),
]
for event in events
]
stat_cards = "".join(
f'<article class="wn-card stat-card"><span>{escape(label)}</span><strong>{value}</strong></article>'
for label, value in stats
)
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Core Hub operator console</title>
<style>{CONSOLE_CSS}</style>
</head>
<body>
<header class="wn-topnav">
<div class="wn-topnav__brand"><span>Core Hub</span><span class="wn-topnav__brand-slug">/ operator</span></div>
<div class="wn-topnav__right">protected console</div>
</header>
<div class="console-shell">
<aside class="wn-sidebar">
<div class="wn-sidebar__group">
<div class="wn-sidebar__group-label">Views</div>
<a class="wn-sidebar__item" href="#overview">Overview <span class="wn-sidebar__count">{len(stats)}</span></a>
<a class="wn-sidebar__item" href="#registry">Registry <span class="wn-sidebar__count">{len(hubs)}</span></a>
<a class="wn-sidebar__item" href="#migration">Migration <span class="wn-sidebar__count">{len(migrations)}</span></a>
<a class="wn-sidebar__item" href="#access">Access <span class="wn-sidebar__count">{len(consumers)}</span></a>
<a class="wn-sidebar__item" href="#events">Events <span class="wn-sidebar__count">{len(events)}</span></a>
</div>
<div class="wn-sidebar__group">
<div class="wn-sidebar__group-label">State</div>
<span class="wn-sidebar__item">Compatibility <span class="wn-sidebar__count">2/4</span></span>
<span class="wn-sidebar__item">Migration <span class="wn-sidebar__count">1/4</span></span>
<span class="wn-sidebar__item">Console <span class="wn-sidebar__count">3/4</span></span>
</div>
</aside>
<main class="console-main">
<section id="overview" class="wn-page-header">
<span class="wn-eyebrow">INFOTECH / CORE-HUB</span>
<div class="wn-page-header__row">
<h1 class="wn-page-header__title">Operator console</h1>
<a class="wn-btn" href="/api/v2/openapi.json">OpenAPI</a>
</div>
<p class="wn-page-header__lede">Registry, migration, access, and event state from the Core Hub replacement runtime.</p>
</section>
<section class="kpi-grid" aria-label="Core Hub counters">{stat_cards}</section>
<section class="console-section" aria-labelledby="gates-heading">
<div class="section-head"><h2 id="gates-heading">Readiness gates</h2><p>Current blocker map</p></div>
<div class="gate-list">
<div class="gate"><b>{tag("blocked")} ops-hub smoke</b><span>Local smoke passed. Closure waits on deployed runtime and approved credential routing.</span></div>
<div class="gate"><b>{tag("blocked")} staging import</b><span>Importer is ready. Closure waits on approved Inter-Hub export bundle and staging DB.</span></div>
<div class="gate"><b>{tag("blocked")} activity-core smoke</b><span>Waits on deployed Core Hub runtime or explicit transition fallback decision.</span></div>
<div class="gate"><b>{tag("active")} console prototype</b><span>Protected read-only console surface is active.</span></div>
</div>
</section>
<section id="registry" class="console-section" aria-labelledby="registry-heading">
<div class="section-head"><h2 id="registry-heading">Hub registry</h2><p>Hubs visible to the protected runtime</p></div>
{table_or_empty(["Slug", "Name", "Domain", "Kind", "Status"], registry_rows, "No hubs registered yet.")}
</section>
<section id="migration" class="console-section" aria-labelledby="migration-heading">
<div class="section-head"><h2 id="migration-heading">Migration runs</h2><p>Import evidence and bundle identity</p></div>
{table_or_empty(["Run", "Source", "Schema", "Bundle", "Status"], migration_rows, "No migration runs recorded yet.")}
</section>
<section id="access" class="console-section" aria-labelledby="access-heading">
<div class="section-head"><h2 id="access-heading">Access</h2><p>Non-secret API consumer metadata</p></div>
{table_or_empty(["Slug", "Name", "Key prefix", "Rate/min", "Status"], consumer_rows, "No API consumers registered yet.")}
</section>
<section id="events" class="console-section" aria-labelledby="events-heading">
<div class="section-head"><h2 id="events-heading">Recent events</h2><p>Latest interaction evidence</p></div>
{table_or_empty(["Event", "Type", "Widget", "Context"], event_rows, "No interaction events recorded yet.")}
</section>
</main>
</div>
</body>
</html>"""
return HTMLResponse(html)

86
tests/test_console.py Normal file
View File

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

View File

@@ -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.