Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]] = {}
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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


Expand Down
25 changes: 22 additions & 3 deletions app/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,15 @@ const CP = (() => {
const bonusLabel = signupBonus > 0
? `<div style="font-size:0.6rem; color:var(--text-muted);">\u2212${formatCurrency(signupBonus, currency)} promo</div>`
: '';
const disconnectedLabel = svc.collector_disconnected
? `<div style="font-size:0.6rem; color:var(--error); font-weight:500; display:flex; align-items:center; justify-content:flex-end; gap:4px;">disconnected${_isOwner ? ` <button class="btn btn-ghost" onclick="event.stopPropagation(); CP.openCredentialModal('${escapeHtml(svc.slug)}')" style="font-size:0.6rem; padding:1px 5px; line-height:1.2; color:var(--error); border:1px solid #ef4444; border-radius:3px; cursor:pointer;">update</button>` : ''}</div>`
: '';
// 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 = `<div title="CashPilot couldn't read this balance — check the earnings-tracking credentials" style="font-size:0.6rem; color:var(--error); font-weight:500; display:flex; align-items:center; justify-content:flex-end; gap:4px;">can't read balance${_isOwner ? ` <button class="btn btn-ghost" onclick="event.stopPropagation(); CP.openCredentialModal('${escapeHtml(svc.slug)}')" style="font-size:0.6rem; padding:1px 5px; line-height:1.2; color:var(--error); border:1px solid #ef4444; border-radius:3px; cursor:pointer;">fix</button>` : ''}</div>`;
} else if (svc.collector_needs_setup) {
disconnectedLabel = `<div title="This service is running and earning. To show its balance here, add its earnings-tracking credentials." style="font-size:0.6rem; color:var(--text-muted); font-weight:500; display:flex; align-items:center; justify-content:flex-end; gap:4px;">tracking not set up${_isOwner ? ` <button class="btn btn-ghost" onclick="event.stopPropagation(); CP.openCredentialModal('${escapeHtml(svc.slug)}')" style="font-size:0.6rem; padding:1px 5px; line-height:1.2; color:var(--text-muted); border:1px solid var(--border); border-radius:3px; cursor:pointer;">set up</button>` : ''}</div>`;
}
let balanceHtml;
if (nativeLabel) {
balanceHtml = `${formatCurrency(displayBalance, currency)}<div style="font-size:0.65rem;color:var(--text-muted);">${nativeLabel}</div>${bonusLabel}${disconnectedLabel}`;
Expand Down Expand Up @@ -1744,6 +1750,19 @@ const CP = (() => {
html += `<h4 style="margin-bottom: 12px; font-size: 0.95rem;">Deploy</h4>`;
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 += `
<div style="font-size:0.8rem; color:var(--text-muted); background:var(--bg-subtle, rgba(255,255,255,0.03)); border:1px solid var(--border); border-radius:6px; padding:8px 10px; margin:10px 0;">
The credentials above run the service. To also see its <strong>balance</strong> on the dashboard,
add earnings-tracking credentials under
<a href="#" onclick="event.preventDefault(); CP.openCredentialModal('${svc.slug}')" style="color:var(--accent, #3b82f6);">Settings → Collectors</a>
after deploying. This is optional — the service earns either way.
</div>`;
}

if (allDeployed && onlineWorkers.length > 0) {
html += `<p style="color:var(--success); font-size:0.9rem; margin:12px 0;">Deployed on all nodes.</p>`;
} else {
Expand Down
110 changes: 110 additions & 0 deletions tests/test_main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
Loading