diff --git a/app/database.py b/app/database.py index 57731fc..1e05c19 100644 --- a/app/database.py +++ b/app/database.py @@ -124,10 +124,12 @@ def decrypt_value(value: str) -> str: CREATE TABLE IF NOT EXISTS workers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'online', containers TEXT NOT NULL DEFAULT '[]', + apps TEXT NOT NULL DEFAULT '[]', system_info TEXT NOT NULL DEFAULT '{}', last_heartbeat TEXT, registered_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -177,6 +179,38 @@ async def init_db() -> None: db = await _get_db() try: await db.executescript(_SCHEMA) + # Migrate workers table: add client_id (UNIQUE) and apps columns + cursor = await db.execute("PRAGMA table_info(workers)") + cols = {row["name"] for row in await cursor.fetchall()} + if "client_id" not in cols: + # Rebuild table: UNIQUE moves from name → client_id, name becomes display-only. + # Existing rows get client_id = name for backward compat. + has_apps = "apps" in cols + apps_select = "apps" if has_apps else "'[]'" + _logger.info("Migrating workers table: adding client_id column") + await db.executescript(f""" + CREATE TABLE workers_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'online', + containers TEXT NOT NULL DEFAULT '[]', + apps TEXT NOT NULL DEFAULT '[]', + system_info TEXT NOT NULL DEFAULT '{{}}', + last_heartbeat TEXT, + registered_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO workers_new + (id, client_id, name, url, status, containers, apps, system_info, last_heartbeat, registered_at) + SELECT id, name, name, url, status, containers, {apps_select}, system_info, last_heartbeat, registered_at + FROM workers; + DROP TABLE workers; + ALTER TABLE workers_new RENAME TO workers; + CREATE INDEX IF NOT EXISTS idx_workers_status ON workers (status); + """) + elif "apps" not in cols: + await db.execute("ALTER TABLE workers ADD COLUMN apps TEXT NOT NULL DEFAULT '[]'") await db.commit() finally: await db.close() @@ -697,30 +731,33 @@ async def mark_setup_completed(user_id: int) -> None: async def upsert_worker( - name: str, + client_id: str, + name: str = "", url: str = "", containers: str = "[]", + apps: str = "[]", system_info: str = "{}", ) -> int: - """Register or update a worker by name. Returns the worker ID.""" + """Register or update a worker by client_id. Returns the worker ID.""" db = await _get_db() try: cursor = await db.execute( """ - INSERT INTO workers (name, url, containers, system_info, status, last_heartbeat) - VALUES (?, ?, ?, ?, 'online', datetime('now')) - ON CONFLICT(name) DO UPDATE SET + INSERT INTO workers (client_id, name, url, containers, apps, system_info, status, last_heartbeat) + VALUES (?, ?, ?, ?, ?, ?, 'online', datetime('now')) + ON CONFLICT(client_id) DO UPDATE SET + name = excluded.name, url = excluded.url, containers = excluded.containers, + apps = excluded.apps, system_info = excluded.system_info, status = 'online', last_heartbeat = datetime('now') """, - (name, url, containers, system_info), + (client_id, name, url, containers, apps, system_info), ) await db.commit() - # Return the worker ID (either new or existing) - cursor = await db.execute("SELECT id FROM workers WHERE name = ?", (name,)) + cursor = await db.execute("SELECT id FROM workers WHERE client_id = ?", (client_id,)) row = await cursor.fetchone() return row["id"] finally: diff --git a/app/main.py b/app/main.py index 72b4a17..3562dcb 100644 --- a/app/main.py +++ b/app/main.py @@ -35,16 +35,28 @@ _collector_alerts: list[dict[str, str]] = [] +def _safe_json(raw: str, fallback: Any = None) -> Any: + """Parse JSON with a fallback so one malformed DB row doesn't 500 the fleet.""" + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return fallback if fallback is not None else [] + + async def _get_all_worker_containers() -> list[dict[str, Any]]: - """Collect container data from all online workers' heartbeat data in DB.""" + """Collect container/app data from all online workers' heartbeat data in DB.""" workers = await database.list_workers() result: list[dict[str, Any]] = [] for w in workers: if w.get("status") != "online": continue - sys_info = json.loads(w.get("system_info", "{}")) + sys_info = _safe_json(w.get("system_info", "{}"), {}) worker_has_docker = sys_info.get("docker_available", False) - containers = json.loads(w.get("containers", "[]")) + is_android = sys_info.get("device_type") == "android" + worker_name = w.get("name", "worker") + + # Docker containers (from Docker-based workers) + containers = _safe_json(w.get("containers", "[]")) for c in containers: slug = c.get("slug", "") if slug: @@ -57,12 +69,38 @@ async def _get_all_worker_containers() -> list[dict[str, Any]]: "cpu_percent": c.get("cpu_percent", 0), "memory_mb": c.get("memory_mb", 0), "category": "", - "deployed_by": w.get("name", "worker"), - "_node": w.get("name", "worker"), + "deployed_by": worker_name, + "_node": worker_name, "_worker_id": w.get("id"), "_has_docker": worker_has_docker, + "_is_android": False, } ) + + # Android apps (from Android workers) + if is_android: + apps = _safe_json(w.get("apps", "[]")) + for a in apps: + slug = a.get("slug", "") + if slug: + result.append( + { + "slug": slug, + "name": a.get("slug", slug), + "status": "running" if a.get("running") else "stopped", + "image": "", + "cpu_percent": 0, + "memory_mb": 0, + "category": "", + "deployed_by": worker_name, + "_node": worker_name, + "_worker_id": w.get("id"), + "_has_docker": False, + "_is_android": True, + "_net_tx_24h": a.get("net_tx_24h", 0), + "_net_rx_24h": a.get("net_rx_24h", 0), + } + ) return result @@ -494,34 +532,7 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: details for the expandable sub-row UI. """ _require_auth_api(request) - statuses: list[dict[str, Any]] = [] - - # Collect containers from all workers - workers = await database.list_workers() - for w in workers: - if w.get("status") != "online": - continue - sys_info = json.loads(w.get("system_info", "{}")) - worker_has_docker = sys_info.get("docker_available", False) - containers = json.loads(w.get("containers", "[]")) - for c in containers: - slug = c.get("slug", "") - if slug: - statuses.append( - { - "slug": slug, - "name": c.get("name", slug), - "status": c.get("status", "unknown"), - "image": c.get("image", ""), - "cpu_percent": c.get("cpu_percent", 0), - "memory_mb": c.get("memory_mb", 0), - "category": "", - "deployed_by": w.get("name", "worker"), - "_node": w.get("name", "worker"), - "_worker_id": w.get("id"), - "_has_docker": worker_has_docker, - } - ) + statuses: list[dict[str, Any]] = await _get_all_worker_containers() # Get latest earnings per platform for balance display earnings = await database.get_earnings_summary() @@ -564,17 +575,20 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: # Build per-instance detail list (local first) instance_details = [] for inst in agg["instances"]: - instance_details.append( - { - "node": inst.get("_node", "unknown"), - "worker_id": inst.get("_worker_id"), - "status": inst.get("status", "unknown"), - "cpu": f"{float(inst.get('cpu_percent', 0)):.2f}", - "memory": f"{float(inst.get('memory_mb', 0)):.1f} MB", - "container_name": inst.get("name", ""), - "has_docker": inst.get("_has_docker", False), - } - ) + detail = { + "node": inst.get("_node", "unknown"), + "worker_id": inst.get("_worker_id"), + "status": inst.get("status", "unknown"), + "cpu": f"{float(inst.get('cpu_percent', 0)):.2f}", + "memory": f"{float(inst.get('memory_mb', 0)):.1f} MB", + "container_name": inst.get("name", ""), + "has_docker": inst.get("_has_docker", False), + "is_android": inst.get("_is_android", False), + } + if inst.get("_is_android"): + detail["net_tx_24h"] = inst.get("_net_tx_24h", 0) + detail["net_rx_24h"] = inst.get("_net_rx_24h", 0) + instance_details.append(detail) # Sort: local first, then alphabetically by node name instance_details.sort(key=lambda x: (0 if x["node"] == "local" else 1, x["node"])) @@ -1425,7 +1439,9 @@ def _verify_fleet_api_key(request: Request) -> None: class WorkerHeartbeat(BaseModel): name: str url: str = "" + client_id: str = "" containers: list[dict[str, Any]] = [] + apps: list[dict[str, Any]] = [] system_info: dict[str, Any] = {} @@ -1433,10 +1449,14 @@ class WorkerHeartbeat(BaseModel): async def api_worker_heartbeat(request: Request, body: WorkerHeartbeat) -> dict[str, Any]: """Receive a heartbeat from a worker. Registers or updates the worker.""" _verify_fleet_api_key(request) + # Use client_id for identity; fall back to name for backward compat + cid = body.client_id or body.name worker_id = await database.upsert_worker( + client_id=cid, name=body.name, url=body.url, containers=json.dumps(body.containers), + apps=json.dumps(body.apps), system_info=json.dumps(body.system_info), ) return {"status": "ok", "worker_id": worker_id} @@ -1448,12 +1468,22 @@ async def api_list_workers(request: Request) -> list[dict[str, Any]]: _require_auth_api(request) workers = await database.list_workers() for w in workers: - # Parse stored JSON for the API response - w["containers"] = json.loads(w.get("containers", "[]")) - w["system_info"] = json.loads(w.get("system_info", "{}")) + _parse_worker_json(w) + return workers + + +def _parse_worker_json(w: dict[str, Any]) -> None: + """Parse stored JSON columns and compute counts for a worker dict.""" + w["containers"] = _safe_json(w.get("containers", "[]")) + w["apps"] = _safe_json(w.get("apps", "[]")) + w["system_info"] = _safe_json(w.get("system_info", "{}"), {}) + is_android = w["system_info"].get("device_type") == "android" + if is_android: + w["container_count"] = len(w["apps"]) + w["running_count"] = sum(1 for a in w["apps"] if a.get("running")) + else: w["container_count"] = len(w["containers"]) w["running_count"] = sum(1 for c in w["containers"] if c.get("status") == "running") - return workers @app.get("/api/workers/{worker_id}") @@ -1463,10 +1493,7 @@ async def api_get_worker(request: Request, worker_id: int) -> dict[str, Any]: worker = await database.get_worker(worker_id) if not worker: raise HTTPException(status_code=404, detail="Worker not found") - worker["containers"] = json.loads(worker.get("containers", "[]")) - worker["system_info"] = json.loads(worker.get("system_info", "{}")) - worker["container_count"] = len(worker["containers"]) - worker["running_count"] = sum(1 for c in worker["containers"] if c.get("status") == "running") + _parse_worker_json(worker) return worker @@ -1539,21 +1566,22 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]: _require_auth_api(request) workers = await database.list_workers() - total_containers = 0 + total_services = 0 total_running = 0 online_workers = 0 for w in workers: - containers = json.loads(w.get("containers", "[]")) - total_containers += len(containers) - total_running += sum(1 for c in containers if c.get("status") == "running") - if w["status"] == "online": - online_workers += 1 + if w["status"] != "online": + continue + online_workers += 1 + _parse_worker_json(w) + total_services += w["container_count"] + total_running += w["running_count"] return { "total_workers": len(workers), "online_workers": online_workers, - "total_containers": total_containers, + "total_containers": total_services, "running_containers": total_running, } diff --git a/app/static/favicon.svg b/app/static/favicon.svg index 42a2d2b..eba8bf7 100644 --- a/app/static/favicon.svg +++ b/app/static/favicon.svg @@ -7,16 +7,16 @@ - + - + - - - - - + + + + + diff --git a/app/static/js/app.js b/app/static/js/app.js index fa767e7..cd4e9c1 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -73,6 +73,13 @@ const CP = (() => { function capFirst(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } + function fmtNetBytes(b) { + if (!b || b < 1024) return (b || 0) + ' B'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; + if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB'; + return (b / 1073741824).toFixed(2) + ' GB'; + } + // ----------------------------------------------------------- // Modal // ----------------------------------------------------------- @@ -475,7 +482,8 @@ const CP = (() => { // Single instance — build container buttons targeting the right node const inst = details[0] || {}; const wParam = inst.worker_id != null ? `', ${inst.worker_id}` : `'`; - const disabledAttr = !inst.has_docker ? ' disabled title="No Docker access"' : ''; + const noDocker = !inst.has_docker || inst.is_android; + const disabledAttr = noDocker ? ' disabled title="No Docker access"' : ''; actionBtns = `
${claimBtn} ${_canWrite ? ` @@ -512,19 +520,23 @@ const CP = (() => { const iStatusLabel = iStatus.charAt(0).toUpperCase() + iStatus.slice(1); const nodeLabel = inst.node === 'local' ? 'Local' : escapeHtml(inst.node); const wParam = inst.worker_id != null ? `', ${inst.worker_id}` : `'`; - const disabledAttr = !inst.has_docker ? ' disabled title="No Docker access"' : ''; + const iNoDocker = !inst.has_docker || inst.is_android; + const disabledAttr = iNoDocker ? ' disabled title="No Docker access"' : ''; + const subLabel = inst.is_android ? '' : escapeHtml(inst.container_name); + const cpuCell = inst.is_android ? `↑ ${fmtNetBytes(inst.net_tx_24h)}` : `${inst.cpu || '0'}%`; + const memCell = inst.is_android ? `↓ ${fmtNetBytes(inst.net_rx_24h)}` : (inst.memory || '0 MB'); html += ` ${nodeLabel} - ${escapeHtml(inst.container_name)} + ${subLabel ? `${subLabel}` : ''} ${iStatusLabel} - ${inst.cpu || '0'}% - ${inst.memory || '0 MB'} + ${cpuCell} + ${memCell}
diff --git a/app/templates/fleet.html b/app/templates/fleet.html index fef29e5..6deea56 100644 --- a/app/templates/fleet.html +++ b/app/templates/fleet.html @@ -16,7 +16,7 @@
-
-
Containers
+
Services
-
@@ -32,7 +32,9 @@

- Deploy drumsergio/cashpilot-worker on each server with these environment variables: + Deploy drumsergio/cashpilot-worker on each server, or install the + Android app on your phone. + For Docker workers, set these environment variables:

CASHPILOT_UI_URL= @@ -109,6 +111,8 @@

Workers

const statusClass = isOnline ? 'running' : 'stopped'; const borderColor = isOnline ? 'var(--success)' : 'var(--text-muted)'; const sysInfo = w.system_info || {}; + const isAndroid = (sysInfo.device_type === 'android'); + const items = isAndroid ? (w.apps || []) : (w.containers || []); html += `
@@ -116,16 +120,18 @@

Workers

${esc(w.name)} + ${isAndroid ? 'Android' : ''}
${_isOwner ? `` : ''}
${esc(w.url || '-')} - ${esc(sysInfo.os || '-')} + ${esc(sysInfo.os || '-')}${sysInfo.os_version ? ' ' + esc(sysInfo.os_version) : ''} ${esc(sysInfo.arch || '-')} Last seen: ${w.last_heartbeat || 'never'}
- ${w.containers && w.containers.length > 0 ? renderContainers(w) : ''} + ${isAndroid && items.length > 0 ? renderApps(items) : ''} + ${!isAndroid && items.length > 0 ? renderContainers(w) : ''}
`; } @@ -133,6 +139,29 @@

