diff --git a/agent/docker_ops.py b/agent/docker_ops.py index 579e953..9951025 100644 --- a/agent/docker_ops.py +++ b/agent/docker_ops.py @@ -1,6 +1,7 @@ """Docker operations wrapper for managing Swarm and Compose containers.""" import socket +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import docker @@ -77,6 +78,51 @@ class DockerOps: except Exception: return "unknown" + @staticmethod + def _extract_ports(container) -> list[str]: + """Extract exposed host ports from a container's network settings. + + Returns a sorted, deduplicated list of strings like '8080', '443'. + Only includes ports bound to the host. For host-network containers, + reads the exposed ports from the image config instead. + """ + ports = set() + network_mode = container.attrs.get("HostConfig", {}).get("NetworkMode", "") + + if network_mode == "host": + # Host networking: use exposed ports from the image/container config + exposed = container.attrs.get("Config", {}).get("ExposedPorts", {}) + for port_proto in exposed: + port = port_proto.split("/")[0] + ports.add(port) + else: + # Bridge/overlay: use published port bindings + port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {}) or {} + for port_proto, bindings in port_bindings.items(): + if bindings: + for binding in bindings: + host_port = binding.get("HostPort") + if host_port: + ports.add(host_port) + + return sorted(ports, key=lambda p: int(p) if p.isdigit() else 0) + + def _get_container_memory(self, container_id: str) -> tuple[str, int, int]: + """Fetch memory usage and limit for a single running container. + + Returns (container_id, usage_bytes, limit_bytes). + """ + try: + stats = self.client.api.stats(container_id, stream=False, one_shot=True) + mem = stats.get("memory_stats", {}) + usage = mem.get("usage", 0) + limit = mem.get("limit", 0) + # Subtract inactive file cache for accurate usage + cache = mem.get("stats", {}).get("inactive_file", 0) + return (container_id, max(usage - cache, 0), limit) + except Exception: + return (container_id, 0, 0) + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -89,7 +135,7 @@ class DockerOps: Returns a list of dicts, each containing: id, name, status, image, created, uptime, is_swarm, - swarm_service + swarm_service, memory_usage, memory_limit Uses the low-level API to get container IDs to avoid NotFound errors from containers that disappear between list and inspect (e.g. Swarm @@ -98,6 +144,7 @@ class DockerOps: raw_ids = [r["Id"] for r in self.client.api.containers(all=True)] result = [] swarm_services_with_containers = set() + running_ids = {} # full_id -> short_id for memory stats lookup for cid in raw_ids: try: @@ -110,6 +157,8 @@ class DockerOps: if swarm_service: swarm_services_with_containers.add(swarm_service) started_at = c.attrs.get("State", {}).get("StartedAt", "") + if c.status == "running": + running_ids[cid] = c.short_id result.append( { "id": c.short_id, @@ -120,9 +169,28 @@ class DockerOps: "uptime": self._format_uptime(started_at) if c.status == "running" else None, "is_swarm": self._is_swarm(c), "swarm_service": swarm_service, + "ports": self._extract_ports(c), + "memory_usage": 0, + "memory_limit": 0, } ) + # Fetch memory stats for running containers in parallel + if running_ids: + mem_stats = {} + with ThreadPoolExecutor(max_workers=20) as pool: + futures = { + pool.submit(self._get_container_memory, full_id): short_id + for full_id, short_id in running_ids.items() + } + for future in futures: + full_id, usage, limit = future.result() + short_id = futures[future] + mem_stats[short_id] = (usage, limit) + for entry in result: + if entry["id"] in mem_stats: + entry["memory_usage"], entry["memory_limit"] = mem_stats[entry["id"]] + # Include Swarm services with 0 replicas (only works on manager nodes) try: for svc in self.client.services.list(): @@ -135,6 +203,15 @@ class DockerOps: ) if "@" in image: image = image.split("@")[0] + # Extract published ports from Swarm service endpoint spec + svc_ports = [] + endpoint_spec = svc.attrs.get("Spec", {}).get("EndpointSpec", {}) + for p in endpoint_spec.get("Ports", []): + published = p.get("PublishedPort") + if published: + svc_ports.append(str(published)) + svc_ports.sort(key=lambda x: int(x) if x.isdigit() else 0) + result.append( { "id": f"swarm:{svc.name}", @@ -145,6 +222,7 @@ class DockerOps: "uptime": None, "is_swarm": True, "swarm_service": svc.name, + "ports": svc_ports, } ) except Exception: @@ -336,7 +414,10 @@ class DockerOps: Uses the low-level API to avoid NotFound errors from containers stuck in Docker's 'Dead' state. """ - containers = self.client.api.containers(all=True) + containers = [ + c for c in self.client.api.containers(all=True) + if c.get("State") != "dead" + ] return { "status": "healthy", "hostname": socket.gethostname(), diff --git a/agent/models.py b/agent/models.py index abf79a4..8ac62db 100644 --- a/agent/models.py +++ b/agent/models.py @@ -10,6 +10,9 @@ class ContainerInfo(BaseModel): uptime: str | None = None is_swarm: bool = False swarm_service: str | None = None + ports: list[str] = [] + memory_usage: int = 0 + memory_limit: int = 0 class ContainerAction(BaseModel): diff --git a/config.json b/config.json index 9dbbfa9..132aea7 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,19 @@ { "nodes": [ - {"name": "hf-pdocker-01", "host": "192.168.86.192", "agent_port": 8889}, - {"name": "hf-pdocker-02", "host": "192.168.86.100", "agent_port": 8889}, - {"name": "bart", "host": "192.168.86.167", "agent_port": 8889} + { + "name": "hf-pdocker-01", + "host": "192.168.86.192", + "agent_port": 8889 + }, + { + "name": "hf-pdocker-02", + "host": "192.168.86.100", + "agent_port": 8889 + }, + { + "name": "bart", + "host": "192.168.86.167", + "agent_port": 8889 + } ] -} +} \ No newline at end of file diff --git a/groups.json b/groups.json index 49317a9..8ecd094 100644 --- a/groups.json +++ b/groups.json @@ -1,103 +1,340 @@ { - "groups": [ - { - "id": "ai-agents", - "name": "AI Agents", - "services": [ - {"node": "hf-pdocker-01", "container": "ai-agents_margaret"}, - {"node": "hf-pdocker-01", "container": "ai-agents_spirit"}, - {"node": "hf-pdocker-01", "container": "ai-agents_bob"}, - {"node": "hf-pdocker-01", "container": "ai-agents_clippy"} - ] - }, - { - "id": "smart-home", - "name": "Smart Home", - "services": [ - {"node": "hf-pdocker-01", "container": "smarthome_homeassistant"}, - {"node": "hf-pdocker-01", "container": "smarthome_esphome"}, - {"node": "hf-pdocker-01", "container": "smarthome_mosquitto"} - ] - }, - { - "id": "monitoring", - "name": "Monitoring", - "services": [ - {"node": "hf-pdocker-01", "container": "monitoring_grafana"}, - {"node": "hf-pdocker-01", "container": "monitoring_prometheus"}, - {"node": "hf-pdocker-01", "container": "monitoring_alertmanager"}, - {"node": "hf-pdocker-01", "container": "monitoring_influxdb"}, - {"node": "hf-pdocker-01", "container": "monitoring_blackbox-exporter"} - ] - }, - { - "id": "media", - "name": "Media", - "services": [ - {"node": "bart", "container": "jellyfin"}, - {"node": "bart", "container": "sonarr"}, - {"node": "bart", "container": "radarr"}, - {"node": "bart", "container": "sabnzbd"}, - {"node": "bart", "container": "nzbhydra2"}, - {"node": "bart", "container": "jellyseerr"} - ] - }, - { - "id": "matrix", - "name": "Matrix", - "services": [ - {"node": "hf-pdocker-01", "container": "matrix_synapse"}, - {"node": "hf-pdocker-01", "container": "matrix_synapse-db"}, - {"node": "hf-pdocker-01", "container": "matrix_wellknown"} - ] - }, - { - "id": "guacamole", - "name": "Guacamole", - "services": [ - {"node": "hf-pdocker-01", "container": "guacamole_guacamole"}, - {"node": "hf-pdocker-01", "container": "guacamole_guacd"}, - {"node": "hf-pdocker-01", "container": "guacamole_guacamole-db"} - ] - }, - { - "id": "dev-tools", - "name": "Dev Tools", - "services": [ - {"node": "hf-pdocker-02", "container": "gitea"}, - {"node": "hf-pdocker-02", "container": "gitea-db"}, - {"node": "hf-pdocker-02", "container": "semaphore"}, - {"node": "hf-pdocker-02", "container": "semaphore-db"}, - {"node": "hf-pdocker-02", "container": "wikijs"}, - {"node": "hf-pdocker-02", "container": "wikijs-db"} - ] - }, - { - "id": "security", - "name": "Security", - "services": [ - {"node": "hf-pdocker-02", "container": "vaultwarden"}, - {"node": "hf-pdocker-02", "container": "trivy"}, - {"node": "bart", "container": "openvas"} - ] - }, - { - "id": "network", - "name": "Network", - "services": [ - {"node": "hf-pdocker-01", "container": "traefik_traefik"}, - {"node": "hf-pdocker-01", "container": "adguard_adguardhome"}, - {"node": "hf-pdocker-02", "container": "unifi"}, - {"node": "hf-pdocker-02", "container": "unifi-db"} - ] - }, - { - "id": "frigate", - "name": "Frigate", - "services": [ - {"node": "hf-pdocker-01", "container": "frigate"}, - {"node": "hf-pdocker-01", "container": "frigate-notify"} - ] - } - ] -} + "groups": [ + { + "id": "ai-agents", + "name": "AI Agents", + "services": [ + { + "node": "hf-pdocker-01", + "container": "margaret" + }, + { + "node": "hf-pdocker-01", + "container": "spirit" + }, + { + "node": "hf-pdocker-01", + "container": "hermes" + } + ] + }, + { + "id": "smart-home", + "name": "Smart Home", + "services": [ + { + "node": "hf-pdocker-01", + "container": "homeassistant" + }, + { + "node": "hf-pdocker-01", + "container": "esphome" + }, + { + "node": "hf-pdocker-01", + "container": "mosquitto" + } + ] + }, + { + "id": "monitoring", + "name": "Monitoring", + "services": [ + { + "node": "hf-pdocker-01", + "container": "monitoring-grafana-1" + }, + { + "node": "hf-pdocker-01", + "container": "monitoring-prometheus-1" + }, + { + "node": "hf-pdocker-01", + "container": "monitoring-alertmanager-1" + }, + { + "node": "hf-pdocker-01", + "container": "monitoring-blackbox-exporter-1" + }, + { + "node": "hf-pdocker-01", + "container": "monitoring-matrix-alertmanager-receiver-1" + }, + { + "node": "hf-pdocker-01", + "container": "monitoring-cadvisor-1" + }, + { + "node": "hf-pdocker-01", + "container": "influxdb" + } + ] + }, + { + "id": "media", + "name": "Media", + "services": [ + { + "node": "bart", + "container": "jellyfin" + }, + { + "node": "bart", + "container": "sonarr" + }, + { + "node": "bart", + "container": "radarr" + }, + { + "node": "bart", + "container": "sabnzbd" + }, + { + "node": "bart", + "container": "nzbhydra2" + }, + { + "node": "bart", + "container": "seerr" + } + ] + }, + { + "id": "matrix", + "name": "Matrix", + "services": [ + { + "node": "hf-pdocker-01", + "container": "synapse" + }, + { + "node": "hf-pdocker-01", + "container": "synapse-db" + }, + { + "node": "hf-pdocker-01", + "container": "matrix-wellknown" + }, + { + "node": "hf-pdocker-01", + "container": "mas" + }, + { + "node": "hf-pdocker-01", + "container": "mas-db" + }, + { + "node": "hf-pdocker-01", + "container": "synapse-admin" + } + ] + }, + { + "id": "guacamole", + "name": "Guacamole", + "services": [ + { + "node": "hf-pdocker-01", + "container": "guacamole" + }, + { + "node": "hf-pdocker-01", + "container": "guacd" + }, + { + "node": "hf-pdocker-01", + "container": "guacamole-db" + } + ] + }, + { + "id": "dev-tools", + "name": "Dev Tools", + "services": [ + { + "node": "hf-pdocker-02", + "container": "gitea" + }, + { + "node": "hf-pdocker-02", + "container": "gitea-db" + }, + { + "node": "hf-pdocker-02", + "container": "semaphore" + }, + { + "node": "hf-pdocker-02", + "container": "semaphore-db" + }, + { + "node": "hf-pdocker-02", + "container": "wikijs" + }, + { + "node": "hf-pdocker-02", + "container": "wikijs-db" + } + ] + }, + { + "id": "security", + "name": "Security", + "services": [ + { + "node": "hf-pdocker-02", + "container": "vaultwarden" + }, + { + "node": "hf-pdocker-02", + "container": "trivy" + } + ] + }, + { + "id": "network", + "name": "Network", + "services": [ + { + "node": "hf-pdocker-01", + "container": "traefik" + } + ] + }, + { + "id": "frigate", + "name": "Frigate", + "services": [ + { + "node": "hf-pdocker-01", + "container": "frigate" + }, + { + "node": "hf-pdocker-01", + "container": "frigate-notify" + }, + { + "node": "hf-pdocker-01", + "container": "clip-service" + } + ] + }, + { + "id": "onyx", + "name": "Onyx", + "services": [ + { + "node": "hf-pdocker-01", + "container": "onyx-api_server-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-background-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-cache-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-index-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-indexing_model_server-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-inference_model_server-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-nginx-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-relational_db-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-web_server-1" + }, + { + "node": "hf-pdocker-01", + "container": "onyx-opensearch-1" + } + ] + }, + { + "id": "llm", + "name": "LLM", + "services": [ + { + "node": "hf-pdocker-01", + "container": "litellm" + }, + { + "node": "hf-pdocker-01", + "container": "litellm-db" + } + ] + }, + { + "id": "automation", + "name": "Automation & Utilities", + "services": [ + { + "node": "hf-pdocker-02", + "container": "n8n" + }, + { + "node": "hf-pdocker-02", + "container": "mcpo" + }, + { + "node": "hf-pdocker-02", + "container": "searxng" + }, + { + "node": "hf-pdocker-02", + "container": "unstructured-api" + }, + { + "node": "hf-pdocker-02", + "container": "couchdb" + } + ] + }, + { + "id": "home-apps", + "name": "Home Apps", + "services": [ + { + "node": "hf-pdocker-01", + "container": "linkedstorm-api" + }, + { + "node": "hf-pdocker-01", + "container": "linkedstorm-pipeline" + }, + { + "node": "hf-pdocker-01", + "container": "linkedstorm-cron" + }, + { + "node": "hf-pdocker-01", + "container": "rongorongo-web" + }, + { + "node": "hf-pdocker-01", + "container": "rongorongo-api" + }, + { + "node": "hf-pdocker-01", + "container": "farm-manager" + } + ] + } + ] +} \ No newline at end of file diff --git a/server/models.py b/server/models.py index 08b4e5f..76f8f4b 100644 --- a/server/models.py +++ b/server/models.py @@ -40,3 +40,6 @@ class ServiceInfo(BaseModel): uptime: str | None = None is_swarm: bool = False swarm_service: str | None = None + ports: list[str] = [] + memory_usage: int = 0 + memory_limit: int = 0 diff --git a/server/routes/services.py b/server/routes/services.py index ec3d0c0..eacd532 100644 --- a/server/routes/services.py +++ b/server/routes/services.py @@ -30,7 +30,7 @@ async def _fetch_containers(http_client: httpx.AsyncClient, node: dict) -> list[ """Fetch containers from a single node agent, adding the node field.""" url = f"http://{node['host']}:{node['agent_port']}/containers" try: - resp = await http_client.get(url, timeout=10.0) + resp = await http_client.get(url, timeout=30.0) resp.raise_for_status() containers = resp.json() for c in containers: diff --git a/server/static/app.js b/server/static/app.js index c5249ea..a4c7d93 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -405,10 +405,18 @@ function renderServiceCard(s) { + ${s.ports && s.ports.length ? `
` : ""} + ${isRunning && s.memory_usage ? `` : ""}