';
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 += `
|
| |