Skip to content

Commit ff95645

Browse files
committed
Add collector alert notifications in navbar
Show a bell icon with badge count when any collector has errors (expired session, invalid token, etc). Clicking an alert navigates to the Settings page and highlights the relevant credential section. Alerts only appear after an actual collection failure, not preemptively.
1 parent 1ee60fc commit ff95645

5 files changed

Lines changed: 242 additions & 89 deletions

File tree

app/collectors/bytelixir.py

Lines changed: 27 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
"""Bytelixir earnings collector.
22
3-
Uses session cookie from the browser to fetch balance. Bytelixir is a
4-
Laravel app with hCaptcha on login, so automated email/password login
3+
Uses session cookie from the browser to fetch balance via /api/v1/user.
4+
Bytelixir is a Laravel app with hCaptcha on login, so automated login
55
is not possible. Users must extract session cookies from their browser.
66
77
To get the cookie: open dash.bytelixir.com, log in (tick "Remember Me"),
88
press F12 > Application > Cookies, and copy the `bytelixir_session` value.
9+
10+
Note: session expires after ~3.5 hours. When expired, the collector
11+
returns an error that surfaces as a notification in the CashPilot UI.
912
"""
1013

1114
from __future__ import annotations
1215

1316
import logging
14-
import re
1517

1618
import httpx
1719

1820
from app.collectors.base import BaseCollector, EarningsResult
1921

2022
logger = logging.getLogger(__name__)
2123

22-
DASH_BASE = "https://dash.bytelixir.com"
24+
API_BASE = "https://dash.bytelixir.com"
2325

2426

2527
class BytelixirCollector(BaseCollector):
@@ -33,61 +35,42 @@ def __init__(self, session_cookie: str) -> None:
3335
async def collect(self) -> EarningsResult:
3436
"""Fetch current Bytelixir balance."""
3537
try:
36-
cookies = {"bytelixir_session": self.session_cookie}
38+
cookies = httpx.Cookies()
39+
cookies.set("bytelixir_session", self.session_cookie, domain="dash.bytelixir.com")
40+
3741
headers = {
38-
"User-Agent": "Mozilla/5.0",
39-
"Accept": "application/json, text/html",
42+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
43+
"Accept": "application/json, text/plain, */*",
4044
"X-Requested-With": "XMLHttpRequest",
41-
"Referer": f"{DASH_BASE}/",
45+
"Referer": f"{API_BASE}/en",
46+
"Origin": API_BASE,
4247
}
4348

44-
async with httpx.AsyncClient(timeout=30, follow_redirects=True, cookies=cookies) as client:
45-
# Try the JSON API first
49+
async with httpx.AsyncClient(timeout=30, cookies=cookies) as client:
4650
resp = await client.get(
47-
f"{DASH_BASE}/api/v1/user",
51+
f"{API_BASE}/api/v1/user",
4852
headers=headers,
4953
)
5054

51-
if resp.status_code == 200:
52-
try:
53-
data = resp.json()
54-
balance = _extract_balance(data)
55-
if balance is not None:
56-
return EarningsResult(
57-
platform=self.platform,
58-
balance=round(balance, 4),
59-
currency="USD",
60-
)
61-
except Exception:
62-
pass
63-
64-
# Fallback: scrape the dashboard HTML for data-balance
65-
resp = await client.get(
66-
f"{DASH_BASE}/en",
67-
headers={**headers, "Accept": "text/html"},
68-
)
69-
70-
if resp.status_code == 200:
71-
balance = _extract_balance_from_html(resp.text)
72-
if balance is not None:
73-
return EarningsResult(
74-
platform=self.platform,
75-
balance=round(balance, 4),
76-
currency="USD",
77-
)
78-
79-
# Check if session expired (redirected to login)
80-
if "/sign-in" in str(resp.url):
55+
if resp.status_code == 401:
8156
return EarningsResult(
8257
platform=self.platform,
8358
balance=0.0,
84-
error="Session expired — get a new bytelixir_session cookie from your browser",
59+
error="Session expired — refresh bytelixir_session cookie in Settings",
8560
)
8661

