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 ? `
\u2212${formatCurrency(signupBonus, currency)} promo
` : ''; - const disconnectedLabel = svc.collector_disconnected - ? `
disconnected${_isOwner ? ` ` : ''}
` - : ''; + // Earnings-collector state. "disconnected" = collector ran and failed + // (e.g. wrong credentials). "needs setup" = the service is deployed and + // earning, but its (separate) earnings-tracking credentials aren't set yet. + let disconnectedLabel = ''; + if (svc.collector_disconnected) { + disconnectedLabel = `
can't read balance${_isOwner ? ` ` : ''}
`; + } else if (svc.collector_needs_setup) { + disconnectedLabel = `
tracking not set up${_isOwner ? ` ` : ''}
`; + } let balanceHtml; if (nativeLabel) { balanceHtml = `${formatCurrency(displayBalance, currency)}
${nativeLabel}
${bonusLabel}${disconnectedLabel}`; @@ -1744,6 +1750,19 @@ const CP = (() => { html += `

Deploy

`; html += envFields; + // Earnings tracking uses SEPARATE credentials (Settings → Collectors). + // The fields above only configure the container that earns; they don't + // let CashPilot read your balance. Make that explicit at deploy time. + if (svc.has_collector) { + html += ` +
+ The credentials above run the service. To also see its balance on the dashboard, + add earnings-tracking credentials under + Settings → Collectors + after deploying. This is optional — the service earns either way. +
`; + } + if (allDeployed && onlineWorkers.length > 0) { html += `

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