Workers

container.innerHTML = html; } + function fmtBytes(b) { + if (!b || b < 1024) return (b || 0) + ' B'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; + if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB'; + return (b / 1073741824).toFixed(2) + ' GB'; + } + + function renderApps(apps) { + let html = '
'; + html += '
'; + for (const a of apps) { + const isRunning = a.running; + const color = isRunning ? 'var(--success)' : 'var(--text-muted)'; + const bg = isRunning ? 'var(--success-soft)' : 'var(--bg-hover)'; + const tx = a.net_tx_24h || 0; + const rx = a.net_rx_24h || 0; + const netTip = (tx || rx) ? ` title="24h: ↑${fmtBytes(tx)} ↓${fmtBytes(rx)}"` : ''; + html += `${esc(a.slug)}`; + } + html += '
'; + return html; + } + function renderContainers(worker) { let html = '
'; html += '
'; diff --git a/docs/icon.svg b/docs/icon.svg index 18b205c..044782c 100644 --- a/docs/icon.svg +++ b/docs/icon.svg @@ -12,31 +12,31 @@ - + - + - - - - - + + + + + - + - - - - - - - - + + + + + + + + diff --git a/docs/logo.svg b/docs/logo.svg index 18b205c..044782c 100644 --- a/docs/logo.svg +++ b/docs/logo.svg @@ -12,31 +12,31 @@ - + - + - - - - - + + + + + - + - - - - - - - - + + + + + + + + diff --git a/tests/test_workers.py b/tests/test_workers.py new file mode 100644 index 0000000..9227154 --- /dev/null +++ b/tests/test_workers.py @@ -0,0 +1,284 @@ +"""Tests for worker heartbeat, fleet summary, and Android app support. + +Exercises /api/workers/heartbeat, /api/workers, /api/fleet/summary, and +the DB migration (client_id identity, apps column). + +Requires fastapi + httpx (installed in CI via requirements.txt). +Skipped automatically in minimal local environments. +""" + +import asyncio +import os + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest # noqa: E402 + +try: + from app.main import ( # noqa: E402 + api_fleet_summary, + api_list_workers, + api_worker_heartbeat, + ) +except ImportError: + pytest.skip( + "Requires full app dependencies (fastapi, httpx, etc.) — runs in CI", + allow_module_level=True, + ) + +from types import SimpleNamespace # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +FLEET_KEY = "test-fleet-key" + + +def _request(api_key: str = FLEET_KEY): + """Build a fake Request with Authorization header.""" + req = MagicMock() + req.headers = {"Authorization": f"Bearer {api_key}"} + return req + + +def _worker_row( + *, + id: int = 1, + client_id: str = "srv-1", + name: str = "server-1", + url: str = "", + status: str = "online", + containers: str = "[]", + apps: str = "[]", + system_info: str = "{}", + last_heartbeat: str = "2026-04-04T12:00:00", + registered_at: str = "2026-04-01T00:00:00", +): + return { + "id": id, + "client_id": client_id, + "name": name, + "url": url, + "status": status, + "containers": containers, + "apps": apps, + "system_info": system_info, + "last_heartbeat": last_heartbeat, + "registered_at": registered_at, + } + + +def _run(coro): + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- +# Heartbeat — client_id identity +# --------------------------------------------------------------------------- + + +class TestHeartbeatClientId: + def test_client_id_passed_to_upsert(self): + """When client_id is provided, it's used as the worker identity.""" + mock_upsert = AsyncMock(return_value=42) + with patch("app.main.database.upsert_worker", mock_upsert): + result = _run( + api_worker_heartbeat( + _request(), + SimpleNamespace( + name="My Phone", + url="", + client_id="android-abc123", + containers=[], + apps=[{"slug": "honeygain", "running": True}], + system_info={"device_type": "android"}, + ), + ) + ) + assert result == {"status": "ok", "worker_id": 42} + call_kwargs = mock_upsert.call_args.kwargs + assert call_kwargs["client_id"] == "android-abc123" + assert call_kwargs["name"] == "My Phone" + + def test_fallback_to_name_when_no_client_id(self): + """Old workers that don't send client_id get name used as identity.""" + mock_upsert = AsyncMock(return_value=7) + with patch("app.main.database.upsert_worker", mock_upsert): + result = _run( + api_worker_heartbeat( + _request(), + SimpleNamespace( + name="watchtower", + url="", + client_id="", + containers=[{"slug": "honeygain", "status": "running"}], + apps=[], + system_info={"docker_available": True}, + ), + ) + ) + assert result["status"] == "ok" + assert mock_upsert.call_args.kwargs["client_id"] == "watchtower" + + def test_two_devices_same_name_different_client_id(self): + """Two devices with the same display name but different client_ids are separate.""" + calls = [] + + async def fake_upsert(**kwargs): + calls.append(kwargs) + return len(calls) + + with patch("app.main.database.upsert_worker", side_effect=fake_upsert): + _run( + api_worker_heartbeat( + _request(), + SimpleNamespace( + name="Samsung S24", + url="", + client_id="device-aaa", + containers=[], + apps=[], + system_info={}, + ), + ) + ) + _run( + api_worker_heartbeat( + _request(), + SimpleNamespace( + name="Samsung S24", + url="", + client_id="device-bbb", + containers=[], + apps=[], + system_info={}, + ), + ) + ) + assert len(calls) == 2 + assert calls[0]["client_id"] == "device-aaa" + assert calls[1]["client_id"] == "device-bbb" + # Both have same display name + assert calls[0]["name"] == calls[1]["name"] == "Samsung S24" + + +# --------------------------------------------------------------------------- +# Fleet summary — online-only and Android counting +# --------------------------------------------------------------------------- + + +class TestFleetSummary: + def test_only_online_workers_counted(self): + """Offline workers should not inflate container/running counts.""" + workers = [ + _worker_row( + id=1, + status="online", + containers='[{"slug":"hg","status":"running"}]', + ), + _worker_row( + id=2, + client_id="srv-2", + name="offline-server", + status="offline", + containers='[{"slug":"earnapp","status":"running"},{"slug":"repocket","status":"running"}]', + ), + ] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "t", "r": "owner"}), + ): + result = _run(api_fleet_summary(_request())) + assert result["total_workers"] == 2 + assert result["online_workers"] == 1 + assert result["total_containers"] == 1 + assert result["running_containers"] == 1 + + def test_android_apps_counted(self): + """Android apps should be counted as services in the fleet summary.""" + workers = [ + _worker_row( + id=1, + status="online", + system_info='{"device_type":"android"}', + containers="[]", + apps='[{"slug":"honeygain","running":true},{"slug":"earnapp","running":false}]', + ), + ] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "t", "r": "owner"}), + ): + result = _run(api_fleet_summary(_request())) + assert result["total_containers"] == 2 + assert result["running_containers"] == 1 + + def test_malformed_json_does_not_crash(self): + """A worker with corrupted JSON should be skipped, not 500.""" + workers = [ + _worker_row( + id=1, + status="online", + system_info="NOT VALID JSON", + containers="NOT VALID JSON", + ), + ] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "t", "r": "owner"}), + ): + result = _run(api_fleet_summary(_request())) + # Should not crash — malformed rows degrade gracefully + assert result["online_workers"] == 1 + assert result["total_containers"] == 0 + + +# --------------------------------------------------------------------------- +# Worker list — Android fields +# --------------------------------------------------------------------------- + + +class TestWorkerList: + def test_android_worker_returns_apps(self): + """Android workers should have apps parsed and counted.""" + workers = [ + _worker_row( + id=1, + status="online", + system_info='{"device_type":"android","os":"Android"}', + containers="[]", + apps='[{"slug":"honeygain","running":true},{"slug":"repocket","running":true}]', + ), + ] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "t", "r": "owner"}), + ): + result = _run(api_list_workers(_request())) + w = result[0] + assert isinstance(w["apps"], list) + assert len(w["apps"]) == 2 + assert w["container_count"] == 2 + assert w["running_count"] == 2 + + def test_docker_worker_counts_containers(self): + """Docker workers should count containers, not apps.""" + workers = [ + _worker_row( + id=1, + status="online", + system_info='{"docker_available":true}', + containers='[{"slug":"honeygain","status":"running"},{"slug":"earnapp","status":"stopped"}]', + apps="[]", + ), + ] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "t", "r": "owner"}), + ): + result = _run(api_list_workers(_request())) + w = result[0] + assert w["container_count"] == 2 + assert w["running_count"] == 1