62+
resp.raise_for_status()
63+
data = resp.json()
64+
65+
# Response shape: {"data": {"balance": "0.0000000000", ...}}
66+
user_data = data.get("data", {})
67+
balance_str = user_data.get("balance", "0")
68+
balance = float(balance_str)
69+
8770
return EarningsResult(
8871
platform=self.platform,
89-
balance=0.0,
90-
error="Could not extract balance from Bytelixir dashboard",
72+
balance=round(balance, 4),
73+
currency="USD",
9174
)
9275
except Exception as exc:
9376
logger.error("Bytelixir collection failed: %s", exc)
@@ -96,47 +79,3 @@ async def collect(self) -> EarningsResult:
9679
balance=0.0,
9780
error=str(exc),
9881
)
99-
100-
101-
def _extract_balance(data: dict) -> float | None:
102-
"""Try to extract a USD balance from JSON response."""
103-
for key in ("balance", "total_balance", "earnings", "total_earnings"):
104-
val = data.get(key)
105-
if val is not None:
106-
try:
107-
return float(val)
108-
except (ValueError, TypeError):
109-
continue
110-
111-
# Nested under 'data' or 'user'
112-
for wrapper in ("data", "user"):
113-
inner = data.get(wrapper, {})
114-
if isinstance(inner, dict):
115-
for key in ("balance", "total_balance", "earnings", "total_earnings"):
116-
val = inner.get(key)
117-
if val is not None:
118-
try:
119-
return float(val)
120-
except (ValueError, TypeError):
121-
continue
122-
return None
123-
124-
125-
def _extract_balance_from_html(html: str) -> float | None:
126-
"""Extract balance from data-balance attribute in dashboard HTML."""
127-
match = re.search(r'data-balance=["\']([^"\']+)["\']', html)
128-
if match:
129-
try:
130-
return float(match.group(1))
131-
except (ValueError, TypeError):
132-
pass
133-
134-
# Also try matching a balance-like number near "balance" text
135-
match = re.search(r"(?:balance|earnings)[^>]*>\s*\$?\s*([\d.]+)", html, re.IGNORECASE)
136-
if match:
137-
try:
138-
return float(match.group(1))
139-
except (ValueError, TypeError):
140-
pass
141-
142-
return None

app/main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131

3232
scheduler = AsyncIOScheduler()
3333

34+
# In-memory store for the latest collector alerts (errors from last run)
35+
_collector_alerts: list[dict[str, str]] = []
36+
3437

3538
# ---------------------------------------------------------------------------
3639
# Periodic collection job
@@ -54,23 +57,27 @@ async def _run_health_check() -> None:
5457

5558
async def _run_collection() -> None:
5659
"""Collect earnings from all deployed services that have collectors."""
60+
global _collector_alerts
5761
try:
5862
deployments = await database.get_deployments()
5963
config = await database.get_config() or {}
6064
if not isinstance(config, dict):
6165
config = {}
6266
collectors = __import__("app.collectors", fromlist=["make_collectors"]).make_collectors(deployments, config)
67+
alerts: list[dict[str, str]] = []
6368
for collector in collectors:
6469
result = await collector.collect()
6570
if result.error:
6671
logger.warning("Collection error for %s: %s", result.platform, result.error)
72+
alerts.append({"platform": result.platform, "error": result.error})
6773
else:
6874
await database.upsert_earnings(
6975
platform=result.platform,
7076
balance=result.balance,
7177
currency=result.currency,
7278
)
7379
logger.info("Collected %s: %.4f %s", result.platform, result.balance, result.currency)
80+
_collector_alerts = alerts
7481
except Exception as exc:
7582
logger.error("Collection run failed: %s", exc)
7683

@@ -788,6 +795,13 @@ async def api_collect(request: Request) -> dict[str, str]:
788795
return {"status": "collection_started"}
789796

