Compare commits
5 commits
e896a60bc0
...
0aeacfd14f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aeacfd14f | |||
| 61dee644d7 | |||
| 02f1237a90 | |||
| b27c5d1981 | |||
| e77165ed72 |
22 changed files with 3855 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ __pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
uv.lock
|
uv.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
web/frontend/node_modules/
|
||||||
|
web/frontend/dist/
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"feature_directory":"specs/001-market-first-wedge"}
|
{"feature_directory":"specs/002-market-web"}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ description = "M2 Marketplace monorepo — schemas, ledger, CLI, indexer (spec 0
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
members = ["ledger", "cli", "indexer"]
|
members = ["ledger", "cli", "indexer", "web/backend"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
|
||||||
24
web/Dockerfile
Normal file
24
web/Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# m2-market-web — FastAPI proxy + React SPA in one container (specs/002-market-web).
|
||||||
|
# Build context: repo root (needs web/backend + web/frontend).
|
||||||
|
|
||||||
|
FROM node:20-slim AS frontend
|
||||||
|
WORKDIR /fe
|
||||||
|
COPY web/frontend/package.json web/frontend/package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY web/frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY web/backend/pyproject.toml ./backend/pyproject.toml
|
||||||
|
COPY web/backend/src ./backend/src
|
||||||
|
RUN pip install --no-cache-dir ./backend
|
||||||
|
COPY --from=frontend /fe/dist ./frontend/dist
|
||||||
|
ENV M2MW_STATIC_DIR=/app/frontend/dist
|
||||||
|
|
||||||
|
RUN groupadd --system web && useradd --system --gid web web
|
||||||
|
USER web
|
||||||
|
EXPOSE 8000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" || exit 1
|
||||||
|
CMD ["uvicorn", "m2_market_web.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
18
web/backend/pyproject.toml
Normal file
18
web/backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[project]
|
||||||
|
name = "m2-market-web"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI backend proxy for the M2 Market web app"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"itsdangerous>=2.2",
|
||||||
|
"uvicorn>=0.30",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/m2_market_web"]
|
||||||
1
web/backend/src/m2_market_web/__init__.py
Normal file
1
web/backend/src/m2_market_web/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""M2 Market web backend."""
|
||||||
65
web/backend/src/m2_market_web/auth.py
Normal file
65
web/backend/src/m2_market_web/auth.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
COOKIE_NAME = "m2mw_session"
|
||||||
|
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
passcode: str
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
secret = os.environ.get("SESSION_SECRET")
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=500, detail="session_not_configured")
|
||||||
|
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_passcode() -> str:
|
||||||
|
passcode = os.environ.get("FLEET_PASSCODE")
|
||||||
|
if not passcode:
|
||||||
|
raise HTTPException(status_code=500, detail="auth_not_configured")
|
||||||
|
return passcode
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def login(body: LoginBody, response: Response) -> None:
|
||||||
|
if not hmac.compare_digest(body.passcode, _configured_passcode()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
||||||
|
token = _serializer().dumps({"scope": "fleet"})
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME,
|
||||||
|
token,
|
||||||
|
max_age=SESSION_MAX_AGE_SECONDS,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_session(
|
||||||
|
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
|
||||||
|
) -> None:
|
||||||
|
if not session_cookie:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
||||||
|
try:
|
||||||
|
payload = _serializer().loads(session_cookie, max_age=SESSION_MAX_AGE_SECONDS)
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
|
||||||
|
) from None
|
||||||
|
if payload.get("scope") != "fleet":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
||||||
|
|
||||||
|
|
||||||
|
SessionDependency = Depends(require_session)
|
||||||
117
web/backend/src/m2_market_web/catalog.py
Normal file
117
web/backend/src/m2_market_web/catalog.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .auth import SessionDependency
|
||||||
|
from .http import client, upstream_error
|
||||||
|
|
||||||
|
PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog")
|
||||||
|
TENANT_DEFAULT = "m2-core"
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[SessionDependency])
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant() -> str:
|
||||||
|
return os.environ.get("TENANT", TENANT_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible(listing: dict[str, Any]) -> bool:
|
||||||
|
visibility = listing.get("tenant_visibility") or []
|
||||||
|
return "*" in visibility or _tenant() in visibility
|
||||||
|
|
||||||
|
|
||||||
|
def _price(listing: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
price = listing.get("price")
|
||||||
|
if isinstance(price, dict):
|
||||||
|
return {
|
||||||
|
"amount": price.get("amount", 0),
|
||||||
|
"currency": price.get("currency", "credits"),
|
||||||
|
"model": price.get("model", "fixed"),
|
||||||
|
}
|
||||||
|
return {"amount": listing.get("price_amount", 0), "currency": "credits", "model": "fixed"}
|
||||||
|
|
||||||
|
|
||||||
|
def _listing_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"listing_id": listing_id,
|
||||||
|
"name": listing.get("name") or listing_id,
|
||||||
|
"summary": listing.get("summary") or "",
|
||||||
|
"category": listing.get("category") or "",
|
||||||
|
"price": _price(listing),
|
||||||
|
"seller": listing.get("seller") or listing.get("seller_id") or "",
|
||||||
|
"stats": listing.get("stats") or {},
|
||||||
|
"score": score,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
|
||||||
|
dto = _listing_dto(listing_id, listing, score)
|
||||||
|
dto.update(
|
||||||
|
{
|
||||||
|
"evidence_summary": listing.get("evidence_summary") or "",
|
||||||
|
"permissions": listing.get("permissions") or [],
|
||||||
|
"install_ref": listing.get("install_ref") or "",
|
||||||
|
"install_command": f"m2-market install {listing_id} --yes",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return dto
|
||||||
|
|
||||||
|
|
||||||
|
async def _memory_search(query_text: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
base_url = os.environ["MEMORY_API_URL"]
|
||||||
|
api_key = os.environ["MEMORY_API_KEY"]
|
||||||
|
async with client(base_url, {"X-API-Key": api_key}) as http:
|
||||||
|
response = await http.post(
|
||||||
|
"/memory/search",
|
||||||
|
json={
|
||||||
|
"query": query_text,
|
||||||
|
"agent_id": PARTITION,
|
||||||
|
"memory_types": ["semantic"],
|
||||||
|
"limit": max(limit * 3, limit),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for result in response.json().get("results", []):
|
||||||
|
meta = result.get("metadata") or {}
|
||||||
|
listing = meta.get("listing")
|
||||||
|
listing_id = meta.get("listing_id")
|
||||||
|
if not isinstance(listing, dict) or not listing_id:
|
||||||
|
continue
|
||||||
|
if not _visible(listing):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{"listing_id": listing_id, "score": result.get("score"), "listing": listing}
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=None)
|
||||||
|
async def search(
|
||||||
|
q: str = "", limit: int = Query(10, ge=1, le=50)
|
||||||
|
) -> list[dict[str, Any]] | JSONResponse:
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
_listing_dto(i["listing_id"], i["listing"], i["score"])
|
||||||
|
for i in await _memory_search(q, limit)
|
||||||
|
]
|
||||||
|
except (httpx.HTTPError, KeyError):
|
||||||
|
return upstream_error("catalog")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/listing/{listing_id}", response_model=None)
|
||||||
|
async def listing(listing_id: str) -> dict[str, Any] | JSONResponse:
|
||||||
|
try:
|
||||||
|
for item in await _memory_search(listing_id, 10):
|
||||||
|
if item["listing_id"] == listing_id:
|
||||||
|
return _detail_dto(item["listing_id"], item["listing"], item["score"])
|
||||||
|
return upstream_error("catalog")
|
||||||
|
except (httpx.HTTPError, KeyError):
|
||||||
|
return upstream_error("catalog")
|
||||||
21
web/backend/src/m2_market_web/http.py
Normal file
21
web/backend/src/m2_market_web/http.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
def upstream_error(panel: str) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=502, content={"error": "upstream_failure", "panel": panel})
|
||||||
|
|
||||||
|
|
||||||
|
def async_transport() -> httpx.AsyncBaseTransport | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def client(base_url: str, headers: dict[str, str] | None = None) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=base_url.rstrip("/"),
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=20.0,
|
||||||
|
transport=async_transport(),
|
||||||
|
)
|
||||||
104
web/backend/src/m2_market_web/ledger.py
Normal file
104
web/backend/src/m2_market_web/ledger.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .auth import SessionDependency
|
||||||
|
from .http import client, upstream_error
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[SessionDependency])
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
return {"X-API-Key": os.environ["LEDGER_SERVICE_KEY"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _tx_dto(tx: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ts": tx.get("ts"),
|
||||||
|
"from": tx.get("from"),
|
||||||
|
"to": tx.get("to"),
|
||||||
|
"amount": tx.get("amount"),
|
||||||
|
"reason": tx.get("reason"),
|
||||||
|
"ref": tx.get("ref"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_raw_url(path: str) -> str:
|
||||||
|
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
|
||||||
|
repo = os.environ["REGISTRY_REPO"].strip("/")
|
||||||
|
return f"{forgejo}/{repo}/raw/branch/main/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_audit_date() -> str:
|
||||||
|
today = date.today().isoformat()
|
||||||
|
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
|
||||||
|
repo = os.environ["REGISTRY_REPO"].strip("/")
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
|
headers = {"Authorization": f"token {token}"} if token else {}
|
||||||
|
async with client(forgejo, headers) as http:
|
||||||
|
response = await http.get(f"/api/v1/repos/{repo}/contents/audit", params={"ref": "main"})
|
||||||
|
response.raise_for_status()
|
||||||
|
dates = sorted(
|
||||||
|
item["name"].removesuffix(".json")
|
||||||
|
for item in response.json()
|
||||||
|
if isinstance(item, dict) and str(item.get("name", "")).endswith(".json")
|
||||||
|
)
|
||||||
|
return today if today in dates else (dates[-1] if dates else today)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wallet/{operator_id}", response_model=None)
|
||||||
|
async def wallet(operator_id: str) -> dict[str, Any] | JSONResponse:
|
||||||
|
try:
|
||||||
|
async with client(os.environ["LEDGER_URL"], _headers()) as http:
|
||||||
|
balance = await http.get(f"/balance/{operator_id}")
|
||||||
|
balance.raise_for_status()
|
||||||
|
tx_log = await http.get("/tx", params={"operator_id": operator_id})
|
||||||
|
tx_log.raise_for_status()
|
||||||
|
balance_body = balance.json()
|
||||||
|
transactions = tx_log.json().get("transactions", [])[:20]
|
||||||
|
return {
|
||||||
|
"operator_id": operator_id,
|
||||||
|
"balance": balance_body.get("balance", 0),
|
||||||
|
"as_of": balance_body.get("as_of"),
|
||||||
|
"transactions": [_tx_dto(tx) for tx in transactions],
|
||||||
|
}
|
||||||
|
except (httpx.HTTPError, KeyError):
|
||||||
|
return upstream_error("wallet")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/economy", response_model=None)
|
||||||
|
async def economy() -> dict[str, Any] | JSONResponse:
|
||||||
|
try:
|
||||||
|
async with client(os.environ["LEDGER_URL"], _headers()) as http:
|
||||||
|
tx_log = await http.get("/tx")
|
||||||
|
tx_log.raise_for_status()
|
||||||
|
transactions = tx_log.json().get("transactions", [])
|
||||||
|
operators = sorted(
|
||||||
|
{
|
||||||
|
party
|
||||||
|
for tx in transactions
|
||||||
|
for party in (tx.get("from"), tx.get("to"))
|
||||||
|
if party and party != "mint"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
balances = []
|
||||||
|
for operator_id in operators:
|
||||||
|
response = await http.get(f"/balance/{operator_id}")
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
balances.append(
|
||||||
|
{"operator_id": operator_id, "balance": body.get("balance", 0)}
|
||||||
|
)
|
||||||
|
audit_date = await _latest_audit_date()
|
||||||
|
return {
|
||||||
|
"operators": balances,
|
||||||
|
"audit": {"date": audit_date, "url": _registry_raw_url(f"audit/{audit_date}.json")},
|
||||||
|
}
|
||||||
|
except (httpx.HTTPError, KeyError):
|
||||||
|
return upstream_error("economy")
|
||||||
49
web/backend/src/m2_market_web/main.py
Normal file
49
web/backend/src/m2_market_web/main.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
from . import auth, catalog, ledger, registry
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(title="m2-market-web")
|
||||||
|
app.include_router(auth.router, prefix="/api")
|
||||||
|
app.include_router(catalog.router, prefix="/api")
|
||||||
|
app.include_router(ledger.router, prefix="/api")
|
||||||
|
app.include_router(registry.router, prefix="/api")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
# In the container the package is pip-installed (site-packages), so the
|
||||||
|
# repo-relative parents[3] fallback misses — M2MW_STATIC_DIR wins.
|
||||||
|
dist = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"M2MW_STATIC_DIR",
|
||||||
|
Path(__file__).resolve().parents[3] / "frontend" / "dist",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/{path:path}", include_in_schema=False)
|
||||||
|
def static_or_spa(path: str, request: Request):
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
return JSONResponse(status_code=404, content={"error": "not_found"})
|
||||||
|
if not dist.exists():
|
||||||
|
return JSONResponse(status_code=404, content={"error": "frontend_not_built"})
|
||||||
|
requested = (dist / path).resolve()
|
||||||
|
if requested.is_file() and dist.resolve() in requested.parents:
|
||||||
|
return FileResponse(requested)
|
||||||
|
index = dist / "index.html"
|
||||||
|
if index.is_file():
|
||||||
|
return FileResponse(index)
|
||||||
|
return JSONResponse(status_code=404, content={"error": "frontend_not_built"})
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
97
web/backend/src/m2_market_web/registry.py
Normal file
97
web/backend/src/m2_market_web/registry.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .auth import SessionDependency
|
||||||
|
from .http import client, upstream_error
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[SessionDependency])
|
||||||
|
|
||||||
|
|
||||||
|
def _repo() -> str:
|
||||||
|
return os.environ["REGISTRY_REPO"].strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
return {"Authorization": f"token {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _age_days(created_at: str | None) -> int:
|
||||||
|
if not created_at:
|
||||||
|
return 0
|
||||||
|
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||||
|
return max(0, (datetime.now(UTC) - created).days)
|
||||||
|
|
||||||
|
|
||||||
|
def _listing_url(listing_id: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{os.environ['FORGEJO_URL'].rstrip('/')}/{_repo()}"
|
||||||
|
f"/src/branch/main/listings/{listing_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _listing_status(http: httpx.AsyncClient, listing_id: str) -> str:
|
||||||
|
response = await http.get(
|
||||||
|
f"/api/v1/repos/{_repo()}/contents/listings/{listing_id}/listing.json",
|
||||||
|
params={"ref": "main"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return "published"
|
||||||
|
listing = body if "status" in body else body.get("content_json", {})
|
||||||
|
if not listing and isinstance(body, dict) and body.get("content"):
|
||||||
|
content = str(body["content"]).replace("\n", "")
|
||||||
|
listing = json.loads(base64.b64decode(content).decode("utf-8"))
|
||||||
|
return listing.get("status", "published") if isinstance(listing, dict) else "published"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/governance", response_model=None)
|
||||||
|
async def governance() -> dict[str, Any] | JSONResponse:
|
||||||
|
try:
|
||||||
|
async with client(os.environ["FORGEJO_URL"], _headers()) as http:
|
||||||
|
prs_response = await http.get(
|
||||||
|
f"/api/v1/repos/{_repo()}/pulls", params={"state": "open"}
|
||||||
|
)
|
||||||
|
prs_response.raise_for_status()
|
||||||
|
open_prs = [
|
||||||
|
{
|
||||||
|
"number": pr.get("number"),
|
||||||
|
"title": pr.get("title", ""),
|
||||||
|
"age_days": _age_days(pr.get("created_at")),
|
||||||
|
"labels": [label.get("name", "") for label in pr.get("labels", [])],
|
||||||
|
"url": pr.get("html_url") or pr.get("url"),
|
||||||
|
}
|
||||||
|
for pr in prs_response.json()
|
||||||
|
]
|
||||||
|
|
||||||
|
listings_response = await http.get(
|
||||||
|
f"/api/v1/repos/{_repo()}/contents/listings", params={"ref": "main"}
|
||||||
|
)
|
||||||
|
listings_response.raise_for_status()
|
||||||
|
listings = []
|
||||||
|
for item in listings_response.json():
|
||||||
|
if not isinstance(item, dict) or item.get("type") not in {None, "dir"}:
|
||||||
|
continue
|
||||||
|
listing_id = item.get("name")
|
||||||
|
if not listing_id:
|
||||||
|
continue
|
||||||
|
listings.append(
|
||||||
|
{
|
||||||
|
"listing_id": listing_id,
|
||||||
|
"status": await _listing_status(http, listing_id),
|
||||||
|
"url": _listing_url(listing_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"open_prs": open_prs, "listings": listings}
|
||||||
|
except (httpx.HTTPError, KeyError, ValueError):
|
||||||
|
return upstream_error("governance")
|
||||||
6
web/backend/tests/conftest.py
Normal file
6
web/backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
267
web/backend/tests/test_backend.py
Normal file
267
web/backend/tests/test_backend.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from m2_market_web import http as web_http
|
||||||
|
from m2_market_web.main import create_app
|
||||||
|
|
||||||
|
SECRET_VALUES = {
|
||||||
|
"fleet-passcode-secret",
|
||||||
|
"session-secret-value",
|
||||||
|
"mem-secret-key",
|
||||||
|
"ledger-secret-key",
|
||||||
|
"forgejo-secret-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("FLEET_PASSCODE", "fleet-passcode-secret")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "session-secret-value")
|
||||||
|
monkeypatch.setenv("MEMORY_API_URL", "https://memory.example")
|
||||||
|
monkeypatch.setenv("MEMORY_API_KEY", "mem-secret-key")
|
||||||
|
monkeypatch.setenv("LEDGER_URL", "https://ledger.example")
|
||||||
|
monkeypatch.setenv("LEDGER_SERVICE_KEY", "ledger-secret-key")
|
||||||
|
monkeypatch.setenv("FORGEJO_URL", "https://git.example")
|
||||||
|
monkeypatch.setenv("FORGEJO_TOKEN", "forgejo-secret-token")
|
||||||
|
monkeypatch.setenv("REGISTRY_REPO", "m2/market-registry")
|
||||||
|
monkeypatch.setenv("TENANT", "m2-core")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch: pytest.MonkeyPatch) -> Callable[[httpx.MockTransport], TestClient]:
|
||||||
|
def build(transport: httpx.MockTransport) -> TestClient:
|
||||||
|
monkeypatch.setattr(web_http, "async_transport", lambda: transport)
|
||||||
|
return TestClient(create_app(), base_url="https://testserver")
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def _authed(test_client: TestClient) -> None:
|
||||||
|
response = test_client.post("/api/login", json={"passcode": "fleet-passcode-secret"})
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def _listing() -> dict:
|
||||||
|
return {
|
||||||
|
"name": "PDF Export",
|
||||||
|
"summary": "Branded reports",
|
||||||
|
"category": "reporting",
|
||||||
|
"price": {"amount": 7, "currency": "credits", "model": "fixed"},
|
||||||
|
"seller": "m2bd",
|
||||||
|
"stats": {"installs": 3},
|
||||||
|
"tenant_visibility": ["*"],
|
||||||
|
"evidence_summary": "Used in one production workflow",
|
||||||
|
"permissions": ["filesystem:write"],
|
||||||
|
"install_ref": "lst_pdf-v1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_transport(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.host == "memory.example":
|
||||||
|
assert request.headers["X-API-Key"] == "mem-secret-key"
|
||||||
|
body = json.loads(request.content)
|
||||||
|
assert body["agent_id"] == "market:catalog"
|
||||||
|
assert body["limit"] == 6 or body["limit"] == 30
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"score": 0.9,
|
||||||
|
"metadata": {"listing_id": "lst_pdf", "listing": _listing()},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"score": 0.8,
|
||||||
|
"metadata": {
|
||||||
|
"listing_id": "lst_hidden",
|
||||||
|
"listing": {"name": "Hidden", "tenant_visibility": ["other"]},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.url.host == "ledger.example":
|
||||||
|
assert request.headers["X-API-Key"] == "ledger-secret-key"
|
||||||
|
if request.url.path == "/balance/m2bd":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"operator_id": "m2bd", "balance": 42, "as_of": "2026-07-02T10:00:00Z"},
|
||||||
|
)
|
||||||
|
if request.url.path == "/balance/platform":
|
||||||
|
return httpx.Response(200, json={"operator_id": "platform", "balance": 8})
|
||||||
|
if request.url.path == "/tx" and request.url.params.get("operator_id") == "m2bd":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"ts": "2026-07-02T09:00:00Z",
|
||||||
|
"from": "mint",
|
||||||
|
"to": "m2bd",
|
||||||
|
"amount": 50,
|
||||||
|
"reason": "grant",
|
||||||
|
"ref": "starter",
|
||||||
|
"private": "ignored",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.url.path == "/tx":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"transactions": [
|
||||||
|
{"from": "mint", "to": "m2bd", "amount": 50},
|
||||||
|
{"from": "m2bd", "to": "platform", "amount": 8},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.url.host == "git.example":
|
||||||
|
assert request.headers["Authorization"] == "token forgejo-secret-token"
|
||||||
|
if request.url.path == "/api/v1/repos/m2/market-registry/contents/audit":
|
||||||
|
return httpx.Response(200, json=[{"name": "2026-07-01.json"}])
|
||||||
|
if request.url.path == "/api/v1/repos/m2/market-registry/pulls":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json=[
|
||||||
|
{
|
||||||
|
"number": 12,
|
||||||
|
"title": "Publish PDF Export",
|
||||||
|
"created_at": "2026-07-01T10:00:00Z",
|
||||||
|
"labels": [{"name": "approved"}],
|
||||||
|
"html_url": "https://git.example/m2/market-registry/pulls/12",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if request.url.path == "/api/v1/repos/m2/market-registry/contents/listings":
|
||||||
|
return httpx.Response(200, json=[{"name": "lst_pdf", "type": "dir"}])
|
||||||
|
if (
|
||||||
|
request.url.path
|
||||||
|
== "/api/v1/repos/m2/market-registry/contents/listings/lst_pdf/listing.json"
|
||||||
|
):
|
||||||
|
return httpx.Response(200, json={"content_json": {"status": "published"}})
|
||||||
|
return httpx.Response(404, json={"unexpected": str(request.url)})
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]) -> None:
|
||||||
|
test_client = client(httpx.MockTransport(_ok_transport))
|
||||||
|
assert test_client.get("/api/search?q=pdf").status_code == 401
|
||||||
|
assert test_client.post("/api/login", json={"passcode": "wrong"}).status_code == 401
|
||||||
|
_authed(test_client)
|
||||||
|
assert test_client.get("/api/search?q=pdf&limit=2").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_wallet_economy_governance_happy_paths(
|
||||||
|
client: Callable[[httpx.MockTransport], TestClient],
|
||||||
|
) -> None:
|
||||||
|
test_client = client(httpx.MockTransport(_ok_transport))
|
||||||
|
_authed(test_client)
|
||||||
|
|
||||||
|
search = test_client.get("/api/search?q=pdf&limit=2")
|
||||||
|
assert search.status_code == 200
|
||||||
|
assert search.json() == [
|
||||||
|
{
|
||||||
|
"listing_id": "lst_pdf",
|
||||||
|
"name": "PDF Export",
|
||||||
|
"summary": "Branded reports",
|
||||||
|
"category": "reporting",
|
||||||
|
"price": {"amount": 7, "currency": "credits", "model": "fixed"},
|
||||||
|
"seller": "m2bd",
|
||||||
|
"stats": {"installs": 3},
|
||||||
|
"score": 0.9,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
listing = test_client.get("/api/listing/lst_pdf")
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert listing.json()["install_command"] == "m2-market install lst_pdf --yes"
|
||||||
|
assert listing.json()["permissions"] == ["filesystem:write"]
|
||||||
|
|
||||||
|
wallet = test_client.get("/api/wallet/m2bd")
|
||||||
|
assert wallet.status_code == 200
|
||||||
|
assert wallet.json()["balance"] == 42
|
||||||
|
assert wallet.json()["transactions"][0] == {
|
||||||
|
"ts": "2026-07-02T09:00:00Z",
|
||||||
|
"from": "mint",
|
||||||
|
"to": "m2bd",
|
||||||
|
"amount": 50,
|
||||||
|
"reason": "grant",
|
||||||
|
"ref": "starter",
|
||||||
|
}
|
||||||
|
|
||||||
|
economy = test_client.get("/api/economy")
|
||||||
|
assert economy.status_code == 200
|
||||||
|
assert economy.json()["operators"] == [
|
||||||
|
{"operator_id": "m2bd", "balance": 42},
|
||||||
|
{"operator_id": "platform", "balance": 8},
|
||||||
|
]
|
||||||
|
assert economy.json()["audit"] == {
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"url": "https://git.example/m2/market-registry/raw/branch/main/audit/2026-07-01.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
governance = test_client.get("/api/governance")
|
||||||
|
assert governance.status_code == 200
|
||||||
|
assert governance.json()["open_prs"][0]["labels"] == ["approved"]
|
||||||
|
assert governance.json()["listings"] == [
|
||||||
|
{
|
||||||
|
"listing_id": "lst_pdf",
|
||||||
|
"status": "published",
|
||||||
|
"url": "https://git.example/m2/market-registry/src/branch/main/listings/lst_pdf",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "failed_host", "panel"),
|
||||||
|
[
|
||||||
|
("/api/search?q=pdf", "memory.example", "catalog"),
|
||||||
|
("/api/wallet/m2bd", "ledger.example", "wallet"),
|
||||||
|
("/api/economy", "ledger.example", "economy"),
|
||||||
|
("/api/governance", "git.example", "governance"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_upstream_failures_are_panel_502(
|
||||||
|
client: Callable[[httpx.MockTransport], TestClient],
|
||||||
|
path: str,
|
||||||
|
failed_host: str,
|
||||||
|
panel: str,
|
||||||
|
) -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.host == failed_host:
|
||||||
|
return httpx.Response(503, json={"secret": "upstream body ignored"})
|
||||||
|
return _ok_transport(request)
|
||||||
|
|
||||||
|
test_client = client(httpx.MockTransport(handler))
|
||||||
|
_authed(test_client)
|
||||||
|
response = test_client.get(path)
|
||||||
|
assert response.status_code == 502
|
||||||
|
assert response.json() == {"error": "upstream_failure", "panel": panel}
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_values_never_appear_in_response_bodies(
|
||||||
|
client: Callable[[httpx.MockTransport], TestClient],
|
||||||
|
) -> None:
|
||||||
|
test_client = client(httpx.MockTransport(_ok_transport))
|
||||||
|
responses = [
|
||||||
|
test_client.post("/api/login", json={"passcode": "fleet-passcode-secret"}),
|
||||||
|
]
|
||||||
|
responses.extend(
|
||||||
|
[
|
||||||
|
test_client.get("/health"),
|
||||||
|
test_client.get("/api/search?q=pdf"),
|
||||||
|
test_client.get("/api/listing/lst_pdf"),
|
||||||
|
test_client.get("/api/wallet/m2bd"),
|
||||||
|
test_client.get("/api/economy"),
|
||||||
|
test_client.get("/api/governance"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for response in responses:
|
||||||
|
body = response.text
|
||||||
|
for secret in SECRET_VALUES:
|
||||||
|
assert secret not in body
|
||||||
13
web/frontend/index.html
Normal file
13
web/frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0f1830" />
|
||||||
|
<title>M2 Market</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1729
web/frontend/package-lock.json
generated
Normal file
1729
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
web/frontend/package.json
Normal file
22
web/frontend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "m2-market-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
130
web/frontend/src/api.ts
Normal file
130
web/frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
export type Price = {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListingStats = {
|
||||||
|
installs?: number;
|
||||||
|
proposals_shown?: number;
|
||||||
|
proposals_accepted?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListingSummary = {
|
||||||
|
listing_id: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
category?: string;
|
||||||
|
price: Price;
|
||||||
|
seller: string;
|
||||||
|
stats?: ListingStats;
|
||||||
|
score?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListingDetail = ListingSummary & {
|
||||||
|
evidence_summary: string;
|
||||||
|
permissions: string[];
|
||||||
|
install_ref?: string;
|
||||||
|
install_command: string;
|
||||||
|
solution_version?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Transaction = {
|
||||||
|
ts: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: number;
|
||||||
|
reason: string;
|
||||||
|
ref?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Wallet = {
|
||||||
|
operator_id: string;
|
||||||
|
balance: number;
|
||||||
|
as_of?: string;
|
||||||
|
transactions: Transaction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Economy = {
|
||||||
|
operators: Array<{ operator_id: string; balance: number }>;
|
||||||
|
audit?: { date: string; url: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Governance = {
|
||||||
|
open_prs: Array<{ number: number; title: string; age_days: number; labels: string[]; url: string }>;
|
||||||
|
listings: Array<{ listing_id: string; status: string; url: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PanelName = 'search' | 'listing' | 'wallet' | 'economy' | 'governance' | 'login';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
panel?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, panel?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.panel = panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (window.location.hash !== '#/login') {
|
||||||
|
window.location.hash = '#/login';
|
||||||
|
}
|
||||||
|
throw new ApiError('Login required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 502) {
|
||||||
|
const body = (await response.json().catch(() => null)) as { error?: string; panel?: string } | null;
|
||||||
|
throw new ApiError(body?.error ?? 'Panel is temporarily unavailable', 502, body?.panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new ApiError(body?.error ?? `Request failed (${response.status})`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login(passcode: string) {
|
||||||
|
return request<void>('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ passcode }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
search(q: string, limit = 10) {
|
||||||
|
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||||
|
return request<ListingSummary[]>(`/api/search?${params}`);
|
||||||
|
},
|
||||||
|
listing(listingId: string) {
|
||||||
|
return request<ListingDetail>(`/api/listing/${encodeURIComponent(listingId)}`);
|
||||||
|
},
|
||||||
|
wallet(operatorId: string) {
|
||||||
|
return request<Wallet>(`/api/wallet/${encodeURIComponent(operatorId)}`);
|
||||||
|
},
|
||||||
|
economy() {
|
||||||
|
return request<Economy>('/api/economy');
|
||||||
|
},
|
||||||
|
governance() {
|
||||||
|
return request<Governance>('/api/governance');
|
||||||
|
},
|
||||||
|
};
|
||||||
630
web/frontend/src/main.tsx
Normal file
630
web/frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,630 @@
|
||||||
|
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
Economy,
|
||||||
|
Governance,
|
||||||
|
ListingDetail,
|
||||||
|
ListingSummary,
|
||||||
|
PanelName,
|
||||||
|
Wallet,
|
||||||
|
api,
|
||||||
|
} from './api';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
const OPERATOR_PRESETS = ['m2bd', 'sdjs-operator', 'chris-operator', 'gunnar-operator'];
|
||||||
|
const OPERATOR_STORAGE_KEY = 'm2-market.operator';
|
||||||
|
|
||||||
|
type Route =
|
||||||
|
| { name: 'login' }
|
||||||
|
| { name: 'search' }
|
||||||
|
| { name: 'listing'; id: string }
|
||||||
|
| { name: 'wallet' }
|
||||||
|
| { name: 'economy' }
|
||||||
|
| { name: 'governance' };
|
||||||
|
|
||||||
|
function routeFromHash(): Route {
|
||||||
|
const hash = window.location.hash.replace(/^#/, '') || '/';
|
||||||
|
const [path] = hash.split('?');
|
||||||
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (parts[0] === 'login') return { name: 'login' };
|
||||||
|
if (parts[0] === 'listing' && parts[1]) return { name: 'listing', id: decodeURIComponent(parts[1]) };
|
||||||
|
if (parts[0] === 'wallet') return { name: 'wallet' };
|
||||||
|
if (parts[0] === 'economy') return { name: 'economy' };
|
||||||
|
if (parts[0] === 'governance') return { name: 'governance' };
|
||||||
|
return { name: 'search' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRoute(): Route {
|
||||||
|
const [route, setRoute] = useState(routeFromHash);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setRoute(routeFromHash());
|
||||||
|
window.addEventListener('hashchange', onChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatM2cr(value: number): string {
|
||||||
|
return `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value)} m2cr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string): string {
|
||||||
|
if (!value) return 'unknown';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelError(error: unknown, panel: PanelName): string | null {
|
||||||
|
const panelAliases: Record<PanelName, string[]> = {
|
||||||
|
search: ['search', 'catalog'],
|
||||||
|
listing: ['listing', 'catalog'],
|
||||||
|
wallet: ['wallet', 'ledger'],
|
||||||
|
economy: ['economy', 'ledger'],
|
||||||
|
governance: ['governance', 'registry', 'forgejo'],
|
||||||
|
login: ['login'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
error.status === 502 &&
|
||||||
|
(!error.panel || panelAliases[panel].includes(error.panel))
|
||||||
|
) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError && error.status === 502) return null;
|
||||||
|
if (error instanceof ApiError && error.status !== 401) return error.message;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelError({ panel, error }: { panel: PanelName; error: unknown }) {
|
||||||
|
const message = getPanelError(error, panel);
|
||||||
|
if (!message) return null;
|
||||||
|
return (
|
||||||
|
<div className="panel-error" role="status">
|
||||||
|
<strong>{panel} rail unavailable</strong>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const route = useRoute();
|
||||||
|
const [operator, setOperator] = useState(() => localStorage.getItem(OPERATOR_STORAGE_KEY) || OPERATOR_PRESETS[0]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
|
||||||
|
}, [operator]);
|
||||||
|
|
||||||
|
if (route.name === 'login') {
|
||||||
|
return <LoginPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<a className="brand" href="#/">
|
||||||
|
<span className="brand-mark">m2</span>
|
||||||
|
<span>
|
||||||
|
<strong>market</strong>
|
||||||
|
<small>machine.machine</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav className="nav" aria-label="Primary">
|
||||||
|
<NavLink href="#/" active={route.name === 'search'} label="Search" />
|
||||||
|
<NavLink href="#/wallet" active={route.name === 'wallet'} label="Wallet" />
|
||||||
|
<NavLink href="#/economy" active={route.name === 'economy'} label="Economy" />
|
||||||
|
<NavLink href="#/governance" active={route.name === 'governance'} label="Governance" />
|
||||||
|
</nav>
|
||||||
|
<OperatorPicker value={operator} onChange={setOperator} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="main-grid">
|
||||||
|
{route.name === 'search' && <SearchPage />}
|
||||||
|
{route.name === 'listing' && <ListingPage listingId={route.id} />}
|
||||||
|
{route.name === 'wallet' && <WalletPage operator={operator} />}
|
||||||
|
{route.name === 'economy' && <EconomyPage />}
|
||||||
|
{route.name === 'governance' && <GovernancePage />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ href, active, label }: { href: string; active: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<a className={active ? 'active' : ''} href={href}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const [passcode, setPasscode] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.login(passcode);
|
||||||
|
window.location.hash = '#/';
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError && err.status === 401 ? 'Passcode rejected.' : 'Login failed.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-panel">
|
||||||
|
<div className="brand login-brand">
|
||||||
|
<span className="brand-mark">m2</span>
|
||||||
|
<span>
|
||||||
|
<strong>market</strong>
|
||||||
|
<small>machine.machine</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="login-form">
|
||||||
|
<label htmlFor="passcode">Fleet passcode</label>
|
||||||
|
<input
|
||||||
|
id="passcode"
|
||||||
|
autoFocus
|
||||||
|
type="password"
|
||||||
|
value={passcode}
|
||||||
|
onChange={(event) => setPasscode(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button disabled={busy || passcode.trim().length === 0}>{busy ? 'Checking...' : 'Enter'}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||||
|
const presetValue = OPERATOR_PRESETS.includes(value) ? value : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="operator-picker" aria-label="Operator picker">
|
||||||
|
<select
|
||||||
|
value={presetValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value) onChange(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
custom
|
||||||
|
</option>
|
||||||
|
{OPERATOR_PRESETS.map((operator) => (
|
||||||
|
<option key={operator} value={operator}>
|
||||||
|
{operator}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input value={value} onChange={(event) => onChange(event.target.value)} aria-label="Operator id" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchPage() {
|
||||||
|
const [query, setQuery] = useState('pdf report');
|
||||||
|
const [submittedQuery, setSubmittedQuery] = useState('pdf report');
|
||||||
|
const [results, setResults] = useState<ListingSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.search(submittedQuery, 10)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled) setResults(payload);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [submittedQuery]);
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmittedQuery(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="view">
|
||||||
|
<div className="view-heading">
|
||||||
|
<p>Catalog</p>
|
||||||
|
<h1>Search listings</h1>
|
||||||
|
</div>
|
||||||
|
<form className="search-bar" onSubmit={submit}>
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search catalog" />
|
||||||
|
<button>Search</button>
|
||||||
|
</form>
|
||||||
|
<PanelError panel="search" error={error} />
|
||||||
|
{loading && <div className="empty-state">Loading catalog results...</div>}
|
||||||
|
{!loading && !error && results.length === 0 && <div className="empty-state">No visible listings matched.</div>}
|
||||||
|
<div className="cards-grid">
|
||||||
|
{results.map((listing) => (
|
||||||
|
<ListingCard key={listing.listing_id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListingCard({ listing }: { listing: ListingSummary }) {
|
||||||
|
return (
|
||||||
|
<article className="listing-card">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{listing.category || 'solution'}</p>
|
||||||
|
<h2>{listing.name}</h2>
|
||||||
|
<p>{listing.summary}</p>
|
||||||
|
</div>
|
||||||
|
<dl className="metrics">
|
||||||
|
<div>
|
||||||
|
<dt>price</dt>
|
||||||
|
<dd>{formatM2cr(listing.price.amount)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>installs</dt>
|
||||||
|
<dd>{listing.stats?.installs ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<a className="text-link" href={`#/listing/${encodeURIComponent(listing.listing_id)}`}>
|
||||||
|
Open listing
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListingPage({ listingId }: { listingId: string }) {
|
||||||
|
const [listing, setListing] = useState<ListingDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.listing(listingId)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled) setListing(payload);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [listingId]);
|
||||||
|
|
||||||
|
async function copyCommand(command: string) {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="view">
|
||||||
|
<a className="text-link back-link" href="#/">
|
||||||
|
Back to search
|
||||||
|
</a>
|
||||||
|
<PanelError panel="listing" error={error} />
|
||||||
|
{loading && <div className="empty-state">Loading listing...</div>}
|
||||||
|
{listing && (
|
||||||
|
<>
|
||||||
|
<div className="listing-hero">
|
||||||
|
<p className="eyebrow">{listing.category || listing.listing_id}</p>
|
||||||
|
<h1>{listing.name}</h1>
|
||||||
|
<p>{listing.summary}</p>
|
||||||
|
<dl className="metrics wide">
|
||||||
|
<div>
|
||||||
|
<dt>seller</dt>
|
||||||
|
<dd>{listing.seller}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>version</dt>
|
||||||
|
<dd>{listing.version || listing.solution_version || 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>price</dt>
|
||||||
|
<dd>{formatM2cr(listing.price.amount)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>installs</dt>
|
||||||
|
<dd>{listing.stats?.installs ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-column">
|
||||||
|
<section className="surface">
|
||||||
|
<p className="eyebrow">Evidence</p>
|
||||||
|
<p>{listing.evidence_summary}</p>
|
||||||
|
</section>
|
||||||
|
<section className="surface">
|
||||||
|
<p className="eyebrow">Permissions</p>
|
||||||
|
{listing.permissions.length === 0 ? (
|
||||||
|
<p>No extra permissions declared.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="plain-list">
|
||||||
|
{listing.permissions.map((permission) => (
|
||||||
|
<li key={permission}>{permission}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="handoff">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Install handoff</p>
|
||||||
|
<h2>Run this on your desktop terminal</h2>
|
||||||
|
{listing.install_ref && <p>Install ref: {listing.install_ref}</p>}
|
||||||
|
</div>
|
||||||
|
<code>{listing.install_command}</code>
|
||||||
|
<button onClick={() => copyCommand(listing.install_command)}>{copied ? 'Copied' : 'Copy command'}</button>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WalletPage({ operator }: { operator: string }) {
|
||||||
|
const [wallet, setWallet] = useState<Wallet | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.wallet(operator.trim() || 'unknown')
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled) setWallet(payload);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [operator]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="view">
|
||||||
|
<div className="view-heading">
|
||||||
|
<p>Wallet</p>
|
||||||
|
<h1>{operator || 'unknown operator'}</h1>
|
||||||
|
</div>
|
||||||
|
<PanelError panel="wallet" error={error} />
|
||||||
|
{loading && <div className="empty-state">Loading wallet...</div>}
|
||||||
|
{wallet && (
|
||||||
|
<>
|
||||||
|
<section className="balance-strip">
|
||||||
|
<div>
|
||||||
|
<span>balance</span>
|
||||||
|
<strong>{formatM2cr(wallet.balance)}</strong>
|
||||||
|
</div>
|
||||||
|
<p>as of {formatDate(wallet.as_of)}</p>
|
||||||
|
</section>
|
||||||
|
{wallet.balance === 0 && wallet.transactions.length === 0 && (
|
||||||
|
<div className="empty-state">No such wallet yet, or the wallet has no activity.</div>
|
||||||
|
)}
|
||||||
|
<TransactionTable transactions={wallet.transactions} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionTable({ transactions }: { transactions: Wallet['transactions'] }) {
|
||||||
|
if (transactions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Ref</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map((tx, index) => (
|
||||||
|
<tr key={`${tx.ts}-${tx.ref ?? index}`}>
|
||||||
|
<td>{formatDate(tx.ts)}</td>
|
||||||
|
<td>{tx.from}</td>
|
||||||
|
<td>{tx.to}</td>
|
||||||
|
<td>{formatM2cr(tx.amount)}</td>
|
||||||
|
<td>{tx.reason}</td>
|
||||||
|
<td>{tx.ref || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EconomyPage() {
|
||||||
|
const [economy, setEconomy] = useState<Economy | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.economy()
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled) setEconomy(payload);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const total = useMemo(() => economy?.operators.reduce((sum, operator) => sum + operator.balance, 0) ?? 0, [economy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="view">
|
||||||
|
<div className="view-heading">
|
||||||
|
<p>Economy</p>
|
||||||
|
<h1>Fleet balances</h1>
|
||||||
|
</div>
|
||||||
|
<PanelError panel="economy" error={error} />
|
||||||
|
{loading && <div className="empty-state">Loading economy...</div>}
|
||||||
|
{economy && (
|
||||||
|
<>
|
||||||
|
<section className="balance-strip">
|
||||||
|
<div>
|
||||||
|
<span>total</span>
|
||||||
|
<strong>{formatM2cr(total)}</strong>
|
||||||
|
</div>
|
||||||
|
{economy.audit ? (
|
||||||
|
<a className="text-link" href={economy.audit.url} target="_blank" rel="noreferrer">
|
||||||
|
Audit snapshot {economy.audit.date}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p>No audit snapshot linked.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<div className="cards-grid compact">
|
||||||
|
{economy.operators.map((operator) => (
|
||||||
|
<article className="mini-card" key={operator.operator_id}>
|
||||||
|
<span>{operator.operator_id}</span>
|
||||||
|
<strong>{formatM2cr(operator.balance)}</strong>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GovernancePage() {
|
||||||
|
const [governance, setGovernance] = useState<Governance | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.governance()
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled) setGovernance(payload);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="view">
|
||||||
|
<div className="view-heading">
|
||||||
|
<p>Governance</p>
|
||||||
|
<h1>Registry review</h1>
|
||||||
|
</div>
|
||||||
|
<PanelError panel="governance" error={error} />
|
||||||
|
{loading && <div className="empty-state">Loading governance...</div>}
|
||||||
|
{governance && (
|
||||||
|
<div className="two-column">
|
||||||
|
<section className="surface">
|
||||||
|
<p className="eyebrow">Open PRs</p>
|
||||||
|
{governance.open_prs.length === 0 ? (
|
||||||
|
<p>No open listing PRs.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="review-list">
|
||||||
|
{governance.open_prs.map((pr) => (
|
||||||
|
<li key={pr.number}>
|
||||||
|
<a href={pr.url} target="_blank" rel="noreferrer">
|
||||||
|
#{pr.number} {pr.title}
|
||||||
|
</a>
|
||||||
|
<span>{pr.age_days}d open</span>
|
||||||
|
<div className="labels">
|
||||||
|
{pr.labels.map((label) => (
|
||||||
|
<span key={label}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="surface">
|
||||||
|
<p className="eyebrow">Listings</p>
|
||||||
|
{governance.listings.length === 0 ? (
|
||||||
|
<p>No listings returned.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="review-list">
|
||||||
|
{governance.listings.map((listing) => (
|
||||||
|
<li key={listing.listing_id}>
|
||||||
|
<a href={listing.url} target="_blank" rel="noreferrer">
|
||||||
|
{listing.listing_id}
|
||||||
|
</a>
|
||||||
|
<span className={`status ${listing.status}`}>{listing.status}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
521
web/frontend/src/styles.css
Normal file
521
web/frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #eaf7ff;
|
||||||
|
background: #0f1830;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
--bg: #0f1830;
|
||||||
|
--bg-soft: #14213d;
|
||||||
|
--panel: #182642;
|
||||||
|
--panel-strong: #203456;
|
||||||
|
--line: rgba(129, 210, 232, 0.22);
|
||||||
|
--cyan: #2ee8ff;
|
||||||
|
--cyan-soft: #9df3ff;
|
||||||
|
--green: #70f1b5;
|
||||||
|
--red: #ff7c9a;
|
||||||
|
--text: #eaf7ff;
|
||||||
|
--muted: #9eb4c8;
|
||||||
|
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(46, 232, 255, 0.08), transparent 34rem),
|
||||||
|
radial-gradient(circle at top right, rgba(112, 241, 181, 0.12), transparent 26rem),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem clamp(1rem, 3vw, 2rem);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(15, 24, 48, 0.9);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: rgba(46, 232, 255, 0.12);
|
||||||
|
color: var(--cyan);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong,
|
||||||
|
.brand small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a,
|
||||||
|
.text-link {
|
||||||
|
color: var(--cyan-soft);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active,
|
||||||
|
.nav a:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(46, 232, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, auto) minmax(10rem, 14rem);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
background: rgba(7, 12, 24, 0.56);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
box-shadow: 0 0 0 3px rgba(46, 232, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid rgba(46, 232, 255, 0.58);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.72rem 1rem;
|
||||||
|
background: var(--cyan);
|
||||||
|
color: #08111f;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-grid {
|
||||||
|
width: min(1180px, calc(100vw - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1.5rem, 5vw, 4rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-heading p,
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 840px;
|
||||||
|
font-size: clamp(2.15rem, 7vw, 4.75rem);
|
||||||
|
line-height: 0.96;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid.compact {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-card,
|
||||||
|
.surface,
|
||||||
|
.mini-card,
|
||||||
|
.handoff,
|
||||||
|
.balance-strip,
|
||||||
|
.listing-hero,
|
||||||
|
.login-panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(32, 52, 86, 0.9), rgba(20, 33, 61, 0.82));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 20rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics.wide {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics div,
|
||||||
|
.mini-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid rgba(157, 243, 255, 0.16);
|
||||||
|
background: rgba(7, 12, 24, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt,
|
||||||
|
.mini-card span,
|
||||||
|
.balance-strip span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd,
|
||||||
|
.mini-card strong,
|
||||||
|
.balance-strip strong {
|
||||||
|
display: block;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-hero,
|
||||||
|
.surface,
|
||||||
|
.handoff,
|
||||||
|
.balance-strip {
|
||||||
|
padding: clamp(1rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-hero p {
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plain-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handoff {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.9rem;
|
||||||
|
background: linear-gradient(135deg, rgba(46, 232, 255, 0.13), rgba(112, 241, 181, 0.1)), var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handoff code {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(46, 232, 255, 0.28);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(7, 12, 24, 0.72);
|
||||||
|
color: var(--cyan-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handoff button {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 820px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: rgba(20, 33, 61, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-bottom: 1px solid rgba(157, 243, 255, 0.12);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid rgba(157, 243, 255, 0.14);
|
||||||
|
background: rgba(7, 12, 24, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-list a {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-list span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.labels {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labels span,
|
||||||
|
.status {
|
||||||
|
width: fit-content;
|
||||||
|
border: 1px solid rgba(46, 232, 255, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
background: rgba(46, 232, 255, 0.09);
|
||||||
|
color: var(--cyan-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.published {
|
||||||
|
border-color: rgba(112, 241, 181, 0.38);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.delisted {
|
||||||
|
border-color: rgba(255, 124, 154, 0.4);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-error,
|
||||||
|
.empty-state {
|
||||||
|
border: 1px solid rgba(255, 124, 154, 0.34);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 124, 154, 0.1);
|
||||||
|
color: #ffdbe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-error strong,
|
||||||
|
.panel-error span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-error span {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
color: #ffdbe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(32, 52, 86, 0.58);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form label {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.topbar {
|
||||||
|
position: static;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-picker,
|
||||||
|
.search-bar,
|
||||||
|
.two-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics.wide {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-strip {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.main-grid {
|
||||||
|
width: min(100% - 1rem, 1180px);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics,
|
||||||
|
.metrics.wide {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/frontend/tsconfig.json
Normal file
21
web/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
15
web/frontend/vite.config.ts
Normal file
15
web/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:8000',
|
||||||
|
'/health': 'http://127.0.0.1:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue