From 4b9c2160705dd9ed59404b9701e35eaaa40be66a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 27 Jun 2026 21:23:52 +0200 Subject: [PATCH] Add Core Hub operator CLI wrappers --- Makefile | 6 +- docs/deployment/operator-cli.md | 46 ++ docs/specs/testing-release-and-migration.md | 8 + scripts/core_hub_cli.py | 7 + src/core_hub/operator_cli.py | 417 ++++++++++++++++++ tests/test_operator_cli.py | 130 ++++++ ...008-api-first-ops-evidence-continuation.md | 9 +- 7 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 docs/deployment/operator-cli.md create mode 100755 scripts/core_hub_cli.py create mode 100644 src/core_hub/operator_cli.py create mode 100644 tests/test_operator_cli.py diff --git a/Makefile b/Makefile index 61a1604..d8e532a 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: install test lint run openapi migrate-validate deployed-smoke staging-profile-check container-build playwright-install visual-check +.PHONY: install test lint run openapi migrate-validate deployed-smoke operator-cli staging-profile-check container-build playwright-install visual-check UV ?= /home/worsch/.local/bin/uv PYTHONPATH ?= src IMAGE_REPOSITORY ?= gitea.coulomb.social/coulomb/core-hub IMAGE_TAG ?= dev +CLI_ARGS ?= install: $(UV) sync --extra dev @@ -27,6 +28,9 @@ migrate-validate: deployed-smoke: PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_deployed_smoke.py +operator-cli: + PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_cli.py $(CLI_ARGS) + staging-profile-check: PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/check_staging_profile.py diff --git a/docs/deployment/operator-cli.md b/docs/deployment/operator-cli.md new file mode 100644 index 0000000..b30bb20 --- /dev/null +++ b/docs/deployment/operator-cli.md @@ -0,0 +1,46 @@ +# Core Hub Operator CLI + +`make operator-cli` runs thin operator wrappers around Core Hub API behavior that +is already covered by tests and smoke scripts. It does not introduce a second +implementation path for Core Hub state. + +## Commands + +Run the deployed API smoke and emit the same non-secret evidence report as +`make deployed-smoke`: + +```bash +CORE_HUB_BASE_URL=https://core-hub-staging.example.invalid \ +CORE_HUB_OPERATOR_TOKEN_FILE=/secure/operator/path/core-hub-operator-token \ +make operator-cli CLI_ARGS="deployed-smoke --output .local/smoke/core-hub-staging.json" +``` + +Summarize ops-hub bootstrap state through protected `/api/v2` routes: + +```bash +CORE_HUB_BASE_URL=https://core-hub-staging.example.invalid \ +CORE_HUB_OPERATOR_TOKEN_FILE=/secure/operator/path/core-hub-operator-token \ +make operator-cli CLI_ARGS="ops-bootstrap-status" +``` + +Validate or import a migration bundle using the same migration code as +`scripts/core_hub_migrate.py`: + +```bash +make operator-cli CLI_ARGS="migration validate /secure/operator/path/interhub-export.bundle.json" +CORE_HUB_DATABASE_URL=postgresql+asyncpg://... \ +make operator-cli CLI_ARGS="migration import /secure/operator/path/interhub-export.bundle.json --dry-run" +``` + +Summarize cutover gates from non-secret evidence reports: + +```bash +make operator-cli CLI_ARGS="readiness-summary \ + --deployed-smoke-report .local/smoke/core-hub-staging.json \ + --migration-report .local/migration/interhub-import.json \ + --activity-core-report .local/smoke/activity-core-core-hub.json" +``` + +The readiness summary treats a migration dry-run as open, not cutover-ready. A +legacy Inter-Hub reference smoke can be supplied with `--inter-hub-report`; it is +reported as an advisory open gate when omitted. diff --git a/docs/specs/testing-release-and-migration.md b/docs/specs/testing-release-and-migration.md index 5821eed..4a2dfde 100644 --- a/docs/specs/testing-release-and-migration.md +++ b/docs/specs/testing-release-and-migration.md @@ -58,6 +58,14 @@ runtime shape, secret references, Alembic migration job, image build command, health/readiness checks, rollback notes, and the handoff to `CORE-WP-0005-T02` staging import. +## Operator CLI Wrappers + +Thin CLI wrappers live in `scripts/core_hub_cli.py` and are documented +in `docs/deployment/operator-cli.md`. Run them through `make +operator-cli CLI_ARGS=" ..."`. They cover deployed smoke evidence, ops-hub +bootstrap status checks, migration validate/import, and readiness +summaries from non-secret evidence reports. + ## Release Gates A Core Hub release cannot replace Inter-Hub until: diff --git a/scripts/core_hub_cli.py b/scripts/core_hub_cli.py new file mode 100755 index 0000000..267c3e6 --- /dev/null +++ b/scripts/core_hub_cli.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from core_hub.operator_cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/core_hub/operator_cli.py b/src/core_hub/operator_cli.py new file mode 100644 index 0000000..455aff7 --- /dev/null +++ b/src/core_hub/operator_cli.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +import httpx + +from core_hub.db import get_sessionmaker +from core_hub.migration import import_bundle, validate_bundle +from core_hub.smoke import ( + DEFAULT_OPERATOR_TOKEN_ENV, + DEFAULT_RUNTIME_TOKEN_ENV, + SmokeError, + load_secret_value, + normalize_base_url, + run_smoke, +) + + +def load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def load_bundle(path: Path) -> dict[str, Any]: + value = load_json(path) + if not isinstance(value, dict): + raise SystemExit("migration bundle must be a JSON object") + return value + + +def print_report(report: Any, output_path: Path | None = None) -> None: + body = json.dumps(report, indent=2, sort_keys=True) + if output_path is not None: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(body + "\n", encoding="utf-8") + print(body) + + +def optional_path(value: str | None) -> Path | None: + return Path(value) if value else None + + +def failure_report(reason: str, **extra: Any) -> dict[str, Any]: + return {"ok": False, "failures": [reason], **extra} + + +def operator_token_from_args(args: argparse.Namespace) -> str: + return load_secret_value(args.operator_token_env, optional_path(args.operator_token_file)) + + +def runtime_token_from_args(args: argparse.Namespace) -> str: + return load_secret_value(args.runtime_token_env, optional_path(args.runtime_token_file)) + + +def api_get(client: Any, path: str, token: str) -> Any: + response = client.request( + "GET", + path, + headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}, + ) + if response.status_code != 200: + raise SmokeError(f"GET {path} returned HTTP {response.status_code}") + return response.json() + + +def page_items(payload: Any, path: str) -> list[dict[str, Any]]: + if not isinstance(payload, dict) or not isinstance(payload.get("data"), list): + raise SmokeError(f"expected paginated data array from {path}") + return [item for item in payload["data"] if isinstance(item, dict)] + + +def collect_bootstrap_status(client: Any, operator_token: str) -> dict[str, Any]: + failures: list[str] = [] + report: dict[str, Any] = { + "ok": False, + "hubs": {"count": 0, "opsHubCandidates": []}, + "manifests": {"count": 0, "active": 0}, + "apiConsumers": {"count": 0, "opsHubCandidates": []}, + "widgets": {"count": 0, "opsReadinessGates": 0}, + "interactionEvents": {"count": 0, "opsEndpointVerified": 0}, + "hubRegistry": {"hubs": 0, "hubCapabilityManifests": 0}, + "failures": failures, + } + + try: + hubs = page_items(api_get(client, "/api/v2/hubs", operator_token), "/api/v2/hubs") + manifests = page_items( + api_get(client, "/api/v2/hub-capability-manifests", operator_token), + "/api/v2/hub-capability-manifests", + ) + consumers = page_items( + api_get(client, "/api/v2/api-consumers", operator_token), + "/api/v2/api-consumers", + ) + widgets = page_items(api_get(client, "/api/v2/widgets", operator_token), "/api/v2/widgets") + events = page_items( + api_get(client, "/api/v2/interaction-events", operator_token), + "/api/v2/interaction-events", + ) + registry_payload = api_get(client, "/api/v2/hub-registry", operator_token) + except SmokeError as exc: + failures.append(str(exc)) + return report + + hub_candidates = [ + {"id": hub.get("id"), "slug": hub.get("slug"), "status": hub.get("status")} + for hub in hubs + if str(hub.get("slug", "")).startswith("ops-hub") + ] + consumer_candidates = [ + { + "id": consumer.get("id"), + "slug": consumer.get("slug"), + "name": consumer.get("name"), + "keyPrefix": consumer.get("keyPrefix"), + "status": consumer.get("status"), + } + for consumer in consumers + if "ops-hub" in str(consumer.get("slug") or consumer.get("name") or "") + ] + registry = registry_payload.get("data") if isinstance(registry_payload, dict) else {} + registry_hubs = registry.get("hubs", []) if isinstance(registry, dict) else [] + registry_manifests = ( + registry.get("hubCapabilityManifests", []) if isinstance(registry, dict) else [] + ) + + report["hubs"] = {"count": len(hubs), "opsHubCandidates": hub_candidates} + report["manifests"] = { + "count": len(manifests), + "active": sum(1 for manifest in manifests if manifest.get("status") == "active"), + } + report["apiConsumers"] = { + "count": len(consumers), + "opsHubCandidates": consumer_candidates, + } + report["widgets"] = { + "count": len(widgets), + "opsReadinessGates": sum( + 1 for widget in widgets if widget.get("widgetType") == "ops-readiness-gate" + ), + } + report["interactionEvents"] = { + "count": len(events), + "opsEndpointVerified": sum( + 1 for event in events if event.get("eventType") == "ops-endpoint-verified" + ), + } + report["hubRegistry"] = { + "hubs": len(registry_hubs) if isinstance(registry_hubs, list) else 0, + "hubCapabilityManifests": ( + len(registry_manifests) if isinstance(registry_manifests, list) else 0 + ), + } + + required = { + "ops_hub": bool(hub_candidates), + "active_manifest": report["manifests"]["active"] > 0, + "api_consumer": bool(consumer_candidates), + "widget": report["widgets"]["opsReadinessGates"] > 0, + "event": report["interactionEvents"]["opsEndpointVerified"] > 0, + "registry": report["hubRegistry"]["hubs"] > 0, + } + report["required"] = required + for name, ok in required.items(): + if not ok: + failures.append(f"missing {name}") + report["ok"] = not failures + return report + + +def gate( + name: str, + ok: bool, + evidence: dict[str, Any] | list[Any] | None, + reason: str, +) -> dict[str, Any]: + return {"name": name, "ok": ok, "reason": "ok" if ok else reason, "evidence": evidence or {}} + + +def build_readiness_summary( + *, + deployed_smoke_report: Path | None = None, + migration_report: Path | None = None, + activity_core_report: Path | None = None, + inter_hub_report: Path | None = None, +) -> dict[str, Any]: + gates: list[dict[str, Any]] = [] + + smoke = load_json(deployed_smoke_report) if deployed_smoke_report else None + smoke_ok = isinstance(smoke, dict) and smoke.get("ok") is True + smoke_evidence = {} + if isinstance(smoke, dict): + smoke_evidence = { + "runId": smoke.get("runId"), + "checks": len(smoke.get("checks", [])) if isinstance(smoke.get("checks"), list) else 0, + "eventId": (smoke.get("bootstrap") or {}).get("event", {}).get("id"), + } + gates.append( + gate( + "deployed_api_smoke", + smoke_ok, + smoke_evidence, + "missing or failed Core Hub smoke", + ) + ) + + migration = load_json(migration_report) if migration_report else None + migration_ok = ( + isinstance(migration, dict) + and migration.get("ok") is True + and migration.get("dryRun") is False + ) + migration_evidence = {} + if isinstance(migration, dict): + migration_evidence = { + "source": migration.get("source"), + "bundleSha256": migration.get("bundleSha256"), + "dryRun": migration.get("dryRun"), + "counts": migration.get("counts"), + } + gates.append( + gate( + "staging_import", + migration_ok, + migration_evidence, + "missing, failed, or dry-run-only migration import report", + ) + ) + + activity = load_json(activity_core_report) if activity_core_report else None + if isinstance(activity, list): + activity_results = activity + elif isinstance(activity, dict) and isinstance(activity.get("results"), list): + activity_results = activity["results"] + elif isinstance(activity, dict): + activity_results = [activity] + else: + activity_results = [] + activity_ok = any( + isinstance(result, dict) + and result.get("status") == "posted" + and result.get("verified") is True + for result in activity_results + ) + gates.append( + gate( + "activity_core_sink", + activity_ok, + { + "posted": sum( + 1 + for result in activity_results + if isinstance(result, dict) and result.get("status") == "posted" + ) + }, + "missing or unverified activity-core Core Hub sink evidence", + ) + ) + + inter_hub = load_json(inter_hub_report) if inter_hub_report else None + inter_hub_ok = isinstance(inter_hub, dict) and inter_hub.get("ok") is True + gates.append( + gate( + "legacy_inter_hub_reference", + inter_hub_ok, + {"provided": inter_hub_report is not None}, + "missing legacy Inter-Hub reference smoke evidence", + ) + ) + + required_gate_names = {"deployed_api_smoke", "staging_import", "activity_core_sink"} + ready_for_cutover = all( + item["ok"] for item in gates if item["name"] in required_gate_names + ) + return { + "ok": ready_for_cutover, + "readyForCutover": ready_for_cutover, + "gates": gates, + "openGates": [item["name"] for item in gates if not item["ok"]], + } + + +async def run_import(path: Path, dry_run: bool) -> dict[str, Any]: + bundle = load_bundle(path) + sessionmaker = get_sessionmaker() + async with sessionmaker() as session: + return await import_bundle(session, bundle, dry_run=dry_run) + + +def add_token_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--base-url", default=os.environ.get("CORE_HUB_BASE_URL")) + parser.add_argument( + "--operator-token-env", + default=os.environ.get("CORE_HUB_OPERATOR_TOKEN_ENV", DEFAULT_OPERATOR_TOKEN_ENV), + ) + parser.add_argument( + "--operator-token-file", + default=os.environ.get("CORE_HUB_OPERATOR_TOKEN_FILE"), + ) + parser.add_argument( + "--runtime-token-env", + default=os.environ.get("CORE_HUB_RUNTIME_TOKEN_ENV", DEFAULT_RUNTIME_TOKEN_ENV), + ) + parser.add_argument( + "--runtime-token-file", + default=os.environ.get("CORE_HUB_RUNTIME_TOKEN_FILE"), + ) + parser.add_argument("--timeout", type=float, default=30.0) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Core Hub operator CLI wrappers.") + parser.add_argument("--output", type=Path, default=None) + subparsers = parser.add_subparsers(dest="command", required=True) + + smoke_parser = subparsers.add_parser("deployed-smoke", help="Run deployed API smoke.") + add_token_args(smoke_parser) + smoke_parser.add_argument( + "--runtime-token-output", + default=os.environ.get("CORE_HUB_RUNTIME_TOKEN_OUTPUT"), + ) + smoke_parser.add_argument("--run-id", default=os.environ.get("CORE_HUB_SMOKE_RUN_ID")) + + status_parser = subparsers.add_parser( + "ops-bootstrap-status", help="Summarize ops-hub API state." + ) + add_token_args(status_parser) + + migration_parser = subparsers.add_parser("migration", help="Migration bundle commands.") + migration_subparsers = migration_parser.add_subparsers(dest="migration_command", required=True) + validate_parser = migration_subparsers.add_parser("validate") + validate_parser.add_argument("bundle", type=Path) + import_parser = migration_subparsers.add_parser("import") + import_parser.add_argument("bundle", type=Path) + import_parser.add_argument("--dry-run", action="store_true") + + readiness_parser = subparsers.add_parser("readiness-summary", help="Summarize cutover gates.") + readiness_parser.add_argument("--deployed-smoke-report", type=Path) + readiness_parser.add_argument("--migration-report", type=Path) + readiness_parser.add_argument("--activity-core-report", type=Path) + readiness_parser.add_argument("--inter-hub-report", type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "deployed-smoke": + if not args.base_url: + print_report(failure_report("set CORE_HUB_BASE_URL or --base-url"), args.output) + return 2 + try: + base_url = normalize_base_url(args.base_url) + operator_token = operator_token_from_args(args) + runtime_token = runtime_token_from_args(args) + except (OSError, SmokeError) as exc: + print_report(failure_report(str(exc)), args.output) + return 2 + if not operator_token: + print_report(failure_report("operator token was not provided"), args.output) + return 2 + with httpx.Client(base_url=base_url, timeout=args.timeout) as client: + report = run_smoke( + client, + base_url=base_url, + operator_token=operator_token, + runtime_token=runtime_token or None, + runtime_token_output=optional_path(args.runtime_token_output), + run_id=args.run_id, + ) + print_report(report, args.output) + return 0 if report.get("ok") else 1 + + if args.command == "ops-bootstrap-status": + if not args.base_url: + print_report(failure_report("set CORE_HUB_BASE_URL or --base-url"), args.output) + return 2 + try: + base_url = normalize_base_url(args.base_url) + operator_token = operator_token_from_args(args) + except (OSError, SmokeError) as exc: + print_report(failure_report(str(exc)), args.output) + return 2 + if not operator_token: + print_report(failure_report("operator token was not provided"), args.output) + return 2 + with httpx.Client(base_url=base_url, timeout=args.timeout) as client: + report = collect_bootstrap_status(client, operator_token) + print_report(report, args.output) + return 0 if report.get("ok") else 1 + + if args.command == "migration": + if args.migration_command == "validate": + report = validate_bundle(load_bundle(args.bundle)) + else: + report = asyncio.run(run_import(args.bundle, dry_run=args.dry_run)) + print_report(report, args.output) + return 0 if report.get("ok") else 1 + + if args.command == "readiness-summary": + report = build_readiness_summary( + deployed_smoke_report=args.deployed_smoke_report, + migration_report=args.migration_report, + activity_core_report=args.activity_core_report, + inter_hub_report=args.inter_hub_report, + ) + print_report(report, args.output) + return 0 if report.get("ok") else 1 + + parser.error(f"unknown command: {args.command}") + return 2 diff --git a/tests/test_operator_cli.py b/tests/test_operator_cli.py new file mode 100644 index 0000000..f715486 --- /dev/null +++ b/tests/test_operator_cli.py @@ -0,0 +1,130 @@ +import json + +from core_hub.operator_cli import build_readiness_summary, collect_bootstrap_status + +OPERATOR_HEADERS = {"Authorization": "Bearer operator-token"} + + +def _seed_ops_hub(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() + manifest = client.post( + f"/api/v2/hub-capability-manifests/{manifest['id']}/activate", + headers=OPERATOR_HEADERS, + ).json() + consumer = client.post( + "/api/v2/api-consumers", + headers=OPERATOR_HEADERS, + json={"slug": "ops-hub", "name": "ops-hub", "hubCapabilityManifestId": manifest["id"]}, + ).json() + key = client.post( + f"/api/v2/api-consumers/{consumer['id']}/api-keys", + headers=OPERATOR_HEADERS, + json={"scopes": "framework:read hub:ops-hub:read hub:ops-hub:write"}, + ).json()["fullKey"] + runtime_headers = {"Authorization": f"Bearer {key}"} + widget = client.post( + "/api/v2/widgets", + headers=runtime_headers, + json={ + "hubId": hub["id"], + "name": "Gitea Registry Readiness", + "widgetType": "ops-readiness-gate", + "capabilityRef": "ops:readiness:gitea-registry", + "viewContext": "ops-hub/readiness/gitea-registry", + "policyScope": "ops-registry", + }, + ).json() + client.post( + "/api/v2/interaction-events", + headers=runtime_headers, + json={ + "widgetId": widget["id"], + "eventType": "ops-endpoint-verified", + "viewContext": "railiance-apps/workplans/RAIL-AP-WP-0001", + "metadata": {"expectedStatus": 401}, + }, + ) + return key + + +def test_bootstrap_status_summarizes_ops_hub_without_secret_leak(client): + full_key = _seed_ops_hub(client) + + report = collect_bootstrap_status(client, "operator-token") + + assert report["ok"] is True + assert report["hubs"]["opsHubCandidates"][0]["slug"] == "ops-hub" + assert report["manifests"]["active"] == 1 + assert report["apiConsumers"]["opsHubCandidates"][0]["keyPrefix"].startswith("ch_") + assert report["widgets"]["opsReadinessGates"] == 1 + assert report["interactionEvents"]["opsEndpointVerified"] == 1 + + rendered = json.dumps(report, sort_keys=True) + assert full_key not in rendered + assert "operator-token" not in rendered + + +def test_readiness_summary_reports_cutover_ready_from_non_secret_evidence(tmp_path): + smoke = tmp_path / "smoke.json" + smoke.write_text( + json.dumps( + { + "ok": True, + "runId": "smoke-1", + "checks": [{"ok": True}], + "bootstrap": {"event": {"id": "event-1"}}, + } + ), + encoding="utf-8", + ) + migration = tmp_path / "migration.json" + migration.write_text( + json.dumps( + { + "ok": True, + "dryRun": False, + "source": "inter-hub-haskell", + "bundleSha256": "abc", + "counts": {}, + } + ), + encoding="utf-8", + ) + activity = tmp_path / "activity.json" + activity.write_text(json.dumps([{"status": "posted", "verified": True}]), encoding="utf-8") + + report = build_readiness_summary( + deployed_smoke_report=smoke, + migration_report=migration, + activity_core_report=activity, + ) + + assert report["readyForCutover"] is True + assert "legacy_inter_hub_reference" in report["openGates"] + + +def test_readiness_summary_keeps_dry_run_import_open(tmp_path): + migration = tmp_path / "migration.json" + migration.write_text(json.dumps({"ok": True, "dryRun": True}), encoding="utf-8") + + report = build_readiness_summary(migration_report=migration) + + assert report["readyForCutover"] is False + assert "staging_import" in report["openGates"] diff --git a/workplans/CORE-WP-0008-api-first-ops-evidence-continuation.md b/workplans/CORE-WP-0008-api-first-ops-evidence-continuation.md index 007c2e7..8c95c3e 100644 --- a/workplans/CORE-WP-0008-api-first-ops-evidence-continuation.md +++ b/workplans/CORE-WP-0008-api-first-ops-evidence-continuation.md @@ -195,7 +195,7 @@ staging cluster has already been deployed. ```task id: CORE-WP-0008-T05 -status: todo +status: done priority: medium state_hub_task_id: "a8b5d576-428e-4060-bf3d-46f54541bbe1" ``` @@ -212,6 +212,13 @@ Initial commands should cover: The CLI must call the same API paths as tests and smokes. It must not become a second implementation of Core Hub behavior. +Completed 2026-06-27: added `scripts/core_hub_cli.py` backed by +`core_hub.operator_cli`, plus `make operator-cli`, documentation, and tests. +The CLI wraps the existing deployed smoke logic, protected `/api/v2` ops-hub +bootstrap status checks, migration validate/import code, and a cutover +readiness summary over non-secret evidence reports. It reports ids, counts, +statuses, and key prefixes only; no full token material is emitted. + ## Task: Gate and design the web UI rebuild ```task