790797

798+
@app.get("/api/collector-alerts")
799+
async def api_collector_alerts(request: Request) -> list[dict[str, str]]:
800+
"""Return collector errors from the last collection run."""
801+
_require_auth_api(request)
802+
return _collector_alerts
803+
804+
791805
# ---------------------------------------------------------------------------
792806
# API: User Preferences (onboarding state)
793807
# ---------------------------------------------------------------------------

app/static/css/style.css

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,112 @@ img { max-width: 100%; }
305305
:root[data-theme="light"] #theme-toggle .icon-sun { display: none; }
306306
:root[data-theme="light"] #theme-toggle .icon-moon { display: block; }
307307

308+
/* Notification bell */
309+
.topbar-notifications {
310+
position: relative;
311+
}
312+
.topbar-notify-btn {
313+
position: relative;
314+
}
315+
.notify-badge {
316+
position: absolute;
317+
top: -2px;
318+
right: -4px;
319+
min-width: 16px;
320+
height: 16px;
321+
line-height: 16px;
322+
font-size: 0.65rem;
323+
font-weight: 700;
324+
text-align: center;
325+
background: var(--error);
326+
color: #fff;
327+
border-radius: 8px;
328+
padding: 0 4px;
329+
pointer-events: none;
330+
}
331+
.notify-dropdown {
332+
display: none;
333+
position: absolute;
334+
top: calc(100% + 8px);
335+
right: 0;
336+
width: 320px;
337+
background: var(--bg-secondary);
338+
border: 1px solid var(--border-light);
339+
border-radius: var(--radius);
340+
box-shadow: var(--shadow-card-hover);
341+
z-index: 200;
342+
overflow: hidden;
343+
}
344+
.notify-dropdown.open {
345+
display: block;
346+
}
347+
.notify-header {
348+
padding: 10px 14px;
349+
font-size: 0.8rem;
350+
font-weight: 600;
351+
color: var(--text-secondary);
352+
border-bottom: 1px solid var(--border-color);
353+
text-transform: uppercase;
354+
letter-spacing: 0.04em;
355+
}
356+
.notify-list {
357+
max-height: 280px;
358+
overflow-y: auto;
359+
}
360+
.notify-item {
361+
display: flex;
362+
align-items: flex-start;
363+
gap: 10px;
364+
padding: 10px 14px;
365+
cursor: pointer;
366+
transition: background var(--transition);
367+
border-bottom: 1px solid var(--border-color);
368+
}
369+
.notify-item:last-child {
370+
border-bottom: none;
371+
}
372+
.notify-item:hover {
373+
background: var(--bg-hover);
374+
}
375+
.notify-item-icon {
376+
flex-shrink: 0;
377+
width: 28px;
378+
height: 28px;
379+
display: flex;
380+
align-items: center;
381+
justify-content: center;
382+
background: var(--error-soft);
383+
color: var(--error);
384+
border-radius: var(--radius-sm);
385+
}
386+
.notify-item-body {
387+
flex: 1;
388+
min-width: 0;
389+
}
390+
.notify-item-platform {
391+
font-weight: 600;
392+
font-size: 0.85rem;
393+
text-transform: capitalize;
394+
}
395+
.notify-item-msg {
396+
font-size: 0.78rem;
397+
color: var(--text-muted);
398+
margin-top: 2px;
399+
white-space: nowrap;
400+
overflow: hidden;
401+
text-overflow: ellipsis;
402+
}
403+
404+
/* Collector section highlight flash */
405+
.collector-section.highlight-flash {
406+
animation: flash-highlight 2s ease;
407+
}
408+
@keyframes flash-highlight {
409+
0%, 100% { background: transparent; }
410+
20% { background: var(--warning-soft); }
411+
50% { background: var(--warning-soft); }
412+
}
413+
308414
.topbar-earnings {
309415
display: flex;
310416
align-items: center;

0 commit comments

Comments
 (0)