2323from docker .errors import APIError , DockerException , NotFound
2424
2525try :
26- from app .catalog import get_service
26+ from app .catalog import get_service , get_services
2727except ImportError :
2828 # Worker image doesn't include catalog module
2929 get_service = None # type: ignore[assignment]
30+ get_services = None # type: ignore[assignment]
3031
3132logger = logging .getLogger (__name__ )
3233
@@ -316,8 +317,31 @@ def start_service(slug: str) -> None:
316317 logger .info ("Started container %s" , container .name )
317318
318319
320+ def _collect_stats (c ) -> tuple [float , float ]:
321+ """Collect CPU% and memory for a single container. Returns (cpu_pct, mem_mb)."""
322+ try :
323+ stats = c .stats (stream = False )
324+ cpu_delta = (
325+ stats ["cpu_stats" ]["cpu_usage" ]["total_usage" ]
326+ - stats ["precpu_stats" ]["cpu_usage" ]["total_usage" ]
327+ )
328+ system_delta = (
329+ stats ["cpu_stats" ]["system_cpu_usage" ]
330+ - stats ["precpu_stats" ]["system_cpu_usage" ]
331+ )
332+ num_cpus = stats ["cpu_stats" ].get (
333+ "online_cpus" ,
334+ len (stats ["cpu_stats" ]["cpu_usage" ].get ("percpu_usage" , [1 ])),
335+ )
336+ cpu_pct = round ((cpu_delta / system_delta ) * num_cpus * 100 , 2 ) if system_delta > 0 else 0.0
337+ mem_mb = round (stats ["memory_stats" ].get ("usage" , 0 ) / (1024 * 1024 ), 1 )
338+ return cpu_pct , mem_mb
339+ except (KeyError , ZeroDivisionError , APIError ):
340+ return 0.0 , 0.0
341+
342+
319343def get_status () -> list [dict [str , Any ]]:
320- """Return live status of all cashpilot-managed containers.
344+ """Return live status of all known containers (labeled + image-matched) .
321345
322346 This is SLOW (~1-2s per container) because it calls Docker stats API.
323347 Use get_status_cached() for page loads; this is for background refresh.
@@ -329,37 +353,18 @@ def get_status() -> list[dict[str, Any]]:
329353 except RuntimeError :
330354 return []
331355
332- containers = client .containers .list (
356+ # Labeled containers (CashPilot-managed)
357+ labeled = client .containers .list (
333358 all = True ,
334359 filters = {"label" : f"{ LABEL_MANAGED } =true" },
335360 )
361+ seen_ids : set [str ] = set ()
336362
337363 results : list [dict [str , Any ]] = []
338- for c in containers :
364+ for c in labeled :
365+ seen_ids .add (c .id )
339366 slug = c .labels .get (LABEL_SERVICE , "unknown" )
340- # Gather resource stats (non-streaming)
341- cpu_pct = 0.0
342- mem_mb = 0.0
343- try :
344- stats = c .stats (stream = False )
345- # CPU %
346- cpu_delta = (
347- stats ["cpu_stats" ]["cpu_usage" ]["total_usage" ] - stats ["precpu_stats" ]["cpu_usage" ]["total_usage" ]
348- )
349- system_delta = stats ["cpu_stats" ]["system_cpu_usage" ] - stats ["precpu_stats" ]["system_cpu_usage" ]
350- num_cpus = stats ["cpu_stats" ].get (
351- "online_cpus" ,
352- len (stats ["cpu_stats" ]["cpu_usage" ].get ("percpu_usage" , [1 ])),
353- )
354- if system_delta > 0 :
355- cpu_pct = round ((cpu_delta / system_delta ) * num_cpus * 100 , 2 )
356-
357- # Memory
358- mem_usage = stats ["memory_stats" ].get ("usage" , 0 )
359- mem_mb = round (mem_usage / (1024 * 1024 ), 1 )
360- except (KeyError , ZeroDivisionError , APIError ):
361- pass
362-
367+ cpu_pct , mem_mb = _collect_stats (c )
363368 results .append (
364369 {
365370 "slug" : slug ,
@@ -375,6 +380,35 @@ def get_status() -> list[dict[str, Any]]:
375380 }
376381 )
377382
383+ # Image-matched containers (deployed externally)
384+ image_map = _build_image_slug_map ()
385+ if image_map :
386+ all_containers = client .containers .list (all = True )
387+ for c in all_containers :
388+ if c .id in seen_ids :
389+ continue
390+ image_name = c .image .tags [0 ] if c .image .tags else ""
391+ slug = image_map .get (image_name , "" )
392+ if not slug and image_name :
393+ slug = image_map .get (image_name .split (":" )[0 ], "" )
394+ if slug :
395+ seen_ids .add (c .id )
396+ cpu_pct , mem_mb = _collect_stats (c )
397+ results .append (
398+ {
399+ "slug" : slug ,
400+ "name" : c .name ,
401+ "status" : c .status ,
402+ "image" : image_name or str (c .image .short_id ),
403+ "cpu_percent" : cpu_pct ,
404+ "memory_mb" : mem_mb ,
405+ "created" : c .attrs .get ("Created" , "" ),
406+ "container_id" : c .short_id ,
407+ "deployed_by" : "external" ,
408+ "category" : "" ,
409+ }
410+ )
411+
378412 # Update the cache
379413 _status_cache = results
380414 _status_cache_time = time .monotonic ()
@@ -401,25 +435,47 @@ def get_status_cached(max_age: int = 600) -> list[dict[str, Any]]:
401435 return get_status_light ()
402436
403437
438+ def _build_image_slug_map () -> dict [str , str ]:
439+ """Build a map of Docker image names to service slugs from catalog."""
440+ if not get_services :
441+ return {}
442+ mapping : dict [str , str ] = {}
443+ for svc in get_services ():
444+ docker_conf = svc .get ("docker" , {})
445+ image = docker_conf .get ("image" , "" )
446+ if image :
447+ # Map both "image" and "image:latest" to the slug
448+ mapping [image ] = svc ["slug" ]
449+ if ":" not in image :
450+ mapping [f"{ image } :latest" ] = svc ["slug" ]
451+ return mapping
452+
453+
404454def get_status_light () -> list [dict [str , Any ]]:
405455 """Return container list/status WITHOUT resource stats (fast).
406456
407457 Only queries Docker for container list + labels, skips the slow
408458 per-container stats() call. Used when we need fresh container
409459 states (running/stopped) but don't need CPU/memory numbers.
460+
461+ Finds containers by cashpilot label OR by matching Docker image
462+ to known catalog services (for containers deployed externally).
410463 """
411464 try :
412465 client = _get_client ()
413466 except RuntimeError :
414467 return []
415468
416- containers = client .containers .list (
469+ # First: labeled containers (CashPilot-managed)
470+ labeled = client .containers .list (
417471 all = True ,
418472 filters = {"label" : f"{ LABEL_MANAGED } =true" },
419473 )
474+ seen_ids : set [str ] = set ()
420475
421476 results : list [dict [str , Any ]] = []
422- for c in containers :
477+ for c in labeled :
478+ seen_ids .add (c .id )
423479 results .append (
424480 {
425481 "slug" : c .labels .get (LABEL_SERVICE , "unknown" ),
@@ -434,6 +490,37 @@ def get_status_light() -> list[dict[str, Any]]:
434490 "category" : c .labels .get (LABEL_CATEGORY , "" ),
435491 }
436492 )
493+
494+ # Second: scan all containers and match by image name
495+ image_map = _build_image_slug_map ()
496+ if image_map :
497+ all_containers = client .containers .list (all = True )
498+ for c in all_containers :
499+ if c .id in seen_ids :
500+ continue
501+ image_name = c .image .tags [0 ] if c .image .tags else ""
502+ slug = image_map .get (image_name , "" )
503+ if not slug and image_name :
504+ # Try without tag
505+ base = image_name .split (":" )[0 ]
506+ slug = image_map .get (base , "" )
507+ if slug :
508+ seen_ids .add (c .id )
509+ results .append (
510+ {
511+ "slug" : slug ,
512+ "name" : c .name ,
513+ "status" : c .status ,
514+ "image" : image_name or str (c .image .short_id ),
515+ "cpu_percent" : 0.0 ,
516+ "memory_mb" : 0.0 ,
517+ "created" : c .attrs .get ("Created" , "" ),
518+ "container_id" : c .short_id ,
519+ "deployed_by" : "external" ,
520+ "category" : "" ,
521+ }
522+ )
523+
437524 return results
438525
439526
0 commit comments