From 27b1461cd07030028c5c0f7a6df98d4918b45b0c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:38:20 +0200 Subject: [PATCH] feat: clearer earnings-tracking state on the dashboard (#82 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployed service can be running and earning while CashPilot still can't show its balance, because the earnings collector uses SEPARATE credentials (e.g. Repocket: container = email+API key; collector = email+password). The dashboard previously rendered a single red "disconnected" badge that conflated two very different states and misled users into thinking the container was broken. Backend: - _collector_needs_setup(slug, config): True when a deployed service has a collector whose required config keys are unset. Surface it as `collector_needs_setup` on /api/services/deployed (Docker + external), suppressed when the collector has actually errored (disconnected wins). - Config read is defensively guarded so a DB hiccup degrades to "unknown" instead of 500ing the dashboard. - /api/services/{slug} now returns `has_collector`. Frontend: - Split the badge into two honest states: muted "tracking not set up → set up" (creds never entered) vs red "can't read balance → fix" (collector ran and failed). Replaces the misleading "disconnected". - Deploy modal shows a note for services with a collector: the deploy credentials run the service; to see the balance, add earnings-tracking credentials under Settings → Collectors (optional — it earns either way). Tests: +6 (needs-setup vs present vs disconnected-precedence, helper unit, has_collector flag). --- app/main.py | 38 +++++++++++++ app/static/js/app.js | 25 +++++++-- tests/test_main_routes.py | 110 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index a9696dc..436283b 100644 --- a/app/main.py +++ b/app/main.py @@ -417,6 +417,25 @@ async def api_list_services(request: Request) -> list[dict[str, Any]]: return catalog.get_services() +def _collector_needs_setup(slug: str, config: dict[str, str]) -> bool: + """True if `slug` has an earnings collector whose required config is unset. + + A service can be deployed and earning while CashPilot still can't read its + balance because the (separate) collector credentials haven't been entered. + This distinguishes that "not set up yet" state from a real collector error. + """ + from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP + + if slug not in COLLECTOR_MAP: + return False + for arg in _COLLECTOR_ARGS.get(slug, []): + if arg.startswith("?"): # optional arg — not required for setup + continue + if not config.get(f"{slug}_{arg}", ""): + return True + return False + + @app.get("/api/services/deployed") async def api_services_deployed(request: Request) -> list[dict[str, Any]]: """Return deployed services with container status, balance, CPU, memory. @@ -440,6 +459,16 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: # Build set of slugs with collector errors (disconnected) alert_slugs = {a["platform"] for a in _collector_alerts} + # Config (decrypted) to detect collectors whose credentials aren't set yet. + # A config-read failure must not blank the dashboard — degrade to "unknown". + config: dict[str, str] = {} + try: + cfg = await database.get_config() + if isinstance(cfg, dict): + config = cfg + except Exception as exc: + logger.warning("Could not load config for collector-setup check: %s", exc) + # Aggregate by slug: one row per service _STATUS_PRIORITY = {"running": 0, "restarting": 1, "exited": 2, "created": 3, "dead": 4} slug_agg: dict[str, dict[str, Any]] = {} @@ -502,6 +531,7 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: "instances": len(agg["instances"]), "instance_details": instance_details, "collector_disconnected": slug in alert_slugs, + "collector_needs_setup": slug not in alert_slugs and _collector_needs_setup(slug, config), } if svc: cashout = svc.get("cashout", {}) @@ -540,6 +570,7 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: "instances": 0, "instance_details": [], "collector_disconnected": slug in alert_slugs, + "collector_needs_setup": slug not in alert_slugs and _collector_needs_setup(slug, config), } if svc: cashout = svc.get("cashout", {}) @@ -608,6 +639,13 @@ async def api_get_service(request: Request, slug: str) -> dict[str, Any]: svc["deployed"] = slug in deployed_slugs or slug in worker_slugs svc["node_count"] = len(worker_nodes) + + # Flag whether earnings tracking uses separate credentials (entered in + # Settings → Collectors), so the deploy UI can tell users the container + # credentials alone won't populate the in-dashboard balance. + from app.collectors import COLLECTOR_MAP + + svc["has_collector"] = slug in COLLECTOR_MAP return svc diff --git a/app/static/js/app.js b/app/static/js/app.js index daf77e1..4458645 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -452,9 +452,15 @@ const CP = (() => { const bonusLabel = signupBonus > 0 ? `
Deployed on all nodes.
`; } else { diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py index 9611888..8b0ff6e 100644 --- a/tests/test_main_routes.py +++ b/tests/test_main_routes.py @@ -494,6 +494,25 @@ def test_api_get_service(self, client): assert resp.status_code == 200 assert resp.json()["slug"] == "hg" + def test_api_get_service_has_collector_flag(self, client): + # honeygain has a collector; a service without one reports False. + hg = {"slug": "honeygain", "name": "Honeygain", "docker": {"image": "test"}} + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=hg), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + assert client.get("/api/services/honeygain").json()["has_collector"] is True + nocol = {"slug": "nodle", "name": "Nodle", "docker": {"image": "test"}} + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=nocol), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + assert client.get("/api/services/nodle").json()["has_collector"] is False + def test_api_get_service_not_found(self, client): with ( _auth_owner(), @@ -567,6 +586,97 @@ def test_api_services_deployed_with_external(self, client): assert len(data) == 1 assert data[0]["container_status"] == "external" + def test_deployed_collector_needs_setup_when_creds_missing(self, client): + # Repocket deployed + earning, but its collector email/password are unset + # → needs_setup True, disconnected False. + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "repocket", "name": "rp", "status": "running"}]), + "apps": "[]", + } + ] + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.main.catalog.get_service", return_value={"name": "Repocket", "category": "bandwidth"}), + ): + resp = client.get("/api/services/deployed") + assert resp.status_code == 200 + row = next(r for r in resp.json() if r["slug"] == "repocket") + assert row["collector_needs_setup"] is True + assert row["collector_disconnected"] is False + + def test_deployed_collector_not_needs_setup_when_creds_present(self, client): + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "repocket", "name": "rp", "status": "running"}]), + "apps": "[]", + } + ] + cfg = {"repocket_email": "me@example.com", "repocket_password": "secret"} + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value=cfg), + patch("app.main.catalog.get_service", return_value={"name": "Repocket", "category": "bandwidth"}), + ): + resp = client.get("/api/services/deployed") + row = next(r for r in resp.json() if r["slug"] == "repocket") + assert row["collector_needs_setup"] is False + + def test_deployed_disconnected_takes_precedence_over_needs_setup(self, client): + # When the collector has actually errored, show the error state, not "needs setup". + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "repocket", "name": "rp", "status": "running"}]), + "apps": "[]", + } + ] + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.main._collector_alerts", [{"platform": "repocket", "error": "auth failed"}]), + patch("app.main.catalog.get_service", return_value={"name": "Repocket", "category": "bandwidth"}), + ): + resp = client.get("/api/services/deployed") + row = next(r for r in resp.json() if r["slug"] == "repocket") + assert row["collector_disconnected"] is True + assert row["collector_needs_setup"] is False + + def test_collector_needs_setup_helper(self): + # Service with no collector → never needs setup. + from app.main import _collector_needs_setup + + assert _collector_needs_setup("not-a-service", {}) is False + # Optional-only collector (storj: ?api_url) → not "needs setup" when blank. + assert _collector_needs_setup("storj", {}) is False + # Required creds absent → needs setup; present → not. + assert _collector_needs_setup("repocket", {}) is True + assert _collector_needs_setup("repocket", {"repocket_email": "a", "repocket_password": "b"}) is False + # --------------------------------------------------------------------------- # API: Earnings