From 893c1f0cd36663cd9546052ecf9d8aeda6516748 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:04:48 +0200 Subject: [PATCH 1/6] fix: center sun in favicon, icon, and logo SVGs --- app/static/favicon.svg | 14 +++++++------- docs/icon.svg | 32 ++++++++++++++++---------------- docs/logo.svg | 32 ++++++++++++++++---------------- 3 files changed, 39 insertions(+), 39 deletions(-) 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/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 @@ - + - + - - - - - + + + + + - + - - - - - - - - + + + + + + + + From b52175ada6a38ec39bf156f1e3d846f96ff8599c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:04:55 +0200 Subject: [PATCH 2/6] feat: support Android apps in worker heartbeat and UI - Accept top-level `apps` array in heartbeat (alongside `containers`) - Store apps in new `apps` column on workers table (auto-migrated) - Convert Android app statuses to service entries for the dashboard - Fleet summary counts apps for Android workers - Fleet UI shows "Android" badge and renders apps distinctly - Dashboard disables Docker actions for Android instances --- app/database.py | 14 ++++- app/main.py | 128 +++++++++++++++++++++++---------------- app/static/js/app.js | 9 ++- app/templates/fleet.html | 21 ++++++- 4 files changed, 111 insertions(+), 61 deletions(-) diff --git a/app/database.py b/app/database.py index 57731fc..527c385 100644 --- a/app/database.py +++ b/app/database.py @@ -128,6 +128,7 @@ def decrypt_value(value: str) -> str: 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 +178,11 @@ async def init_db() -> None: db = await _get_db() try: await db.executescript(_SCHEMA) + # Migrate: add 'apps' column to workers if missing (added for Android support) + cursor = await db.execute("PRAGMA table_info(workers)") + cols = {row["name"] for row in await cursor.fetchall()} + if "apps" not in cols: + await db.execute("ALTER TABLE workers ADD COLUMN apps TEXT NOT NULL DEFAULT '[]'") await db.commit() finally: await db.close() @@ -700,6 +706,7 @@ async def upsert_worker( name: str, url: str = "", containers: str = "[]", + apps: str = "[]", system_info: str = "{}", ) -> int: """Register or update a worker by name. Returns the worker ID.""" @@ -707,16 +714,17 @@ async def upsert_worker( try: cursor = await db.execute( """ - INSERT INTO workers (name, url, containers, system_info, status, last_heartbeat) - VALUES (?, ?, ?, ?, 'online', datetime('now')) + INSERT INTO workers (name, url, containers, apps, system_info, status, last_heartbeat) + VALUES (?, ?, ?, ?, ?, 'online', datetime('now')) ON CONFLICT(name) DO UPDATE SET 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), + (name, url, containers, apps, system_info), ) await db.commit() # Return the worker ID (either new or existing) diff --git a/app/main.py b/app/main.py index 72b4a17..cc94c30 100644 --- a/app/main.py +++ b/app/main.py @@ -36,7 +36,7 @@ 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: @@ -44,6 +44,10 @@ async def _get_all_worker_containers() -> list[dict[str, Any]]: continue sys_info = json.loads(w.get("system_info", "{}")) worker_has_docker = sys_info.get("docker_available", False) + is_android = sys_info.get("device_type") == "android" + worker_name = w.get("name", "worker") + + # Docker containers (from Docker-based workers) containers = json.loads(w.get("containers", "[]")) for c in containers: slug = c.get("slug", "") @@ -57,12 +61,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 = json.loads(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 +524,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 +567,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"])) @@ -1426,6 +1432,7 @@ class WorkerHeartbeat(BaseModel): name: str url: str = "" containers: list[dict[str, Any]] = [] + apps: list[dict[str, Any]] = [] system_info: dict[str, Any] = {} @@ -1437,6 +1444,7 @@ async def api_worker_heartbeat(request: Request, body: WorkerHeartbeat) -> dict[ 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 +1456,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"] = json.loads(w.get("containers", "[]")) + w["apps"] = json.loads(w.get("apps", "[]")) + w["system_info"] = json.loads(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 +1481,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 @@ -1544,9 +1559,16 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]: 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") + sys_info = json.loads(w.get("system_info", "{}")) + is_android = sys_info.get("device_type") == "android" + if is_android: + apps = json.loads(w.get("apps", "[]")) + total_containers += len(apps) + total_running += sum(1 for a in apps if a.get("running")) + else: + 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 diff --git a/app/static/js/app.js b/app/static/js/app.js index fa767e7..3081a6a 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -475,7 +475,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,12 +513,14 @@ 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); html += ` ${nodeLabel} - ${escapeHtml(inst.container_name)} + ${subLabel ? `${subLabel}` : ''} ${iStatusLabel} diff --git a/app/templates/fleet.html b/app/templates/fleet.html index fef29e5..3153e34 100644 --- a/app/templates/fleet.html +++ b/app/templates/fleet.html @@ -109,6 +109,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 +118,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 +137,19 @@

