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 ADDED
@@ -0,0 +1,7 @@
1
+ """WUP Browser Dashboard — FastAPI backend for WUP regression watcher."""
2
+
3
+ __version__ = "0.1.2"
4
+
5
+ from .main import create_app, app
6
+
7
+ __all__ = ["create_app", "app", "__version__"]
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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wupbro = wupbro.__main__:main
@@ -0,0 +1 @@
1
+ wupbro