feat: add dark mode, navigation, and Web Vitals tracking to marketplace

Backend:
- Simplify DatabaseConfig: remove effective_url property and project root finder
- Update to Pydantic v2 model_config (replace nested Config class)
- Add web_vitals router to main.py and __init__.py
- Fix ExplorerService datetime handling (ensure timezone-naive comparisons)
- Fix status_label extraction to handle both enum and string job states

Frontend (Marketplace):
- Add dark mode toggle with system preference detection
This commit is contained in:
oib
2026-02-15 19:02:51 +01:00
parent 72e21fd07f
commit 7062b2cc78
26 changed files with 1945 additions and 769 deletions

View File

@@ -18,35 +18,12 @@ class DatabaseConfig(BaseSettings):
max_overflow: int = 20
pool_pre_ping: bool = True
@property
def effective_url(self) -> str:
"""Get the effective database URL."""
if self.url:
return self.url
# Auto-generate SQLite URL based on environment
if self.adapter == "sqlite":
project_root = self._find_project_root()
db_path = project_root / "data" / "coordinator.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{db_path}"
elif self.adapter == "postgresql":
return "postgresql://localhost:5432/aitbc_coordinator"
return "sqlite:///:memory:"
@staticmethod
def _find_project_root() -> Path:
"""Find project root by looking for .git directory."""
current = Path(__file__).resolve()
while current.parent != current:
if (current / ".git").exists():
return current
current = current.parent
return Path(__file__).resolve().parents[3]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="allow"
)
class Settings(BaseSettings):
@@ -116,7 +93,10 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Get the database URL (backward compatibility)."""
return self.database.effective_url
if self.database.url:
return self.database.url
# Default SQLite path for backward compatibility
return f"sqlite:///./aitbc_coordinator.db"
settings = Settings()

View File

@@ -19,6 +19,7 @@ from .routers import (
zk_applications,
explorer,
payments,
web_vitals,
)
from .routers.governance import router as governance
from .routers.partners import router as partners
@@ -75,6 +76,7 @@ def create_app() -> FastAPI:
app.include_router(governance, prefix="/v1")
app.include_router(partners, prefix="/v1")
app.include_router(explorer, prefix="/v1")
app.include_router(web_vitals, prefix="/v1")
# Add Prometheus metrics endpoint
metrics_app = make_asgi_app()

View File

@@ -11,6 +11,7 @@ from .users import router as users
from .exchange import router as exchange
from .marketplace_offers import router as marketplace_offers
from .payments import router as payments
from .web_vitals import router as web_vitals
# from .registry import router as registry
__all__ = ["client", "miner", "admin", "marketplace", "marketplace_gpu", "explorer", "services", "users", "exchange", "marketplace_offers", "payments", "registry"]
__all__ = ["client", "miner", "admin", "marketplace", "marketplace_gpu", "explorer", "services", "users", "exchange", "marketplace_offers", "payments", "web_vitals", "registry"]

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Web Vitals API endpoint for collecting performance metrics
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
import logging
router = APIRouter()
class WebVitalsEntry(BaseModel):
name: str
startTime: Optional[float] = None
duration: Optional[float] = None
class WebVitalsMetric(BaseModel):
name: str
value: float
id: str
delta: Optional[float] = None
entries: List[WebVitalsEntry] = []
url: Optional[str] = None
timestamp: Optional[str] = None
@router.post("/web-vitals")
async def collect_web_vitals(metric: WebVitalsMetric):
"""
Collect Web Vitals performance metrics from the frontend.
This endpoint receives Core Web Vitals (LCP, FID, CLS, TTFB, FCP) for monitoring.
"""
try:
# Log the metric for monitoring/analysis
logging.info(f"Web Vitals - {metric.name}: {metric.value}ms (ID: {metric.id}) from {metric.url or 'unknown'}")
# In a production setup, you might:
# - Store in database for trend analysis
# - Send to monitoring service (DataDog, New Relic, etc.)
# - Trigger alerts for poor performance
# For now, just acknowledge receipt
return {"status": "received", "metric": metric.name, "value": metric.value}
except Exception as e:
logging.error(f"Error processing web vitals metric: {e}")
raise HTTPException(status_code=500, detail="Failed to process metric")
# Health check for web vitals endpoint
@router.get("/web-vitals/health")
async def web_vitals_health():
"""Health check for web vitals collection endpoint"""
return {"status": "healthy", "service": "web-vitals"}

View File

@@ -70,7 +70,8 @@ class ExplorerService:
items: list[TransactionSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
status_label = _STATUS_LABELS.get(job.state, job.state.value.title())
state_val = job.state.value if hasattr(job.state, "value") else job.state
status_label = _STATUS_LABELS.get(job.state) or state_val.title()
# Try to get payment amount from receipt
value_str = "0"
@@ -118,14 +119,26 @@ class ExplorerService:
}
)
def touch(address: Optional[str], tx_id: str, when: datetime, earned: float = 0.0, spent: float = 0.0) -> None:
def _ensure_dt(val: object) -> datetime:
if isinstance(val, datetime):
return val.replace(tzinfo=None)
if isinstance(val, str):
try:
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
return dt.replace(tzinfo=None)
except ValueError:
return datetime.min
return datetime.min
def touch(address: Optional[str], tx_id: str, when: object, earned: float = 0.0, spent: float = 0.0) -> None:
if not address:
return
entry = address_map[address]
entry["address"] = address
entry["tx_count"] = int(entry["tx_count"]) + 1
if when > entry["last_active"]:
entry["last_active"] = when
when_dt = _ensure_dt(when)
if when_dt > _ensure_dt(entry["last_active"]):
entry["last_active"] = when_dt
# Track earnings and spending
entry["earned"] = float(entry["earned"]) + earned
entry["spent"] = float(entry["spent"]) + spent