generated from coulomb/repo-seed
Add protected operator console prototype
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
290
src/core_hub/console.py
Normal 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
86
tests/test_console.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user