wupbro 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wupbro/__init__.py +7 -0
- wupbro/__main__.py +21 -0
- wupbro/main.py +44 -0
- wupbro/models.py +59 -0
- wupbro/routers/__init__.py +1 -0
- wupbro/routers/dashboard.py +24 -0
- wupbro/routers/drivers.py +129 -0
- wupbro/routers/events.py +48 -0
- wupbro/storage.py +110 -0
- wupbro/templates/index.html +182 -0
- wupbro-0.1.2.dist-info/METADATA +154 -0
- wupbro-0.1.2.dist-info/RECORD +15 -0
- wupbro-0.1.2.dist-info/WHEEL +5 -0
- wupbro-0.1.2.dist-info/entry_points.txt +2 -0
- wupbro-0.1.2.dist-info/top_level.txt +1 -0
wupbro/__init__.py
ADDED
wupbro/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""CLI entry-point: `python -m wupbro` or `wupbro`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="wupbro", description="WUP Browser Dashboard")
|
|
11
|
+
parser.add_argument("--host", default=os.environ.get("WUPBRO_HOST", "0.0.0.0"))
|
|
12
|
+
parser.add_argument("--port", type=int, default=int(os.environ.get("WUPBRO_PORT", "8000")))
|
|
13
|
+
parser.add_argument("--reload", action="store_true", help="auto-reload (dev)")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
import uvicorn
|
|
17
|
+
uvicorn.run("wupbro.main:app", host=args.host, port=args.port, reload=args.reload)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
wupbro/main.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""FastAPI entry-point for wupbro."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .routers import dashboard, drivers, events
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_app() -> FastAPI:
|
|
13
|
+
app = FastAPI(
|
|
14
|
+
title="WUP Browser Dashboard",
|
|
15
|
+
version=__version__,
|
|
16
|
+
description=(
|
|
17
|
+
"Backend for the WUP regression watcher. Receives events from "
|
|
18
|
+
"WUP agents (REGRESSION, PASS, ANOMALY, VISUAL_DIFF, HEALTH_TRANSITION), "
|
|
19
|
+
"exposes drivers (DOM diff, browserless, anomaly), and serves a dashboard."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Permissive CORS — dashboard usually served from same origin, but agents
|
|
24
|
+
# may live elsewhere. Tighten in production.
|
|
25
|
+
app.add_middleware(
|
|
26
|
+
CORSMiddleware,
|
|
27
|
+
allow_origins=["*"],
|
|
28
|
+
allow_methods=["*"],
|
|
29
|
+
allow_headers=["*"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.include_router(events.router)
|
|
33
|
+
app.include_router(drivers.router)
|
|
34
|
+
app.include_router(dashboard.router)
|
|
35
|
+
|
|
36
|
+
@app.get("/healthz", tags=["meta"])
|
|
37
|
+
async def healthz():
|
|
38
|
+
return {"ok": True, "version": __version__}
|
|
39
|
+
|
|
40
|
+
return app
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Module-level instance for `uvicorn wupbro.main:app`
|
|
44
|
+
app = create_app()
|
wupbro/models.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Pydantic models for wupbro events and driver requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
EventType = Literal[
|
|
12
|
+
"REGRESSION",
|
|
13
|
+
"PASS",
|
|
14
|
+
"ANOMALY",
|
|
15
|
+
"VISUAL_DIFF",
|
|
16
|
+
"HEALTH_TRANSITION",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Event(BaseModel):
|
|
21
|
+
"""Generic WUP event posted by an agent."""
|
|
22
|
+
|
|
23
|
+
type: EventType
|
|
24
|
+
service: Optional[str] = None
|
|
25
|
+
file: Optional[str] = None
|
|
26
|
+
endpoint: Optional[str] = None
|
|
27
|
+
url: Optional[str] = None
|
|
28
|
+
status: Optional[str] = None
|
|
29
|
+
stage: Optional[str] = None
|
|
30
|
+
reason: Optional[str] = None
|
|
31
|
+
diff: Optional[Dict[str, Any]] = None
|
|
32
|
+
timestamp: int = Field(default_factory=lambda: int(time.time()))
|
|
33
|
+
|
|
34
|
+
# Forward-compat: allow extra keys to land in `extra`
|
|
35
|
+
model_config = {"extra": "allow"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EventList(BaseModel):
|
|
39
|
+
items: List[Event]
|
|
40
|
+
total: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DomDiffRequest(BaseModel):
|
|
44
|
+
url: str
|
|
45
|
+
service: str = "default"
|
|
46
|
+
max_depth: int = 10
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScreenshotRequest(BaseModel):
|
|
50
|
+
url: str
|
|
51
|
+
full_page: bool = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AnomalyReport(BaseModel):
|
|
55
|
+
service: str
|
|
56
|
+
metric: str
|
|
57
|
+
value: float
|
|
58
|
+
threshold: float
|
|
59
|
+
timestamp: int = Field(default_factory=lambda: int(time.time()))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Routers for wupbro."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Minimal HTML dashboard rendered with Jinja2 (no React required)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
from fastapi.responses import HTMLResponse
|
|
9
|
+
from fastapi.templating import Jinja2Templates
|
|
10
|
+
|
|
11
|
+
router = APIRouter(tags=["dashboard"])
|
|
12
|
+
|
|
13
|
+
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
|
14
|
+
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/", response_class=HTMLResponse)
|
|
18
|
+
async def root(request: Request) -> HTMLResponse:
|
|
19
|
+
return templates.TemplateResponse(request, "index.html")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/dashboard", response_class=HTMLResponse)
|
|
23
|
+
async def dashboard(request: Request) -> HTMLResponse:
|
|
24
|
+
return templates.TemplateResponse(request, "index.html")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Driver endpoints exposed by wupbro.
|
|
2
|
+
|
|
3
|
+
- /drivers/dom-diff/capture → uses wup.visual_diff.VisualDiffer (Playwright)
|
|
4
|
+
- /drivers/browserless/screenshot → proxies a browserless container
|
|
5
|
+
- /drivers/anomaly/report → records anomaly metrics into the event store
|
|
6
|
+
|
|
7
|
+
Each driver degrades gracefully if its underlying dependency is missing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
18
|
+
|
|
19
|
+
from ..models import AnomalyReport, DomDiffRequest, ScreenshotRequest, Event
|
|
20
|
+
from ..storage import EventStore, get_default_store
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/drivers", tags=["drivers"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _store() -> EventStore:
|
|
26
|
+
return get_default_store()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# DOM diff driver — relies on the wup package being installed alongside.
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@router.post("/dom-diff/capture")
|
|
34
|
+
async def dom_diff_capture(req: DomDiffRequest,
|
|
35
|
+
store: EventStore = Depends(_store)) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Trigger a one-shot DOM snapshot + diff against the previous snapshot.
|
|
38
|
+
|
|
39
|
+
Requires: `pip install wup playwright` and `playwright install chromium`.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
from wup.models.config import VisualDiffConfig
|
|
43
|
+
from wup.visual_diff import VisualDiffer
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
raise HTTPException(503, detail=f"wup package not available: {exc}")
|
|
46
|
+
|
|
47
|
+
cfg = VisualDiffConfig(
|
|
48
|
+
enabled=True,
|
|
49
|
+
pages=[req.url],
|
|
50
|
+
pages_from_endpoints=False,
|
|
51
|
+
max_depth=req.max_depth,
|
|
52
|
+
delay_seconds=0,
|
|
53
|
+
)
|
|
54
|
+
differ = VisualDiffer(os.getcwd(), cfg)
|
|
55
|
+
results = await differ.run_for_service(req.service, [])
|
|
56
|
+
|
|
57
|
+
# forward each diff to the event store
|
|
58
|
+
for r in results:
|
|
59
|
+
store.add(Event(
|
|
60
|
+
type="VISUAL_DIFF",
|
|
61
|
+
service=req.service,
|
|
62
|
+
url=r.get("url"),
|
|
63
|
+
diff=r.get("diff"),
|
|
64
|
+
timestamp=int(time.time()),
|
|
65
|
+
))
|
|
66
|
+
return {"results": results}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Browserless proxy — POSTs to an external browserless/chrome container.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@router.post("/browserless/screenshot")
|
|
74
|
+
async def browserless_screenshot(req: ScreenshotRequest) -> Dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
Proxy to a `browserless/chrome` container.
|
|
77
|
+
|
|
78
|
+
Set BROWSERLESS_URL (default http://browserless:3000) for the upstream.
|
|
79
|
+
"""
|
|
80
|
+
upstream = os.environ.get("BROWSERLESS_URL", "http://browserless:3000")
|
|
81
|
+
body = {"url": req.url, "options": {"fullPage": req.full_page}}
|
|
82
|
+
try:
|
|
83
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
84
|
+
resp = await client.post(f"{upstream}/screenshot", json=body)
|
|
85
|
+
if resp.status_code >= 400:
|
|
86
|
+
raise HTTPException(resp.status_code, detail=resp.text)
|
|
87
|
+
return {
|
|
88
|
+
"upstream": upstream,
|
|
89
|
+
"status": resp.status_code,
|
|
90
|
+
"content_type": resp.headers.get("content-type", ""),
|
|
91
|
+
"size_bytes": len(resp.content),
|
|
92
|
+
}
|
|
93
|
+
except httpx.HTTPError as exc:
|
|
94
|
+
raise HTTPException(503, detail=f"browserless unreachable: {exc}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Anomaly driver — record numeric anomalies as events.
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@router.post("/anomaly/report", status_code=201)
|
|
102
|
+
async def anomaly_report(report: AnomalyReport,
|
|
103
|
+
store: EventStore = Depends(_store)) -> Dict[str, Any]:
|
|
104
|
+
store.add(Event(
|
|
105
|
+
type="ANOMALY",
|
|
106
|
+
service=report.service,
|
|
107
|
+
status="anomaly",
|
|
108
|
+
reason=f"{report.metric}={report.value} > threshold={report.threshold}",
|
|
109
|
+
timestamp=report.timestamp,
|
|
110
|
+
))
|
|
111
|
+
return {"accepted": True, "service": report.service}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/health")
|
|
115
|
+
async def driver_health() -> Dict[str, Any]:
|
|
116
|
+
"""Best-effort capability discovery."""
|
|
117
|
+
caps: Dict[str, bool] = {"events": True, "anomaly": True}
|
|
118
|
+
try:
|
|
119
|
+
import wup.visual_diff # noqa: F401
|
|
120
|
+
caps["dom_diff"] = True
|
|
121
|
+
except ImportError:
|
|
122
|
+
caps["dom_diff"] = False
|
|
123
|
+
try:
|
|
124
|
+
import playwright # noqa: F401
|
|
125
|
+
caps["playwright"] = True
|
|
126
|
+
except ImportError:
|
|
127
|
+
caps["playwright"] = False
|
|
128
|
+
caps["browserless_url"] = os.environ.get("BROWSERLESS_URL", "")
|
|
129
|
+
return caps
|
wupbro/routers/events.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""POST /events — receive events from WUP agents.
|
|
2
|
+
|
|
3
|
+
GET /events — list recent events (filterable by type/service).
|
|
4
|
+
GET /events/stats — aggregate counts by type.
|
|
5
|
+
DELETE /events — clear the store (admin/debug).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Query, Depends
|
|
13
|
+
|
|
14
|
+
from ..models import Event, EventList
|
|
15
|
+
from ..storage import EventStore, get_default_store
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/events", tags=["events"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _store() -> EventStore:
|
|
21
|
+
return get_default_store()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.post("", status_code=201)
|
|
25
|
+
async def post_event(event: Event, store: EventStore = Depends(_store)) -> dict:
|
|
26
|
+
store.add(event)
|
|
27
|
+
return {"accepted": True, "type": event.type, "timestamp": event.timestamp}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("", response_model=EventList)
|
|
31
|
+
async def list_events(
|
|
32
|
+
type: Optional[str] = Query(None, description="Filter by event type"),
|
|
33
|
+
service: Optional[str] = Query(None, description="Filter by service name"),
|
|
34
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
35
|
+
store: EventStore = Depends(_store),
|
|
36
|
+
) -> EventList:
|
|
37
|
+
items = store.list(type_filter=type, service_filter=service, limit=limit)
|
|
38
|
+
return EventList(items=items, total=len(items))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/stats")
|
|
42
|
+
async def event_stats(store: EventStore = Depends(_store)) -> dict:
|
|
43
|
+
return store.stats()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.delete("", status_code=204)
|
|
47
|
+
async def clear_events(store: EventStore = Depends(_store)) -> None:
|
|
48
|
+
store.clear()
|
wupbro/storage.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""In-memory + JSONL persistence for WUP events.
|
|
2
|
+
|
|
3
|
+
The storage layer is intentionally simple: events are kept in memory
|
|
4
|
+
(deque, capped) AND appended to a JSONL file for persistence between
|
|
5
|
+
restarts. Suitable for a single-node dashboard.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
from collections import deque
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Deque, Iterable, List, Optional
|
|
15
|
+
|
|
16
|
+
from .models import Event
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventStore:
|
|
20
|
+
"""Thread-safe ring buffer + JSONL persistence."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, jsonl_path: Optional[Path] = None, capacity: int = 1000):
|
|
23
|
+
self.capacity = capacity
|
|
24
|
+
self.events: Deque[Event] = deque(maxlen=capacity)
|
|
25
|
+
self.jsonl_path = jsonl_path
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._seq = 0 # monotonic insertion counter (tiebreaker for sort)
|
|
28
|
+
self._seq_by_id: dict = {}
|
|
29
|
+
if self.jsonl_path:
|
|
30
|
+
self.jsonl_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self._load_existing()
|
|
32
|
+
|
|
33
|
+
def _load_existing(self) -> None:
|
|
34
|
+
if not self.jsonl_path or not self.jsonl_path.exists():
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
with self.jsonl_path.open("r", encoding="utf-8") as fh:
|
|
38
|
+
for line in fh:
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if not line:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
self.events.append(Event.model_validate_json(line))
|
|
44
|
+
except Exception:
|
|
45
|
+
continue
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def add(self, event: Event) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._seq += 1
|
|
52
|
+
self._seq_by_id[id(event)] = self._seq
|
|
53
|
+
self.events.append(event)
|
|
54
|
+
if self.jsonl_path:
|
|
55
|
+
try:
|
|
56
|
+
with self.jsonl_path.open("a", encoding="utf-8") as fh:
|
|
57
|
+
fh.write(event.model_dump_json() + "\n")
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def list(
|
|
62
|
+
self,
|
|
63
|
+
type_filter: Optional[str] = None,
|
|
64
|
+
service_filter: Optional[str] = None,
|
|
65
|
+
limit: int = 100,
|
|
66
|
+
) -> List[Event]:
|
|
67
|
+
with self._lock:
|
|
68
|
+
items: List[Event] = list(self.events)
|
|
69
|
+
seq_map = dict(self._seq_by_id)
|
|
70
|
+
if type_filter:
|
|
71
|
+
items = [e for e in items if e.type == type_filter]
|
|
72
|
+
if service_filter:
|
|
73
|
+
items = [e for e in items if e.service == service_filter]
|
|
74
|
+
# newest first: primary key timestamp, tiebreaker insertion sequence
|
|
75
|
+
items.sort(key=lambda e: (e.timestamp, seq_map.get(id(e), 0)), reverse=True)
|
|
76
|
+
return items[:limit]
|
|
77
|
+
|
|
78
|
+
def clear(self) -> None:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self.events.clear()
|
|
81
|
+
self._seq_by_id.clear()
|
|
82
|
+
self._seq = 0
|
|
83
|
+
if self.jsonl_path and self.jsonl_path.exists():
|
|
84
|
+
try:
|
|
85
|
+
self.jsonl_path.unlink()
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def stats(self) -> dict:
|
|
90
|
+
with self._lock:
|
|
91
|
+
counts: dict = {}
|
|
92
|
+
for e in self.events:
|
|
93
|
+
counts[e.type] = counts.get(e.type, 0) + 1
|
|
94
|
+
return {"total": len(self.events), "by_type": counts}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Module-level default store (used by routers); tests can replace via dependency override.
|
|
98
|
+
_default_store: Optional[EventStore] = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_default_store() -> EventStore:
|
|
102
|
+
global _default_store
|
|
103
|
+
if _default_store is None:
|
|
104
|
+
_default_store = EventStore(jsonl_path=Path(".wupbro/events.jsonl"))
|
|
105
|
+
return _default_store
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_default_store(store: EventStore) -> None:
|
|
109
|
+
global _default_store
|
|
110
|
+
_default_store = store
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>WUP Dashboard</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0d1117;
|
|
10
|
+
--panel: #161b22;
|
|
11
|
+
--border: #30363d;
|
|
12
|
+
--text: #e6edf3;
|
|
13
|
+
--muted: #7d8590;
|
|
14
|
+
--green: #3fb950;
|
|
15
|
+
--red: #f85149;
|
|
16
|
+
--yellow: #d29922;
|
|
17
|
+
--blue: #58a6ff;
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body {
|
|
21
|
+
margin: 0;
|
|
22
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
23
|
+
background: var(--bg);
|
|
24
|
+
color: var(--text);
|
|
25
|
+
}
|
|
26
|
+
header {
|
|
27
|
+
padding: 16px 24px;
|
|
28
|
+
border-bottom: 1px solid var(--border);
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 16px;
|
|
32
|
+
}
|
|
33
|
+
header h1 { font-size: 18px; margin: 0; }
|
|
34
|
+
header .badge {
|
|
35
|
+
padding: 2px 8px; border-radius: 12px; background: var(--panel);
|
|
36
|
+
color: var(--muted); font-size: 12px;
|
|
37
|
+
}
|
|
38
|
+
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
|
39
|
+
.stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
40
|
+
.stat {
|
|
41
|
+
flex: 1; min-width: 120px;
|
|
42
|
+
padding: 16px; background: var(--panel); border: 1px solid var(--border);
|
|
43
|
+
border-radius: 8px;
|
|
44
|
+
}
|
|
45
|
+
.stat .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
|
46
|
+
.stat .value { font-size: 24px; font-weight: 600; margin-top: 4px; }
|
|
47
|
+
.stat.regression .value { color: var(--red); }
|
|
48
|
+
.stat.pass .value { color: var(--green); }
|
|
49
|
+
.stat.anomaly .value { color: var(--yellow); }
|
|
50
|
+
.stat.diff .value { color: var(--blue); }
|
|
51
|
+
|
|
52
|
+
table { width: 100%; border-collapse: collapse; background: var(--panel); border-radius: 8px; overflow: hidden; }
|
|
53
|
+
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
|
|
54
|
+
th { background: #1c2129; color: var(--muted); font-weight: 500; text-transform: uppercase; font-size: 11px; }
|
|
55
|
+
tr:last-child td { border-bottom: none; }
|
|
56
|
+
|
|
57
|
+
.type-tag {
|
|
58
|
+
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500;
|
|
59
|
+
display: inline-block; min-width: 70px; text-align: center;
|
|
60
|
+
}
|
|
61
|
+
.type-REGRESSION { background: rgba(248,81,73,.15); color: var(--red); }
|
|
62
|
+
.type-PASS { background: rgba(63,185,80,.15); color: var(--green); }
|
|
63
|
+
.type-ANOMALY { background: rgba(210,153,34,.15); color: var(--yellow); }
|
|
64
|
+
.type-VISUAL_DIFF { background: rgba(88,166,255,.15); color: var(--blue); }
|
|
65
|
+
.type-HEALTH_TRANSITION { background: rgba(125,133,144,.15); color: var(--muted); }
|
|
66
|
+
|
|
67
|
+
.controls { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
68
|
+
button, select {
|
|
69
|
+
background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
|
70
|
+
padding: 6px 12px; border-radius: 6px; font-size: 13px; cursor: pointer;
|
|
71
|
+
}
|
|
72
|
+
button:hover { background: #1c2129; }
|
|
73
|
+
.muted { color: var(--muted); }
|
|
74
|
+
.empty { text-align: center; padding: 40px; color: var(--muted); }
|
|
75
|
+
</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<header>
|
|
79
|
+
<h1>🔬 WUP Dashboard</h1>
|
|
80
|
+
<span class="badge" id="status">connecting…</span>
|
|
81
|
+
<span class="badge muted" id="last-update">—</span>
|
|
82
|
+
</header>
|
|
83
|
+
<main>
|
|
84
|
+
<div class="stats" id="stats"></div>
|
|
85
|
+
<div class="controls">
|
|
86
|
+
<select id="filter-type">
|
|
87
|
+
<option value="">All types</option>
|
|
88
|
+
<option value="REGRESSION">Regression</option>
|
|
89
|
+
<option value="PASS">Pass</option>
|
|
90
|
+
<option value="ANOMALY">Anomaly</option>
|
|
91
|
+
<option value="VISUAL_DIFF">Visual diff</option>
|
|
92
|
+
<option value="HEALTH_TRANSITION">Health transition</option>
|
|
93
|
+
</select>
|
|
94
|
+
<button onclick="refresh()">↻ Refresh</button>
|
|
95
|
+
<button onclick="clearAll()" style="color: var(--red)">⌫ Clear</button>
|
|
96
|
+
</div>
|
|
97
|
+
<table>
|
|
98
|
+
<thead>
|
|
99
|
+
<tr>
|
|
100
|
+
<th>Time</th><th>Type</th><th>Service</th>
|
|
101
|
+
<th>Endpoint / URL</th><th>Status</th><th>Reason</th>
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<tbody id="rows">
|
|
105
|
+
<tr><td colspan="6" class="empty">Loading…</td></tr>
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</main>
|
|
109
|
+
|
|
110
|
+
<script>
|
|
111
|
+
const fmtTime = (ts) => new Date(ts * 1000).toLocaleTimeString();
|
|
112
|
+
|
|
113
|
+
async function fetchEvents() {
|
|
114
|
+
const filter = document.getElementById("filter-type").value;
|
|
115
|
+
const url = filter ? `/events?type=${filter}&limit=200` : "/events?limit=200";
|
|
116
|
+
const r = await fetch(url);
|
|
117
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
118
|
+
return r.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchStats() {
|
|
122
|
+
const r = await fetch("/events/stats");
|
|
123
|
+
return r.json();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderStats(stats) {
|
|
127
|
+
const types = ["REGRESSION", "PASS", "ANOMALY", "VISUAL_DIFF", "HEALTH_TRANSITION"];
|
|
128
|
+
const cls = { REGRESSION: "regression", PASS: "pass", ANOMALY: "anomaly", VISUAL_DIFF: "diff", HEALTH_TRANSITION: "" };
|
|
129
|
+
const labels = { REGRESSION: "Regressions", PASS: "Passes", ANOMALY: "Anomalies", VISUAL_DIFF: "Visual diffs", HEALTH_TRANSITION: "Health" };
|
|
130
|
+
const container = document.getElementById("stats");
|
|
131
|
+
const totalCard = `<div class="stat"><div class="label">Total events</div><div class="value">${stats.total}</div></div>`;
|
|
132
|
+
const cards = types.map(t => {
|
|
133
|
+
const v = (stats.by_type && stats.by_type[t]) || 0;
|
|
134
|
+
return `<div class="stat ${cls[t] || ""}"><div class="label">${labels[t]}</div><div class="value">${v}</div></div>`;
|
|
135
|
+
});
|
|
136
|
+
container.innerHTML = totalCard + cards.join("");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderRows(items) {
|
|
140
|
+
const tbody = document.getElementById("rows");
|
|
141
|
+
if (!items.length) {
|
|
142
|
+
tbody.innerHTML = '<tr><td colspan="6" class="empty">No events yet</td></tr>';
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
tbody.innerHTML = items.map(e => `
|
|
146
|
+
<tr>
|
|
147
|
+
<td class="muted">${fmtTime(e.timestamp)}</td>
|
|
148
|
+
<td><span class="type-tag type-${e.type}">${e.type}</span></td>
|
|
149
|
+
<td>${e.service || "—"}</td>
|
|
150
|
+
<td class="muted">${e.endpoint || e.url || "—"}</td>
|
|
151
|
+
<td>${e.status || "—"}</td>
|
|
152
|
+
<td class="muted">${e.reason || "—"}</td>
|
|
153
|
+
</tr>
|
|
154
|
+
`).join("");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function refresh() {
|
|
158
|
+
try {
|
|
159
|
+
const [events, stats] = await Promise.all([fetchEvents(), fetchStats()]);
|
|
160
|
+
renderStats(stats);
|
|
161
|
+
renderRows(events.items);
|
|
162
|
+
document.getElementById("status").textContent = "live";
|
|
163
|
+
document.getElementById("status").style.color = "var(--green)";
|
|
164
|
+
document.getElementById("last-update").textContent = "updated " + new Date().toLocaleTimeString();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
document.getElementById("status").textContent = "offline";
|
|
167
|
+
document.getElementById("status").style.color = "var(--red)";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function clearAll() {
|
|
172
|
+
if (!confirm("Clear all events?")) return;
|
|
173
|
+
await fetch("/events", { method: "DELETE" });
|
|
174
|
+
refresh();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
document.getElementById("filter-type").addEventListener("change", refresh);
|
|
178
|
+
refresh();
|
|
179
|
+
setInterval(refresh, 3000);
|
|
180
|
+
</script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wupbro
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: WUP Browser Dashboard — FastAPI backend for WUP regression watcher
|
|
5
|
+
Author: WUP contributors
|
|
6
|
+
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: fastapi>=0.110
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Requires-Dist: jinja2>=3.1
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
19
|
+
Requires-Dist: goal>=2.1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: costs>=0.1.20; extra == "dev"
|
|
21
|
+
Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# wupbro
|
|
24
|
+
|
|
25
|
+
FastAPI backend + minimal HTML dashboard for the WUP regression watcher.
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
┌──────────────────────┐ POST /events ┌──────────────────────┐
|
|
31
|
+
│ WUP agent (shell) │ ───────────────▶ │ wupbro (FastAPI) │
|
|
32
|
+
│ - file watcher │ │ - /events (sink) │
|
|
33
|
+
│ - testql runner │ │ - /drivers/* │
|
|
34
|
+
│ - visual diff │ │ - /dashboard (UI) │
|
|
35
|
+
└──────────────────────┘ └──────────────────────┘
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
┌─────────────────┐
|
|
39
|
+
│ Browser UI │
|
|
40
|
+
│ (auto-refresh) │
|
|
41
|
+
└─────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Endpoints
|
|
45
|
+
|
|
46
|
+
| Path | Method | Purpose |
|
|
47
|
+
|-----------------------------------|--------|---------------------------------------------|
|
|
48
|
+
| `/events` | POST | Receive event from a WUP agent |
|
|
49
|
+
| `/events` | GET | List recent events (filter by type/service) |
|
|
50
|
+
| `/events/stats` | GET | Aggregate counts by type |
|
|
51
|
+
| `/events` | DELETE | Clear store (admin/debug) |
|
|
52
|
+
| `/drivers/dom-diff/capture` | POST | One-shot Playwright DOM snapshot + diff |
|
|
53
|
+
| `/drivers/browserless/screenshot` | POST | Proxy to a `browserless/chrome` container |
|
|
54
|
+
| `/drivers/anomaly/report` | POST | Record numeric anomaly as ANOMALY event |
|
|
55
|
+
| `/drivers/health` | GET | Driver capability discovery |
|
|
56
|
+
| `/`, `/dashboard` | GET | HTML dashboard |
|
|
57
|
+
| `/healthz` | GET | Liveness probe |
|
|
58
|
+
| `/openapi.json`, `/docs` | GET | OpenAPI spec + Swagger UI |
|
|
59
|
+
|
|
60
|
+
## Event types
|
|
61
|
+
|
|
62
|
+
`REGRESSION`, `PASS`, `ANOMALY`, `VISUAL_DIFF`, `HEALTH_TRANSITION`.
|
|
63
|
+
|
|
64
|
+
Schema (Pydantic):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"type": "REGRESSION",
|
|
69
|
+
"service": "users-web",
|
|
70
|
+
"file": "app/users/routes.py",
|
|
71
|
+
"endpoint": "/api/users",
|
|
72
|
+
"status": "fail",
|
|
73
|
+
"stage": "quick",
|
|
74
|
+
"reason": "TestQL exit code 1",
|
|
75
|
+
"timestamp": 1730000000
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Extra fields are preserved via `model_config = {"extra": "allow"}`.
|
|
80
|
+
|
|
81
|
+
## Run
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Install
|
|
85
|
+
pip install -e wupbro/
|
|
86
|
+
|
|
87
|
+
# Dev server (auto-reload)
|
|
88
|
+
wupbro --reload --port 8000
|
|
89
|
+
|
|
90
|
+
# Or directly
|
|
91
|
+
uvicorn wupbro.main:app --host 0.0.0.0 --port 8000 --reload
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Dashboard: <http://localhost:8000/>
|
|
95
|
+
|
|
96
|
+
OpenAPI docs: <http://localhost:8000/docs>
|
|
97
|
+
|
|
98
|
+
## Configure WUP agent to send events
|
|
99
|
+
|
|
100
|
+
In your `wup.yaml`:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
web:
|
|
104
|
+
enabled: true
|
|
105
|
+
endpoint: "http://localhost:8000"
|
|
106
|
+
endpoint_env: "WUPBRO_ENDPOINT" # fallback if endpoint is empty
|
|
107
|
+
timeout_s: 2.0 # short — must not block watcher
|
|
108
|
+
api_key: "" # optional bearer token
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or via environment variable:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
export WUPBRO_ENDPOINT=http://localhost:8000
|
|
115
|
+
wup watch . --mode testql
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The agent will POST events fire-and-forget on:
|
|
119
|
+
|
|
120
|
+
- service health transitions (up ↔ down)
|
|
121
|
+
- regressions (when TestQL fails)
|
|
122
|
+
- visual DOM diffs (when significant changes detected)
|
|
123
|
+
|
|
124
|
+
## Browserless integration
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
docker run -d --name browserless -p 3000:3000 browserless/chrome
|
|
128
|
+
export BROWSERLESS_URL=http://localhost:3000
|
|
129
|
+
wup-web
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
curl -X POST http://localhost:8000/drivers/browserless/screenshot \
|
|
136
|
+
-H "Content-Type: application/json" \
|
|
137
|
+
-d '{"url": "http://example.com", "full_page": true}'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Storage
|
|
141
|
+
|
|
142
|
+
Events are kept in an in-memory ring buffer (capacity 1000) and persisted to `.wupbro/events.jsonl` for restart durability.
|
|
143
|
+
|
|
144
|
+
## Tests
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
PYTHONPATH=wupbro python3 -m pytest wupbro/tests/ -v
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
17 tests covering events router, drivers (anomaly, browserless, dom-diff, health), dashboard HTML, OpenAPI schema.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
Licensed under Apache-2.0.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
wupbro/__init__.py,sha256=zCuE1TMepCmf2qAt_RQkYCbeFc0JbHTVrnXtxM-94Ss,182
|
|
2
|
+
wupbro/__main__.py,sha256=oXLjPSkNX7vbOXzNRukO_VXaF6_zCy_hVEf-R6XQaM0,670
|
|
3
|
+
wupbro/main.py,sha256=6ZtBJD3xchb7kTcGNovP5hgUUq9JcAf-vVrjp2fw9hY,1236
|
|
4
|
+
wupbro/models.py,sha256=cayG7Iz2h29ln_vcMRdH9gp4ikKCx4CNALCl5-RISz8,1269
|
|
5
|
+
wupbro/storage.py,sha256=2MjZCoazshSaiVX0_oQYs_tR8yCtOx5aB6Emtjsy-s4,3675
|
|
6
|
+
wupbro/routers/__init__.py,sha256=nqd9_ug9P9oE4E50eIn6fD6vMdO-pFuDuhe28XXiPDk,26
|
|
7
|
+
wupbro/routers/dashboard.py,sha256=5qbBsigVdA9psL03n0LvUSSk1IN_qtGiiU1WxAErFag,765
|
|
8
|
+
wupbro/routers/drivers.py,sha256=GiJqBHJTNP6cpau2egyzQxvf70xagGQfA-lMrCYFvzY,4564
|
|
9
|
+
wupbro/routers/events.py,sha256=fuK8IzIYQQoyi2kHfAxn6ACZoXv3M-v7jepNkb67KeU,1458
|
|
10
|
+
wupbro/templates/index.html,sha256=Vs0Ufm6UCQtaT_BE0JNoWMCRjxFXISLNIhCV548cdGc,6759
|
|
11
|
+
wupbro-0.1.2.dist-info/METADATA,sha256=DiBad3BoYOm0XgujRnRIv7PqU80nngRgRodtbAVvnfA,5182
|
|
12
|
+
wupbro-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
wupbro-0.1.2.dist-info/entry_points.txt,sha256=ZoiFlqDNWFI0Or8T0csnW80lD77Z17DBJYzgh3gVqgg,48
|
|
14
|
+
wupbro-0.1.2.dist-info/top_level.txt,sha256=TvEt3F9VMcrEO6LmPEPiKnIqaEcvGhJUTpeGDnwB_Gw,7
|
|
15
|
+
wupbro-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wupbro
|