fix: agent timeouts hid hf-pdocker-01; refresh links and groups
- agent: stats one_shot=True + 20 workers (.192 went 12s -> ~2s); /health ignores Dead containers - server: /containers fetch timeout 10s -> 30s (was silently returning [] for the busiest node) - links page: drop FRIDAY Voice, add Onyx, Guacamole, Synapse Admin, n8n, CouchDB; MCPO -> /docs, Trivy -> /healthz, Jellyseerr -> Seerr - config.json: drop dead gpu-workstation (192.168.86.27) - groups.json: rebuilt for current container names (14 groups) - includes earlier uncommitted work (models, styles, ports sorting) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhgEUqcaV4WM2S4hfXgHD
This commit is contained in:
+83
-2
@@ -1,6 +1,7 @@
|
|||||||
"""Docker operations wrapper for managing Swarm and Compose containers."""
|
"""Docker operations wrapper for managing Swarm and Compose containers."""
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
@@ -77,6 +78,51 @@ class DockerOps:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return "unknown"
|
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
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -89,7 +135,7 @@ class DockerOps:
|
|||||||
|
|
||||||
Returns a list of dicts, each containing:
|
Returns a list of dicts, each containing:
|
||||||
id, name, status, image, created, uptime, is_swarm,
|
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
|
Uses the low-level API to get container IDs to avoid NotFound errors
|
||||||
from containers that disappear between list and inspect (e.g. Swarm
|
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)]
|
raw_ids = [r["Id"] for r in self.client.api.containers(all=True)]
|
||||||
result = []
|
result = []
|
||||||
swarm_services_with_containers = set()
|
swarm_services_with_containers = set()
|
||||||
|
running_ids = {} # full_id -> short_id for memory stats lookup
|
||||||
|
|
||||||
for cid in raw_ids:
|
for cid in raw_ids:
|
||||||
try:
|
try:
|
||||||
@@ -110,6 +157,8 @@ class DockerOps:
|
|||||||
if swarm_service:
|
if swarm_service:
|
||||||
swarm_services_with_containers.add(swarm_service)
|
swarm_services_with_containers.add(swarm_service)
|
||||||
started_at = c.attrs.get("State", {}).get("StartedAt", "")
|
started_at = c.attrs.get("State", {}).get("StartedAt", "")
|
||||||
|
if c.status == "running":
|
||||||
|
running_ids[cid] = c.short_id
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": c.short_id,
|
"id": c.short_id,
|
||||||
@@ -120,9 +169,28 @@ class DockerOps:
|
|||||||
"uptime": self._format_uptime(started_at) if c.status == "running" else None,
|
"uptime": self._format_uptime(started_at) if c.status == "running" else None,
|
||||||
"is_swarm": self._is_swarm(c),
|
"is_swarm": self._is_swarm(c),
|
||||||
"swarm_service": swarm_service,
|
"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)
|
# Include Swarm services with 0 replicas (only works on manager nodes)
|
||||||
try:
|
try:
|
||||||
for svc in self.client.services.list():
|
for svc in self.client.services.list():
|
||||||
@@ -135,6 +203,15 @@ class DockerOps:
|
|||||||
)
|
)
|
||||||
if "@" in image:
|
if "@" in image:
|
||||||
image = image.split("@")[0]
|
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(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": f"swarm:{svc.name}",
|
"id": f"swarm:{svc.name}",
|
||||||
@@ -145,6 +222,7 @@ class DockerOps:
|
|||||||
"uptime": None,
|
"uptime": None,
|
||||||
"is_swarm": True,
|
"is_swarm": True,
|
||||||
"swarm_service": svc.name,
|
"swarm_service": svc.name,
|
||||||
|
"ports": svc_ports,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -336,7 +414,10 @@ class DockerOps:
|
|||||||
Uses the low-level API to avoid NotFound errors from containers
|
Uses the low-level API to avoid NotFound errors from containers
|
||||||
stuck in Docker's 'Dead' state.
|
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 {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class ContainerInfo(BaseModel):
|
|||||||
uptime: str | None = None
|
uptime: str | None = None
|
||||||
is_swarm: bool = False
|
is_swarm: bool = False
|
||||||
swarm_service: str | None = None
|
swarm_service: str | None = None
|
||||||
|
ports: list[str] = []
|
||||||
|
memory_usage: int = 0
|
||||||
|
memory_limit: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ContainerAction(BaseModel):
|
class ContainerAction(BaseModel):
|
||||||
|
|||||||
+16
-4
@@ -1,7 +1,19 @@
|
|||||||
{
|
{
|
||||||
"nodes": [
|
"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": "hf-pdocker-01",
|
||||||
{"name": "bart", "host": "192.168.86.167", "agent_port": 8889}
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+339
-102
@@ -1,103 +1,340 @@
|
|||||||
{
|
{
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "ai-agents",
|
"id": "ai-agents",
|
||||||
"name": "AI Agents",
|
"name": "AI Agents",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_margaret"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_spirit"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_bob"},
|
"container": "margaret"
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_clippy"}
|
},
|
||||||
]
|
{
|
||||||
},
|
"node": "hf-pdocker-01",
|
||||||
{
|
"container": "spirit"
|
||||||
"id": "smart-home",
|
},
|
||||||
"name": "Smart Home",
|
{
|
||||||
"services": [
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "smarthome_homeassistant"},
|
"container": "hermes"
|
||||||
{"node": "hf-pdocker-01", "container": "smarthome_esphome"},
|
}
|
||||||
{"node": "hf-pdocker-01", "container": "smarthome_mosquitto"}
|
]
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
"id": "smart-home",
|
||||||
"id": "monitoring",
|
"name": "Smart Home",
|
||||||
"name": "Monitoring",
|
"services": [
|
||||||
"services": [
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_grafana"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_prometheus"},
|
"container": "homeassistant"
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_alertmanager"},
|
},
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_influxdb"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_blackbox-exporter"}
|
"node": "hf-pdocker-01",
|
||||||
]
|
"container": "esphome"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "media",
|
"node": "hf-pdocker-01",
|
||||||
"name": "Media",
|
"container": "mosquitto"
|
||||||
"services": [
|
}
|
||||||
{"node": "bart", "container": "jellyfin"},
|
]
|
||||||
{"node": "bart", "container": "sonarr"},
|
},
|
||||||
{"node": "bart", "container": "radarr"},
|
{
|
||||||
{"node": "bart", "container": "sabnzbd"},
|
"id": "monitoring",
|
||||||
{"node": "bart", "container": "nzbhydra2"},
|
"name": "Monitoring",
|
||||||
{"node": "bart", "container": "jellyseerr"}
|
"services": [
|
||||||
]
|
{
|
||||||
},
|
"node": "hf-pdocker-01",
|
||||||
{
|
"container": "monitoring-grafana-1"
|
||||||
"id": "matrix",
|
},
|
||||||
"name": "Matrix",
|
{
|
||||||
"services": [
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "matrix_synapse"},
|
"container": "monitoring-prometheus-1"
|
||||||
{"node": "hf-pdocker-01", "container": "matrix_synapse-db"},
|
},
|
||||||
{"node": "hf-pdocker-01", "container": "matrix_wellknown"}
|
{
|
||||||
]
|
"node": "hf-pdocker-01",
|
||||||
},
|
"container": "monitoring-alertmanager-1"
|
||||||
{
|
},
|
||||||
"id": "guacamole",
|
{
|
||||||
"name": "Guacamole",
|
"node": "hf-pdocker-01",
|
||||||
"services": [
|
"container": "monitoring-blackbox-exporter-1"
|
||||||
{"node": "hf-pdocker-01", "container": "guacamole_guacamole"},
|
},
|
||||||
{"node": "hf-pdocker-01", "container": "guacamole_guacd"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "guacamole_guacamole-db"}
|
"node": "hf-pdocker-01",
|
||||||
]
|
"container": "monitoring-matrix-alertmanager-receiver-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dev-tools",
|
"node": "hf-pdocker-01",
|
||||||
"name": "Dev Tools",
|
"container": "monitoring-cadvisor-1"
|
||||||
"services": [
|
},
|
||||||
{"node": "hf-pdocker-02", "container": "gitea"},
|
{
|
||||||
{"node": "hf-pdocker-02", "container": "gitea-db"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-02", "container": "semaphore"},
|
"container": "influxdb"
|
||||||
{"node": "hf-pdocker-02", "container": "semaphore-db"},
|
}
|
||||||
{"node": "hf-pdocker-02", "container": "wikijs"},
|
]
|
||||||
{"node": "hf-pdocker-02", "container": "wikijs-db"}
|
},
|
||||||
]
|
{
|
||||||
},
|
"id": "media",
|
||||||
{
|
"name": "Media",
|
||||||
"id": "security",
|
"services": [
|
||||||
"name": "Security",
|
{
|
||||||
"services": [
|
"node": "bart",
|
||||||
{"node": "hf-pdocker-02", "container": "vaultwarden"},
|
"container": "jellyfin"
|
||||||
{"node": "hf-pdocker-02", "container": "trivy"},
|
},
|
||||||
{"node": "bart", "container": "openvas"}
|
{
|
||||||
]
|
"node": "bart",
|
||||||
},
|
"container": "sonarr"
|
||||||
{
|
},
|
||||||
"id": "network",
|
{
|
||||||
"name": "Network",
|
"node": "bart",
|
||||||
"services": [
|
"container": "radarr"
|
||||||
{"node": "hf-pdocker-01", "container": "traefik_traefik"},
|
},
|
||||||
{"node": "hf-pdocker-01", "container": "adguard_adguardhome"},
|
{
|
||||||
{"node": "hf-pdocker-02", "container": "unifi"},
|
"node": "bart",
|
||||||
{"node": "hf-pdocker-02", "container": "unifi-db"}
|
"container": "sabnzbd"
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
"node": "bart",
|
||||||
"id": "frigate",
|
"container": "nzbhydra2"
|
||||||
"name": "Frigate",
|
},
|
||||||
"services": [
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "frigate"},
|
"node": "bart",
|
||||||
{"node": "hf-pdocker-01", "container": "frigate-notify"}
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -40,3 +40,6 @@ class ServiceInfo(BaseModel):
|
|||||||
uptime: str | None = None
|
uptime: str | None = None
|
||||||
is_swarm: bool = False
|
is_swarm: bool = False
|
||||||
swarm_service: str | None = None
|
swarm_service: str | None = None
|
||||||
|
ports: list[str] = []
|
||||||
|
memory_usage: int = 0
|
||||||
|
memory_limit: int = 0
|
||||||
|
|||||||
@@ -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."""
|
"""Fetch containers from a single node agent, adding the node field."""
|
||||||
url = f"http://{node['host']}:{node['agent_port']}/containers"
|
url = f"http://{node['host']}:{node['agent_port']}/containers"
|
||||||
try:
|
try:
|
||||||
resp = await http_client.get(url, timeout=10.0)
|
resp = await http_client.get(url, timeout=30.0)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
containers = resp.json()
|
containers = resp.json()
|
||||||
for c in containers:
|
for c in containers:
|
||||||
|
|||||||
+95
-25
@@ -405,10 +405,18 @@ function renderServiceCard(s) {
|
|||||||
<span class="card-meta-label">Image</span>
|
<span class="card-meta-label">Image</span>
|
||||||
<span class="card-meta-value" title="${escHtml(s.image)}">${escHtml(imageDisplay)}</span>
|
<span class="card-meta-value" title="${escHtml(s.image)}">${escHtml(imageDisplay)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${s.ports && s.ports.length ? `<div class="card-meta-row">
|
||||||
|
<span class="card-meta-label">Ports</span>
|
||||||
|
<span class="card-meta-value card-ports">${s.ports.map(p => `<span class="port-badge">${escHtml(p)}</span>`).join(" ")}</span>
|
||||||
|
</div>` : ""}
|
||||||
<div class="card-meta-row">
|
<div class="card-meta-row">
|
||||||
<span class="card-meta-label">Up</span>
|
<span class="card-meta-label">Up</span>
|
||||||
<span class="card-meta-value">${escHtml(uptimeText)}</span>
|
<span class="card-meta-value">${escHtml(uptimeText)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${isRunning && s.memory_usage ? `<div class="card-meta-row">
|
||||||
|
<span class="card-meta-label">Mem</span>
|
||||||
|
<span class="card-meta-value">${formatMemory(s.memory_usage, s.memory_limit)}</span>
|
||||||
|
</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="card-action-btn action-start" title="Start"
|
<button class="card-action-btn action-start" title="Start"
|
||||||
@@ -735,9 +743,13 @@ const serverLinks = [
|
|||||||
{ category: "Public Services", links: [
|
{ category: "Public Services", links: [
|
||||||
{ name: "Home Assistant", url: "https://yoda.hobbs.farm", icon: "\uD83C\uDFE0" },
|
{ name: "Home Assistant", url: "https://yoda.hobbs.farm", icon: "\uD83C\uDFE0" },
|
||||||
{ name: "Matrix Chat", url: "https://mess.hobbs.farm", icon: "\uD83D\uDCAC" },
|
{ name: "Matrix Chat", url: "https://mess.hobbs.farm", icon: "\uD83D\uDCAC" },
|
||||||
|
{ name: "Matrix Auth (MAS)", url: "https://auth.hobbs.farm", icon: "\uD83D\uDD10" },
|
||||||
{ name: "Guacamole", url: "https://solo.hobbs.farm/guacamole/", icon: "\uD83D\uDDA5\uFE0F" },
|
{ name: "Guacamole", url: "https://solo.hobbs.farm/guacamole/", icon: "\uD83D\uDDA5\uFE0F" },
|
||||||
{ name: "Vaultwarden", url: "https://vault.hobbs.farm", icon: "\uD83D\uDD12" },
|
{ name: "Vaultwarden", url: "https://vault.hobbs.farm", icon: "\uD83D\uDD12" },
|
||||||
{ name: "Gitea", url: "https://git.hobbs.farm", icon: "\uD83D\uDCE6" },
|
{ name: "Gitea", url: "https://git.hobbs.farm", icon: "\uD83D\uDCE6" },
|
||||||
|
{ name: "Onyx", url: "https://onyx.hobbs.farm", icon: "\uD83D\uDD0D" },
|
||||||
|
{ name: "LinkedStorm", url: "https://linkedstorm.hobbs.farm", icon: "\uD83D\uDD0A" },
|
||||||
|
{ name: "Rongorongo", url: "https://rongorongo.hobbs.farm", icon: "\uD83D\uDDFF" },
|
||||||
]},
|
]},
|
||||||
{ category: "hf-pdocker-01 (192.168.86.192)", links: [
|
{ category: "hf-pdocker-01 (192.168.86.192)", links: [
|
||||||
{ name: "Farm Manager", url: "http://192.168.86.192:8888", icon: "\uD83C\uDF3E" },
|
{ name: "Farm Manager", url: "http://192.168.86.192:8888", icon: "\uD83C\uDF3E" },
|
||||||
@@ -747,20 +759,29 @@ const serverLinks = [
|
|||||||
{ name: "Grafana", url: "http://192.168.86.192:3001", icon: "\uD83D\uDCCA" },
|
{ name: "Grafana", url: "http://192.168.86.192:3001", icon: "\uD83D\uDCCA" },
|
||||||
{ name: "Prometheus", url: "http://192.168.86.192:9095", icon: "\uD83D\uDD25" },
|
{ name: "Prometheus", url: "http://192.168.86.192:9095", icon: "\uD83D\uDD25" },
|
||||||
{ name: "Alertmanager", url: "http://192.168.86.192:9093", icon: "\uD83D\uDEA8" },
|
{ name: "Alertmanager", url: "http://192.168.86.192:9093", icon: "\uD83D\uDEA8" },
|
||||||
{ name: "AdGuard Home", url: "http://192.168.86.192:3000", icon: "\uD83D\uDEE1\uFE0F" },
|
|
||||||
{ name: "Frigate Alerts", url: "http://192.168.86.192:5199", icon: "\uD83D\uDD14" },
|
{ name: "Frigate Alerts", url: "http://192.168.86.192:5199", icon: "\uD83D\uDD14" },
|
||||||
{ name: "Traefik Dashboard", url: "http://192.168.86.192:8090", icon: "\uD83D\uDEA6" },
|
{ name: "Traefik Dashboard", url: "http://192.168.86.192:8090", icon: "\uD83D\uDEA6" },
|
||||||
{ name: "Portainer", url: "http://192.168.86.192:9000", icon: "\uD83D\uDC33" },
|
{ name: "LiteLLM", url: "http://192.168.86.192:4000/ui/login", icon: "\uD83E\uDD16" },
|
||||||
|
{ name: "LinkedStorm", url: "http://192.168.86.192:8310", icon: "\uD83D\uDD0A" },
|
||||||
|
{ name: "Rongorongo", url: "http://192.168.86.192:8181", icon: "\uD83D\uDDFF" },
|
||||||
|
{ name: "Onyx", url: "http://192.168.86.192:3005", icon: "\uD83D\uDD0D" },
|
||||||
|
{ name: "Guacamole", url: "http://192.168.86.192:8199/guacamole/", icon: "\uD83D\uDDA5\uFE0F" },
|
||||||
|
{ name: "Synapse Admin", url: "http://192.168.86.192:8080", icon: "\uD83D\uDEE0\uFE0F" },
|
||||||
|
]},
|
||||||
|
{ category: "AI Agents (hf-pdocker-01)", links: [
|
||||||
|
{ name: "Margaret", url: "http://192.168.86.192:18790", icon: "\uD83E\uDDD1\u200D\uD83D\uDCBB" },
|
||||||
|
{ name: "Spirit", url: "http://192.168.86.192:18795", icon: "\uD83E\uDDD1\u200D\uD83D\uDCBB" },
|
||||||
]},
|
]},
|
||||||
{ category: "hf-pdocker-02 (192.168.86.100)", links: [
|
{ category: "hf-pdocker-02 (192.168.86.100)", links: [
|
||||||
{ name: "Gitea", url: "http://192.168.86.100:3003", icon: "\uD83D\uDCE6" },
|
{ name: "Gitea", url: "http://192.168.86.100:3003", icon: "\uD83D\uDCE6" },
|
||||||
{ name: "Wiki.js", url: "http://192.168.86.100:3002", icon: "\uD83D\uDCDA" },
|
{ name: "Wiki.js", url: "http://192.168.86.100:3002", icon: "\uD83D\uDCDA" },
|
||||||
{ name: "Semaphore", url: "http://192.168.86.100:3004", icon: "\uD83C\uDFAF" },
|
{ name: "Semaphore", url: "http://192.168.86.100:3004", icon: "\uD83C\uDFAF" },
|
||||||
{ name: "Unifi Controller", url: "https://192.168.86.100:8443", icon: "\uD83D\uDCF6" },
|
|
||||||
{ name: "Vaultwarden", url: "http://192.168.86.100:26226", icon: "\uD83D\uDD12" },
|
{ name: "Vaultwarden", url: "http://192.168.86.100:26226", icon: "\uD83D\uDD12" },
|
||||||
{ name: "Actual Budget", url: "http://192.168.86.100:5006", icon: "\uD83D\uDCB0" },
|
{ name: "MCPO", url: "http://192.168.86.100:8001/docs", icon: "\uD83E\uDD16" },
|
||||||
{ name: "MCPO", url: "http://192.168.86.100:8001", icon: "\uD83E\uDD16" },
|
{ name: "Trivy server (API only)", url: "http://192.168.86.100:4954/healthz", icon: "\uD83D\uDD0D" },
|
||||||
{ name: "Trivy", url: "http://192.168.86.100:4954", icon: "\uD83D\uDD0D" },
|
{ name: "SearXNG", url: "http://192.168.86.100:8888", icon: "\uD83D\uDD0E" },
|
||||||
|
{ name: "n8n", url: "http://192.168.86.100:5678", icon: "\uD83D\uDD00" },
|
||||||
|
{ name: "CouchDB (Fauxton)", url: "http://192.168.86.100:5984/_utils/", icon: "\uD83D\uDECB\uFE0F" },
|
||||||
]},
|
]},
|
||||||
{ category: "bart (192.168.86.167)", links: [
|
{ category: "bart (192.168.86.167)", links: [
|
||||||
{ name: "Jellyfin", url: "http://192.168.86.167:8096", icon: "\uD83C\uDFAC" },
|
{ name: "Jellyfin", url: "http://192.168.86.167:8096", icon: "\uD83C\uDFAC" },
|
||||||
@@ -768,8 +789,17 @@ const serverLinks = [
|
|||||||
{ name: "Radarr", url: "http://192.168.86.167:7878", icon: "\uD83C\uDFAC" },
|
{ name: "Radarr", url: "http://192.168.86.167:7878", icon: "\uD83C\uDFAC" },
|
||||||
{ name: "SABnzbd", url: "http://192.168.86.167:8082", icon: "\u2B07\uFE0F" },
|
{ name: "SABnzbd", url: "http://192.168.86.167:8082", icon: "\u2B07\uFE0F" },
|
||||||
{ name: "NZBHydra2", url: "http://192.168.86.167:5076", icon: "\uD83D\uDD0E" },
|
{ name: "NZBHydra2", url: "http://192.168.86.167:5076", icon: "\uD83D\uDD0E" },
|
||||||
{ name: "Jellyseerr", url: "http://192.168.86.167:5055", icon: "\u2B50" },
|
{ name: "Seerr", url: "http://192.168.86.167:5055", icon: "\u2B50" },
|
||||||
{ name: "OpenVAS", url: "http://192.168.86.167:9392", icon: "\uD83D\uDEE1\uFE0F" },
|
]},
|
||||||
|
// GPU Machine = the .150 Windows workstation running Docker Desktop.
|
||||||
|
// hermes moved to hf-pdocker-01 (host network, no published port) and FRIDAY
|
||||||
|
// Voice was decommissioned 2026-08-26, so neither has a link card.
|
||||||
|
{ category: "GPU Machine (192.168.86.150)", links: [
|
||||||
|
{ name: "UniFi OS Server (controller)", url: "https://192.168.86.150:11443", icon: "\uD83D\uDCF6" },
|
||||||
|
{ name: "llama.cpp", url: "http://192.168.86.150:8081", icon: "\uD83E\uDDE0" },
|
||||||
|
// speaches removed 2026-08-21 (VRAM freed for llama.cpp); re-add when rebuilt:
|
||||||
|
// { name: "Speaches STT/TTS (GPU)", url: "http://192.168.86.150:8000", icon: "\uD83D\uDDE3\uFE0F" },
|
||||||
|
{ name: "Ollama", url: "http://192.168.86.150:11434", icon: "\uD83E\uDD99" },
|
||||||
]},
|
]},
|
||||||
{ category: "Infrastructure", links: [
|
{ category: "Infrastructure", links: [
|
||||||
{ name: "Synology NAS", url: "https://192.168.86.30:5001", icon: "\uD83D\uDCBE" },
|
{ name: "Synology NAS", url: "https://192.168.86.30:5001", icon: "\uD83D\uDCBE" },
|
||||||
@@ -778,24 +808,49 @@ const serverLinks = [
|
|||||||
|
|
||||||
function renderLinksView() {
|
function renderLinksView() {
|
||||||
const container = $("#links-view");
|
const container = $("#links-view");
|
||||||
container.innerHTML = `<h2 style="margin-bottom:20px;">Server Links</h2>` +
|
const query = ($("#links-search") || {}).value || "";
|
||||||
serverLinks.map((cat) => `
|
const q = query.toLowerCase();
|
||||||
<div class="links-category">
|
|
||||||
<div class="links-category-title">${escHtml(cat.category)}</div>
|
const filtered = q
|
||||||
<div class="links-grid">
|
? serverLinks.map((cat) => ({
|
||||||
${cat.links.map((l) => `
|
...cat,
|
||||||
<a href="${escHtml(l.url)}" target="_blank" rel="noopener" class="link-card">
|
links: cat.links.filter((l) =>
|
||||||
<div class="link-icon">${l.icon}</div>
|
l.name.toLowerCase().includes(q) ||
|
||||||
<div class="link-info">
|
l.url.toLowerCase().includes(q) ||
|
||||||
<div class="link-name">${escHtml(l.name)}</div>
|
cat.category.toLowerCase().includes(q)
|
||||||
<div class="link-url">${escHtml(l.url)}</div>
|
),
|
||||||
</div>
|
})).filter((cat) => cat.links.length > 0)
|
||||||
<span class="link-arrow">\u203A</span>
|
: serverLinks;
|
||||||
</a>
|
|
||||||
`).join("")}
|
const header = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
||||||
</div>
|
<h2 style="margin:0;">Server Links</h2>
|
||||||
|
<input type="text" id="links-search" class="search-input" placeholder="Search links..." value="${escHtml(query)}">
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const cats = filtered.map((cat) => `
|
||||||
|
<div class="links-category">
|
||||||
|
<div class="links-category-title">${escHtml(cat.category)}</div>
|
||||||
|
<div class="links-grid">
|
||||||
|
${cat.links.map((l) => `
|
||||||
|
<a href="${escHtml(l.url)}" target="_blank" rel="noopener" class="link-card">
|
||||||
|
<div class="link-icon">${l.icon}</div>
|
||||||
|
<div class="link-info">
|
||||||
|
<div class="link-name">${escHtml(l.name)}</div>
|
||||||
|
<div class="link-url">${escHtml(l.url)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="link-arrow">\u203A</span>
|
||||||
|
</a>
|
||||||
|
`).join("")}
|
||||||
</div>
|
</div>
|
||||||
`).join("");
|
</div>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
container.innerHTML = header + (cats || `<div class="empty-state"><p>No links match your search.</p></div>`);
|
||||||
|
|
||||||
|
const searchInput = $("#links-search");
|
||||||
|
searchInput.addEventListener("input", () => renderLinksView());
|
||||||
|
searchInput.focus();
|
||||||
|
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -814,6 +869,21 @@ function capitalize(str) {
|
|||||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + " KB";
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + " MB";
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemory(usage, limit) {
|
||||||
|
const usageStr = formatBytes(usage);
|
||||||
|
if (limit && limit < 1e15) {
|
||||||
|
return usageStr + " / " + formatBytes(limit);
|
||||||
|
}
|
||||||
|
return usageStr;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Event bindings
|
// Event bindings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -561,6 +561,25 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-ports {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: visible;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user