Workers

container.innerHTML = html; } + 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)'; + html += `${esc(a.slug)}`; + } + html += '
'; + return html; + } + function renderContainers(worker) { let html = '
'; html += '
'; From f187a6fcd3e59e93e645767457e309586615b7c3 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:13:58 +0200 Subject: [PATCH 3/6] fix: address review findings on Android worker support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resilient JSON parsing: _safe_json() helper so one malformed DB row degrades that worker instead of 500-ing the whole fleet/services UI - Fleet summary now filters offline workers (was counting stale data) - Fleet UI: "Containers" label → "Services", onboarding text mentions Android app, app pills show 24h net traffic on hover tooltip - Dashboard sub-rows show net_tx/rx for Android instances instead of CPU/memory placeholders --- app/main.py | 31 ++++++++++++++++++++----------- app/static/js/app.js | 13 +++++++++++-- app/templates/fleet.html | 18 +++++++++++++++--- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/app/main.py b/app/main.py index cc94c30..fe7777c 100644 --- a/app/main.py +++ b/app/main.py @@ -35,6 +35,14 @@ _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/app data from all online workers' heartbeat data in DB.""" workers = await database.list_workers() @@ -42,13 +50,13 @@ async def _get_all_worker_containers() -> 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) is_android = sys_info.get("device_type") == "android" worker_name = w.get("name", "worker") # Docker containers (from Docker-based workers) - containers = json.loads(w.get("containers", "[]")) + containers = _safe_json(w.get("containers", "[]")) for c in containers: slug = c.get("slug", "") if slug: @@ -71,7 +79,7 @@ async def _get_all_worker_containers() -> list[dict[str, Any]]: # Android apps (from Android workers) if is_android: - apps = json.loads(w.get("apps", "[]")) + apps = _safe_json(w.get("apps", "[]")) for a in apps: slug = a.get("slug", "") if slug: @@ -1462,9 +1470,9 @@ async def api_list_workers(request: Request) -> list[dict[str, Any]]: def _parse_worker_json(w: dict[str, Any]) -> None: """Parse stored JSON columns and compute counts for a worker dict.""" - w["containers"] = json.loads(w.get("containers", "[]")) - w["apps"] = json.loads(w.get("apps", "[]")) - w["system_info"] = json.loads(w.get("system_info", "{}")) + 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"]) @@ -1559,18 +1567,19 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]: online_workers = 0 for w in workers: - sys_info = json.loads(w.get("system_info", "{}")) + if w["status"] != "online": + continue + online_workers += 1 + sys_info = _safe_json(w.get("system_info", "{}"), {}) is_android = sys_info.get("device_type") == "android" if is_android: - apps = json.loads(w.get("apps", "[]")) + apps = _safe_json(w.get("apps", "[]")) total_containers += len(apps) total_running += sum(1 for a in apps if a.get("running")) else: - containers = json.loads(w.get("containers", "[]")) + containers = _safe_json(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 return { "total_workers": len(workers), diff --git a/app/static/js/app.js b/app/static/js/app.js index 3081a6a..a06f16e 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 // ----------------------------------------------------------- @@ -516,6 +523,8 @@ const CP = (() => { 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 += ` @@ -526,8 +535,8 @@ const CP = (() => { - ${inst.cpu || '0'}% - ${inst.memory || '0 MB'} + ${cpuCell} + ${memCell}
diff --git a/app/templates/fleet.html b/app/templates/fleet.html index 3153e34..de3da65 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= @@ -137,6 +139,13 @@

Workers

container.innerHTML = html; } + function fmtBytes(b) { + if (!b || b < 1024) return b + ' 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 += '
'; @@ -144,7 +153,10 @@

Workers

const isRunning = a.running; const color = isRunning ? 'var(--success)' : 'var(--text-muted)'; const bg = isRunning ? 'var(--success-soft)' : 'var(--bg-hover)'; - html += `${esc(a.slug)}`; + 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; From 7ef4919716a03826e211b5a23a3a3bd0679211ec Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:24:11 +0200 Subject: [PATCH 4/6] fix: use stable client_id for worker identity, add worker tests Workers are now identified by client_id (UNIQUE) instead of name. Existing workers are migrated with client_id = name for backward compat. The heartbeat accepts an optional client_id field; when absent, name is used as fallback. This prevents two devices with the same display name from overwriting each other's state. Also adds 8 tests covering heartbeat identity, fleet summary (online-only filtering, Android app counting, malformed JSON resilience), and worker list (Android vs Docker counting). --- app/database.py | 49 ++++++-- app/main.py | 4 + tests/test_workers.py | 284 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+), 11 deletions(-) create mode 100644 tests/test_workers.py diff --git a/app/database.py b/app/database.py index 527c385..bc3d419 100644 --- a/app/database.py +++ b/app/database.py @@ -124,7 +124,8 @@ 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 '[]', @@ -178,10 +179,35 @@ async def init_db() -> None: db = await _get_db() try: await db.executescript(_SCHEMA) - # Migrate: add 'apps' column to workers if missing (added for Android support) + # 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 "apps" not in cols: + 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 "'[]'" + 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; + """) + elif "apps" not in cols: await db.execute("ALTER TABLE workers ADD COLUMN apps TEXT NOT NULL DEFAULT '[]'") await db.commit() finally: @@ -703,20 +729,22 @@ 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, apps, 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, @@ -724,11 +752,10 @@ async def upsert_worker( status = 'online', last_heartbeat = datetime('now') """, - (name, url, containers, apps, 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 fe7777c..d9cf919 100644 --- a/app/main.py +++ b/app/main.py @@ -1439,6 +1439,7 @@ 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] = {} @@ -1448,7 +1449,10 @@ 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), 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 From 81f359e3563c63ff6e2eb2e129a4004a5af0222a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:29:15 +0200 Subject: [PATCH 5/6] fix: recreate idx_workers_status after migration, minor cleanups - Migration now recreates the idx_workers_status index after table rebuild (was lost on upgraded installs) - Log migration with _logger.info for observability - Fleet summary reuses _parse_worker_json() instead of inline parsing - fmtBytes() null-safe for undefined/zero values --- app/database.py | 2 ++ app/main.py | 17 +++++------------ app/templates/fleet.html | 2 +- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/database.py b/app/database.py index bc3d419..1e05c19 100644 --- a/app/database.py +++ b/app/database.py @@ -187,6 +187,7 @@ async def init_db() -> None: # 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, @@ -206,6 +207,7 @@ async def init_db() -> None: 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 '[]'") diff --git a/app/main.py b/app/main.py index d9cf919..3562dcb 100644 --- a/app/main.py +++ b/app/main.py @@ -1566,7 +1566,7 @@ 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 @@ -1574,21 +1574,14 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]: if w["status"] != "online": continue online_workers += 1 - sys_info = _safe_json(w.get("system_info", "{}"), {}) - is_android = sys_info.get("device_type") == "android" - if is_android: - apps = _safe_json(w.get("apps", "[]")) - total_containers += len(apps) - total_running += sum(1 for a in apps if a.get("running")) - else: - containers = _safe_json(w.get("containers", "[]")) - total_containers += len(containers) - total_running += sum(1 for c in containers if c.get("status") == "running") + _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/templates/fleet.html b/app/templates/fleet.html index de3da65..6deea56 100644 --- a/app/templates/fleet.html +++ b/app/templates/fleet.html @@ -140,7 +140,7 @@

Workers

} function fmtBytes(b) { - if (!b || b < 1024) return b + ' 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'; From 866a8a302f030eb6f42b7a2126970ffa72b7187c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:33:30 +0200 Subject: [PATCH 6/6] fix: label Android TX/RX in dashboard sub-rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show ↑/↓ prefixes so it's clear the CPU/Memory columns display network traffic for Android instances. --- app/static/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/static/js/app.js b/app/static/js/app.js index a06f16e..cd4e9c1 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -523,8 +523,8 @@ const CP = (() => { 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'); + 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 += `