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:
2026-10-26 16:27:43 -05:00
parent 6338621ad4
commit 446c01fb78
8 changed files with 559 additions and 134 deletions
+83 -2
View File
@@ -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(),
+3
View File
@@ -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):
+15 -3
View File
@@ -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
}
] ]
} }
+338 -101
View File
@@ -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"
}
]
}
]
} }
+3
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+19
View File
@@ -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;