3434# In-memory store for the latest collector alerts (errors from last run)
3535_collector_alerts : list [dict [str , str ]] = []
3636
37+ # UI-only mode: no Docker socket, all container ops go through workers
38+ _ui_only : bool = os .getenv ("CASHPILOT_MODE" , "standalone" ).lower () == "ui"
39+
40+
41+ async def _get_all_worker_containers () -> list [dict [str , Any ]]:
42+ """Collect container data from all online workers' heartbeat data in DB."""
43+ workers = await database .list_workers ()
44+ result : list [dict [str , Any ]] = []
45+ for w in workers :
46+ if w .get ("status" ) != "online" :
47+ continue
48+ sys_info = json .loads (w .get ("system_info" , "{}" ))
49+ worker_has_docker = sys_info .get ("docker_available" , False )
50+ containers = json .loads (w .get ("containers" , "[]" ))
51+ for c in containers :
52+ slug = c .get ("slug" , "" )
53+ if slug :
54+ result .append (
55+ {
56+ "slug" : slug ,
57+ "name" : c .get ("name" , slug ),
58+ "status" : c .get ("status" , "unknown" ),
59+ "image" : c .get ("image" , "" ),
60+ "cpu_percent" : c .get ("cpu_percent" , 0 ),
61+ "memory_mb" : c .get ("memory_mb" , 0 ),
62+ "category" : "" ,
63+ "deployed_by" : w .get ("name" , "worker" ),
64+ "_node" : w .get ("name" , "worker" ),
65+ "_worker_id" : w .get ("id" ),
66+ "_has_docker" : worker_has_docker ,
67+ }
68+ )
69+ return result
70+
71+
72+ def _require_worker_id_in_ui_mode (worker_id : int | None ) -> None :
73+ """Raise 400 if running in UI mode and no worker_id was provided."""
74+ if _ui_only and worker_id is None :
75+ raise HTTPException (
76+ status_code = 400 ,
77+ detail = "worker_id is required in UI mode (no local Docker socket)" ,
78+ )
79+
3780
3881# ---------------------------------------------------------------------------
3982# Periodic collection job
@@ -48,7 +91,10 @@ async def _run_health_check() -> None:
4891 deployed on multiple nodes where one may be stopped).
4992 """
5093 try :
51- statuses = orchestrator .get_status ()
94+ if _ui_only :
95+ statuses = await _get_all_worker_containers ()
96+ else :
97+ statuses = orchestrator .get_status ()
5298 # Aggregate: slug -> best status (running wins)
5399 slug_best : dict [str , str ] = {}
54100 for s in statuses :
@@ -141,11 +187,13 @@ async def lifespan(app: FastAPI):
141187 scheduler .add_job (exchange_rates .refresh , "interval" , minutes = 15 , id = "exchange_rates" )
142188 scheduler .start ()
143189 await exchange_rates .refresh ()
144- docker_mode = "direct" if orchestrator .docker_available () else "monitor-only"
145- logger .info ("CashPilot started (Docker: %s)" , docker_mode )
146-
147- # Populate status cache immediately so first page load is instant
148- asyncio .get_event_loop ().run_in_executor (None , orchestrator .get_status )
190+ if _ui_only :
191+ logger .info ("CashPilot started (mode: ui, no local Docker)" )
192+ else :
193+ docker_mode = "direct" if orchestrator .docker_available () else "monitor-only"
194+ logger .info ("CashPilot started (Docker: %s)" , docker_mode )
195+ # Populate status cache immediately so first page load is instant
196+ asyncio .get_event_loop ().run_in_executor (None , orchestrator .get_status )
149197
150198 yield
151199
@@ -428,6 +476,8 @@ async def page_settings(request: Request):
428476async def api_mode (request : Request ) -> dict [str , Any ]:
429477 """Return CashPilot operating mode and Docker availability."""
430478 _require_auth_api (request )
479+ if _ui_only :
480+ return {"docker" : False , "mode" : "ui" }
431481 has_docker = orchestrator .docker_available ()
432482 return {
433483 "docker" : has_docker ,
@@ -450,16 +500,18 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
450500 details for the expandable sub-row UI.
451501 """
452502 _require_auth_api (request )
453- try :
454- statuses = list (orchestrator .get_status_cached ()) # copy — don't mutate cache
455- except RuntimeError :
456- statuses = []
457-
458- # Tag local containers with node info
459- for s in statuses :
460- s ["_node" ] = "local"
461- s ["_worker_id" ] = None
462- s ["_has_docker" ] = orchestrator .docker_available ()
503+ if _ui_only :
504+ statuses : list [dict [str , Any ]] = []
505+ else :
506+ try :
507+ statuses = list (orchestrator .get_status_cached ()) # copy — don't mutate cache
508+ except RuntimeError :
509+ statuses = []
510+ # Tag local containers with node info
511+ for s in statuses :
512+ s ["_node" ] = "local"
513+ s ["_worker_id" ] = None
514+ s ["_has_docker" ] = orchestrator .docker_available ()
463515
464516 # Also include worker containers
465517 workers = await database .list_workers ()
@@ -641,6 +693,8 @@ async def api_get_service(request: Request, slug: str) -> dict[str, Any]:
641693@app .get ("/api/status" )
642694async def api_status (request : Request ) -> list [dict [str , Any ]]:
643695 _require_auth_api (request )
696+ if _ui_only :
697+ return []
644698 try :
645699 return orchestrator .get_status_cached ()
646700 except RuntimeError as exc :
@@ -653,11 +707,31 @@ class DeployRequest(BaseModel):
653707
654708
655709@app .post ("/api/deploy/{slug}" )
656- async def api_deploy (request : Request , slug : str , body : DeployRequest ) -> dict [str , str ]:
710+ async def api_deploy (request : Request , slug : str , body : DeployRequest , worker_id : int | None = None ) -> dict [str , str ]:
657711 _require_writer (request )
658712 svc = catalog .get_service (slug )
659713 if not svc :
660714 raise HTTPException (status_code = 404 , detail = f"Service '{ slug } ' not found" )
715+
716+ if _ui_only or worker_id is not None :
717+ _require_worker_id_in_ui_mode (worker_id )
718+ # Build deploy spec from catalog and forward to worker
719+ docker_conf = svc .get ("docker" , {})
720+ image = docker_conf .get ("image" )
721+ if not image :
722+ raise HTTPException (status_code = 400 , detail = f"Service '{ slug } ' has no Docker image" )
723+ spec = {
724+ "image" : image ,
725+ "env" : body .env or {},
726+ "hostname" : body .hostname ,
727+ }
728+ result = await _proxy_worker_deploy (worker_id , slug , spec ) # type: ignore[arg-type]
729+ container_id = result .get ("container_id" , "remote" )
730+ await database .save_deployment (slug = slug , container_id = container_id )
731+ await database .record_health_event (slug , "start" , f"deployed to worker { worker_id } " )
732+ asyncio .create_task (_run_collection ())
733+ return {"status" : "deployed" , "container_id" : container_id }
734+
661735 try :
662736 container_id = orchestrator .deploy_service (
663737 slug = slug ,
@@ -666,7 +740,6 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest) -> dict[s
666740 )
667741 await database .save_deployment (slug = slug , container_id = container_id )
668742 await database .record_health_event (slug , "start" , "deployed" )
669- # Trigger collection so earnings show up quickly
670743 asyncio .create_task (_run_collection ())
671744 return {"status" : "deployed" , "container_id" : container_id }
672745 except RuntimeError as exc :
@@ -676,8 +749,11 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest) -> dict[s
676749
677750
678751@app .post ("/api/stop/{slug}" )
679- async def api_stop (request : Request , slug : str ) -> dict [str , str ]:
752+ async def api_stop (request : Request , slug : str , worker_id : int | None = None ) -> dict [str , str ]:
680753 _require_writer (request )
754+ _require_worker_id_in_ui_mode (worker_id )
755+ if worker_id is not None :
756+ return await _proxy_worker_command (worker_id , "stop" , slug )
681757 try :
682758 orchestrator .stop_service (slug )
683759 return {"status" : "stopped" }
@@ -688,8 +764,11 @@ async def api_stop(request: Request, slug: str) -> dict[str, str]:
688764
689765
690766@app .post ("/api/restart/{slug}" )
691- async def api_restart (request : Request , slug : str ) -> dict [str , str ]:
767+ async def api_restart (request : Request , slug : str , worker_id : int | None = None ) -> dict [str , str ]:
692768 _require_writer (request )
769+ _require_worker_id_in_ui_mode (worker_id )
770+ if worker_id is not None :
771+ return await _proxy_worker_command (worker_id , "restart" , slug )
693772 try :
694773 orchestrator .restart_service (slug )
695774 return {"status" : "restarted" }
@@ -700,8 +779,13 @@ async def api_restart(request: Request, slug: str) -> dict[str, str]:
700779
701780
702781@app .delete ("/api/remove/{slug}" )
703- async def api_remove (request : Request , slug : str ) -> dict [str , str ]:
782+ async def api_remove (request : Request , slug : str , worker_id : int | None = None ) -> dict [str , str ]:
704783 _require_writer (request )
784+ _require_worker_id_in_ui_mode (worker_id )
785+ if worker_id is not None :
786+ result = await _proxy_worker_command (worker_id , "remove" , slug )
787+ await database .remove_deployment (slug )
788+ return result
705789 try :
706790 orchestrator .remove_service (slug )
707791 await database .remove_deployment (slug )
@@ -718,7 +802,7 @@ async def api_remove(request: Request, slug: str) -> dict[str, str]:
718802
719803
720804async def _proxy_worker_command (worker_id : int , command : str , slug : str ) -> dict [str , str ]:
721- """Forward a container command (restart/stop/start) to a worker."""
805+ """Forward a container command (restart/stop/start/remove ) to a worker."""
722806 worker = await database .get_worker (worker_id )
723807 if not worker :
724808 raise HTTPException (status_code = 404 , detail = "Worker not found" )
@@ -734,7 +818,33 @@ async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict
734818
735819 try :
736820 async with httpx .AsyncClient (timeout = 30 ) as client :
737- resp = await client .post (f"{ url } /api/containers/{ slug } /{ command } " , headers = headers )
821+ if command == "remove" :
822+ resp = await client .delete (f"{ url } /api/containers/{ slug } " , headers = headers )
823+ else :
824+ resp = await client .post (f"{ url } /api/containers/{ slug } /{ command } " , headers = headers )
825+ return resp .json ()
826+ except httpx .HTTPError as exc :
827+ raise HTTPException (status_code = 503 , detail = f"Worker communication failed: { exc } " )
828+
829+
830+ async def _proxy_worker_deploy (worker_id : int , slug : str , spec : dict [str , Any ]) -> dict [str , Any ]:
831+ """Forward a deploy command with full spec to a worker."""
832+ worker = await database .get_worker (worker_id )
833+ if not worker :
834+ raise HTTPException (status_code = 404 , detail = "Worker not found" )
835+ if worker ["status" ] != "online" :
836+ raise HTTPException (status_code = 503 , detail = "Worker is offline" )
837+ if not worker ["url" ]:
838+ raise HTTPException (status_code = 503 , detail = "Worker URL not known" )
839+
840+ url = worker ["url" ].rstrip ("/" )
841+ headers = {}
842+ if FLEET_API_KEY :
843+ headers ["Authorization" ] = f"Bearer { FLEET_API_KEY } "
844+
845+ try :
846+ async with httpx .AsyncClient (timeout = 60 ) as client :
847+ resp = await client .post (f"{ url } /api/containers/{ slug } /deploy" , json = spec , headers = headers )
738848 return resp .json ()
739849 except httpx .HTTPError as exc :
740850 raise HTTPException (status_code = 503 , detail = f"Worker communication failed: { exc } " )
@@ -777,6 +887,7 @@ async def api_service_restart(request: Request, slug: str, worker_id: int | None
777887 _require_writer (request )
778888 if worker_id is not None :
779889 return await _proxy_worker_command (worker_id , "restart" , slug )
890+ _require_worker_id_in_ui_mode (worker_id )
780891 try :
781892 orchestrator .restart_service (slug )
782893 await database .record_health_event (slug , "restart" )
@@ -792,6 +903,7 @@ async def api_service_stop(request: Request, slug: str, worker_id: int | None =
792903 _require_writer (request )
793904 if worker_id is not None :
794905 return await _proxy_worker_command (worker_id , "stop" , slug )
906+ _require_worker_id_in_ui_mode (worker_id )
795907 try :
796908 orchestrator .stop_service (slug )
797909 await database .record_health_event (slug , "stop" )
@@ -807,6 +919,7 @@ async def api_service_start(request: Request, slug: str, worker_id: int | None =
807919 _require_writer (request )
808920 if worker_id is not None :
809921 return await _proxy_worker_command (worker_id , "start" , slug )
922+ _require_worker_id_in_ui_mode (worker_id )
810923 try :
811924 orchestrator .start_service (slug )
812925 await database .record_health_event (slug , "start" )
@@ -824,6 +937,7 @@ async def api_service_logs(
824937 _require_auth_api (request )
825938 if worker_id is not None :
826939 return await _proxy_worker_logs (worker_id , slug , lines )
940+ _require_worker_id_in_ui_mode (worker_id )
827941 try :
828942 logs = orchestrator .get_service_logs (slug , lines = min (lines , 1000 ))
829943 return {"logs" : logs }
@@ -834,8 +948,13 @@ async def api_service_logs(
834948
835949
836950@app .delete ("/api/services/{slug}" )
837- async def api_service_remove (request : Request , slug : str ) -> dict [str , str ]:
951+ async def api_service_remove (request : Request , slug : str , worker_id : int | None = None ) -> dict [str , str ]:
838952 _require_writer (request )
953+ _require_worker_id_in_ui_mode (worker_id )
954+ if worker_id is not None :
955+ result = await _proxy_worker_command (worker_id , "remove" , slug )
956+ await database .remove_deployment (slug )
957+ return result
839958 try :
840959 orchestrator .remove_service (slug )
841960 await database .remove_deployment (slug )
@@ -913,11 +1032,15 @@ async def api_earnings_summary(request: Request) -> dict[str, Any]:
9131032 if usd_val is not None :
9141033 summary ["total" ] = round (summary ["total" ] + usd_val , 2 )
9151034
916- # Count active (running) services — use cached data for instant response
1035+ # Count active (running) services
9171036 active = 0
9181037 try :
919- statuses = orchestrator .get_status_cached ()
920- active = sum (1 for s in statuses if s .get ("status" ) == "running" )
1038+ if _ui_only :
1039+ worker_containers = await _get_all_worker_containers ()
1040+ active = sum (1 for s in worker_containers if s .get ("status" ) == "running" )
1041+ else :
1042+ statuses = orchestrator .get_status_cached ()
1043+ active = sum (1 for s in statuses if s .get ("status" ) == "running" )
9211044 except Exception :
9221045 pass
9231046 summary ["active_services" ] = active
@@ -1287,17 +1410,19 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]:
12871410 if w ["status" ] == "online" :
12881411 online_workers += 1
12891412
1290- # Add local containers (cached for instant response)
1291- try :
1292- local_status = orchestrator .get_status_cached ()
1293- total_containers += len (local_status )
1294- total_running += sum (1 for c in local_status if c .get ("status" ) == "running" )
1295- except Exception :
1296- pass
1413+ # Add local containers (cached for instant response) — skip in UI mode
1414+ if not _ui_only :
1415+ try :
1416+ local_status = orchestrator .get_status_cached ()
1417+ total_containers += len (local_status )
1418+ total_running += sum (1 for c in local_status if c .get ("status" ) == "running" )
1419+ except Exception :
1420+ pass
12971421
1422+ local_offset = 0 if _ui_only else 1
12981423 return {
1299- "total_workers" : len (workers ) + 1 , # +1 for local
1300- "online_workers" : online_workers + 1 ,
1424+ "total_workers" : len (workers ) + local_offset ,
1425+ "online_workers" : online_workers + local_offset ,
13011426 "total_containers" : total_containers ,
13021427 "running_containers" : total_running ,
13031428 }
0 commit comments