# SPDX-License-Identifier: Apache-2.0 # Standard from importlib import resources from urllib.parse import unquote import argparse import asyncio import json import os import threading import time # Third Party from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.staticfiles import StaticFiles from starlette.responses import FileResponse, PlainTextResponse import httpx import uvicorn _PACKAGE = "lmcache.lmcache_frontend" def _package_resource_path(relative: str) -> str: """Return absolute filesystem path for a file shipped inside the package. Replacement for the deprecated ``pkg_resources.resource_filename``; works for regular (non-zipped) installs, which is how LMCache ships. """ return str(resources.files(_PACKAGE).joinpath(relative)) try: # Local from .heartbeat import HeartbeatService # import as module except ImportError: # Third Party from heartbeat import HeartbeatService # type: ignore # import as script # Create router router = APIRouter() class _NodeRegistry: """Encapsulates the mutable proxy/node list used by the frontend. Replacing the list is done in-place via :py:meth:`replace` so that aliases (the module-level ``target_nodes`` reference and the list handed to ``HeartbeatService``) stay in sync. """ def __init__(self) -> None: self._nodes: list[dict] = [] @property def nodes(self) -> list[dict]: """Return the underlying list (mutated in place).""" return self._nodes def replace(self, new_nodes: list[dict]) -> None: """Swap the registry content in place with ``new_nodes``.""" self._nodes[:] = new_nodes def is_allowed(self, host: str, port: str) -> bool: """Return True if ``host:port`` matches any registered node. Used by the SSRF guard in :func:`proxy_request` so the proxy only forwards to pre-registered destinations. """ return self.resolve(host, port) is not None def resolve(self, host: str, port: str) -> tuple[str, str] | None: """Return the registry-owned ``(host, port)`` matching the input. The returned tuple is taken from the registry itself, not from the caller-supplied arguments. Using this value to build the outbound URL breaks the SSRF taint flow for static analysers. """ port = str(port) for proxy in self._nodes: p_host, p_port = str(proxy.get("host")), str(proxy.get("port")) if p_host == host and p_port == port: return p_host, p_port for child in proxy.get("nodes", []): c_host, c_port = str(child.get("host")), str(child.get("port")) if c_host == host and c_port == port: return c_host, c_port return None _node_registry = _NodeRegistry() # ``target_nodes`` is a module-level alias to the list owned by the # registry. External readers and in-place mutations (append / element # update) keep working unchanged; whole-list replacement MUST go # through ``_node_registry.replace`` so all aliases stay in sync. target_nodes = _node_registry.nodes # Initialize heartbeat service with app context heartbeat_service: HeartbeatService = HeartbeatService() global args args = None async def fetch_child_nodes_from_proxy(proxy_node: dict) -> list[dict]: """Fetch child nodes from a single proxy node. Args: proxy_node: Proxy dict with ``name``/``host``/``port`` keys. ``/api/nodes`` of the proxy is queried. Returns: A list of leaf node dicts discovered from the proxy. An empty list is returned when the request fails. """ try: url = f"http://{proxy_node['host']}:{proxy_node['port']}/api/nodes" async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(url) response.raise_for_status() return_nodes = [] added_names = set() # Track node names to avoid duplicates nodes = response.json().get("nodes", []) # Process each node: only add leaf nodes (nodes without children) for node in nodes: if node.get("children"): # If node has children, add only the children (leaf nodes) for child in node["children"]: # Skip if already added if child["name"] in added_names: continue child["proxy_id"] = proxy_node["name"] return_nodes.append(child) added_names.add(child["name"]) else: # Skip if already added if node["name"] in added_names: continue node["proxy_id"] = proxy_node["name"] return_nodes.append(node) added_names.add(node["name"]) return return_nodes except Exception as e: print(f"Failed to fetch nodes from proxy {proxy_node['name']}: {e}") return [] async def fetch_all_child_nodes_concurrently( proxy_nodes: list[dict], ) -> list[dict]: """Fetch child nodes from multiple proxy nodes concurrently. Args: proxy_nodes: Proxy dicts to query. Each dict is mutated in place with a new ``nodes`` field. Returns: The same ``proxy_nodes`` list with ``nodes`` populated. """ tasks = [fetch_child_nodes_from_proxy(proxy) for proxy in proxy_nodes] results = await asyncio.gather(*tasks, return_exceptions=True) # Process results and handle exceptions for i, result in enumerate(results): if isinstance(result, Exception): print(f"Error fetching nodes from proxy {proxy_nodes[i]['name']}: {result}") proxy_nodes[i]["nodes"] = [] else: proxy_nodes[i]["nodes"] = result return proxy_nodes async def _fetch_child_nodes_from_api_nodes( host: str, port: str, proxy_name: str ) -> list: """Try to fetch child nodes via /api/nodes from a multiProcess server. Returns a non-empty list of child node dicts when the target is a multiProcess lmcache server that exposes /api/nodes. Returns an empty list for inProcess nodes (connection error or no children). """ try: url = "http://%s:%s/api/nodes" % (host, port) async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(url) response.raise_for_status() nodes_data = response.json().get("nodes", []) children = [] for node in nodes_data: if node.get("children"): for child in node["children"]: child["proxy_id"] = proxy_name children.append(child) else: node["proxy_id"] = proxy_name children.append(node) return children except Exception: return [] async def fetch_nodes_from_supplier(url: str) -> list[dict]: """Fetch node information from node supplier. For each discovered node, attempt to retrieve its child nodes via ``/api/nodes`` (multiProcess lmcache server). If that call succeeds and returns children, those children are used as the ``nodes`` list (multiProcess mode). Otherwise the node itself is used as the sole child (inProcess / leaf mode). """ try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(url) response.raise_for_status() data = response.json() unique_nodes: dict[str, dict] = {} for api_address, info in data.get("processInfos", {}).items(): for entity in info.get("lmCacheInfoEntities", []): addr = entity["apiAddress"] if addr.startswith("http://"): addr = addr[7:] host, port = addr.split(":") node_key = "%s:%s" % (host, port) if node_key not in unique_nodes: unique_nodes[node_key] = { "host": host, "port": port, } # Fetch child nodes concurrently for all discovered nodes. proxy_list = [ { "name": "proxy_%s" % node_key.replace(":", "_"), "host": info["host"], "port": info["port"], } for node_key, info in unique_nodes.items() ] child_tasks = [ _fetch_child_nodes_from_api_nodes(p["host"], p["port"], p["name"]) for p in proxy_list ] child_results = await asyncio.gather(*child_tasks, return_exceptions=True) result = [] for proxy, children in zip(proxy_list, child_results, strict=False): if isinstance(children, Exception) or not children: # inProcess mode: node itself is the leaf target. leaf = { "name": proxy["name"], "host": proxy["host"], "port": proxy["port"], } nodes = [leaf] else: # multiProcess mode: use discovered child nodes. assert not isinstance(children, BaseException) nodes = children result.append( { "name": proxy["name"], "host": proxy["host"], "port": proxy["port"], "nodes": nodes, } ) return result except Exception as e: print(f"Failed to fetch nodes from supplier: {e}") return [] def load_config(config_path: str | None = None) -> None: """Load proxy/node list from a JSON config file. Args: config_path: Optional path to the JSON file. When ``None`` the packaged ``config.json`` is used. """ try: # Prioritize user-specified configuration file if config_path: with open(config_path, "r") as f: _node_registry.replace(json.load(f)) print( f"Loaded {len(target_nodes)} target nodes from specified path: " f"{config_path}" ) else: # Use package resource path as default configuration default_config_path = _package_resource_path("config.json") with open(default_config_path, "r") as f: _node_registry.replace(json.load(f)) print(f"Loaded default configuration with {len(target_nodes)} target nodes") except Exception as e: print(f"Failed to load configuration file: {e}") _node_registry.replace([]) def validate_node(node: dict, is_proxy: bool = False) -> bool: """Validate a single node configuration dict. Args: node: Candidate node dict. is_proxy: Reserved for future proxy-specific validation. Returns: True when ``node`` has the required ``name``/``host``/``port`` keys and (optionally) a string ``proxy_id``. """ if not isinstance(node, dict): return False required_keys = {"name", "host", "port"} if not required_keys.issubset(node.keys()): return False if "proxy_id" in node and node["proxy_id"]: if not isinstance(node["proxy_id"], str): return False return True def validate_nodes(nodes: list) -> bool: """Validate a list of node dicts; see :func:`validate_node`.""" if not isinstance(nodes, list): return False return all(validate_node(node) for node in nodes) @router.get("/api/nodes") async def get_all_nodes() -> dict: """Get all nodes in tree structure (proxies with their child nodes). Returns: ``{"nodes": [...]}`` where each element is a proxy dict whose ``children`` list contains the leaf nodes behind it. """ all_nodes = [] for proxy in target_nodes: # Create proxy node with children property proxy_node = { "id": f"proxy_{proxy['name']}", "name": proxy["name"], "host": proxy["host"], "port": proxy["port"], "is_proxy": True, "children": [], } # Add child nodes for node in proxy.get("nodes", []): proxy_node["children"].append( { "id": "node_%s" % node["name"], "name": node["name"], "host": node["host"], "port": node["port"], "is_proxy": False, "proxy_id": proxy["name"], } ) all_nodes.append(proxy_node) return {"nodes": all_nodes} @router.get("/api/proxies/{proxy_name}/refresh") async def refresh_proxy_nodes(proxy_name: str): """Refresh child nodes of a proxy""" proxy = next((p for p in target_nodes if p["name"] == proxy_name), None) if not proxy: raise HTTPException(status_code=404, detail="Proxy not found") try: child_nodes = await fetch_child_nodes_from_proxy(proxy) proxy["nodes"] = child_nodes return {"status": "success", "nodes": child_nodes} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/api/proxies") async def get_proxies(): """Get all proxy nodes (without child nodes)""" proxies = [ {"name": proxy["name"], "host": proxy["host"], "port": proxy["port"]} for proxy in target_nodes ] return {"proxies": proxies} @router.get("/api/proxies/{proxy_name}/nodes") async def get_proxy_nodes(proxy_name: str): """Get all nodes under specified proxy""" proxy = next((p for p in target_nodes if p["name"] == proxy_name), None) if not proxy: raise HTTPException(status_code=404, detail="Proxy not found") return {"nodes": proxy["nodes"]} # ==== Node Management Endpoints ==== @router.post("/api/proxies") async def add_proxy(request: Request): """Add a new proxy node""" try: new_proxy = await request.json() if not validate_node(new_proxy, is_proxy=True): raise HTTPException(status_code=400, detail="Invalid proxy format") # Check for duplicate names if any(proxy["name"] == new_proxy["name"] for proxy in target_nodes): raise HTTPException(status_code=409, detail="Proxy name already exists") # Ensure nodes field exists if "nodes" not in new_proxy: new_proxy["nodes"] = [] target_nodes.append(new_proxy) return {"status": "success", "message": "Proxy added"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/api/proxies/{proxy_name}/nodes") async def add_node_to_proxy(proxy_name: str, request: Request): """Add child node to proxy""" try: new_node = await request.json() if not validate_node(new_node): raise HTTPException(status_code=400, detail="Invalid node format") # Find corresponding proxy proxy = next((p for p in target_nodes if p["name"] == proxy_name), None) if not proxy: raise HTTPException(status_code=404, detail="Proxy not found") # Check for duplicate names if any(node["name"] == new_node["name"] for node in proxy["nodes"]): raise HTTPException(status_code=409, detail="Node name already exists") proxy["nodes"].append(new_node) return {"status": "success", "message": "Node added to proxy"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @router.put("/api/nodes/{node_name}") async def update_node(node_name: str, request: Request): """Update a proxy or child node identified by ``node_name``. Searches top-level proxies first, then children of every proxy, so both direct proxies and supplier-discovered leaf nodes can be updated through the same endpoint (mirrors ``proxy_request_by_name``). """ try: updated_node = await request.json() if not validate_node(updated_node): raise HTTPException(status_code=400, detail="Invalid node format") for i, node in enumerate(target_nodes): if node["name"] == node_name: # Preserve existing children when the caller did not # provide one; proxies are expected to keep ``nodes``. if "nodes" not in updated_node and "nodes" in node: updated_node["nodes"] = node["nodes"] target_nodes[i] = updated_node return {"status": "success", "message": "Node updated"} for proxy in target_nodes: children = proxy.get("nodes", []) for j, child in enumerate(children): if child["name"] == node_name: children[j] = updated_node return { "status": "success", "message": "Child node updated", } raise HTTPException(status_code=404, detail="Node not found") except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @router.delete("/api/nodes/{node_name}") async def delete_node(node_name: str): """Delete a proxy or child node by ``node_name``. Mirrors :func:`update_node`: matches top-level proxies first, falls back to scanning children of each proxy. """ try: original_count = len(target_nodes) _node_registry.replace( [node for node in target_nodes if node["name"] != node_name] ) if len(target_nodes) != original_count: return {"status": "success", "message": "Node deleted"} for proxy in target_nodes: children = proxy.get("nodes", []) for idx, child in enumerate(children): if child["name"] == node_name: del children[idx] return { "status": "success", "message": "Child node deleted", } raise HTTPException(status_code=404, detail="Node not found") except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @router.api_route( "/proxy2/{node_name}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], ) async def proxy_request_by_name(request: Request, node_name: str, path: str): """Proxy requests using node name as identifier. Searches top-level target_nodes first, then child nodes of every proxy, so both direct nodes and supplier-discovered leaf nodes are reachable with a single /proxy2/{name}/{path} call. """ # 1. top-level proxy nodes node = next((n for n in target_nodes if n["name"] == node_name), None) # 2. child nodes of every proxy if not node: for proxy in target_nodes: node = next( (n for n in proxy.get("nodes", []) if n["name"] == node_name), None, ) if node: break if not node: raise HTTPException( status_code=404, detail=f"Node with name '{node_name}' not found" ) # Use existing proxy_request logic return await proxy_request( request, target_host=node["host"], target_port_or_socket=node["port"], path=path ) @router.api_route( "/proxy/{target_host}/{target_port_or_socket}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], ) async def proxy_request( request: Request, target_host: str, target_port_or_socket: str | int, path: str ): """Proxy requests to the specified target host and port or socket path. For security, non-socket targets must match a host/port already registered in :data:`_node_registry`; this prevents the endpoint from being used as an open relay (SSRF). Socket paths are accepted as-is because they are local UDS endpoints. """ target_port_or_socket = unquote(str(target_port_or_socket)) # Check if target_port_or_socket is a socket path (contains '/') is_socket_path = "/" in target_port_or_socket if is_socket_path: # For socket paths, use UDS transport socket_path = target_port_or_socket target_url = f"http://localhost/{path}" # Create UDS transport transport = httpx.AsyncHTTPTransport(uds=socket_path) else: port = target_port_or_socket # SSRF guard: resolve against the registry and reuse the # trusted host/port from there when building the outbound URL. # This keeps user-controlled values out of the URL sink. resolved = _node_registry.resolve(target_host, port) if resolved is None: raise HTTPException( status_code=403, detail=("Target %s:%s is not a registered node" % (target_host, port)), ) safe_host, safe_port = resolved target_url = f"http://{safe_host}:{safe_port}/{path}" transport = None # Use default transport headers = {} for key, value in request.headers.items(): if key.lower() in [ "host", "content-length", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", ]: continue headers[key] = value body = await request.body() # Create client with appropriate transport async with httpx.AsyncClient(transport=transport) as client: try: response = await client.request( method=request.method, url=target_url, headers=headers, content=body, params=request.query_params, timeout=60.0, ) response_headers = {} for key, value in response.headers.items(): if key.lower() in [ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", ]: continue response_headers[key] = value return PlainTextResponse( content=response.content, headers=response_headers, media_type=response.headers.get("content-type", "text/plain"), status_code=response.status_code, ) except httpx.ConnectError as e: if is_socket_path: detail = f"Failed to connect to socket: {socket_path}" else: detail = f"Failed to connect to target service {target_host}:{port}" raise HTTPException(status_code=502, detail=detail) from e except httpx.TimeoutException as e: if is_socket_path: detail = f"Connection to socket {socket_path} timed out" else: detail = f"Connection to target service {target_host}:{port} timed out" raise HTTPException(status_code=504, detail=detail) from e except httpx.HTTPError as e: raise HTTPException( status_code=502, detail=f"Error communicating with target service: {str(e)}", ) from e except Exception as e: raise HTTPException(status_code=500, detail=f"Proxy error: {str(e)}") from e @router.get("/health") async def health_check(): return {"status": "healthy", "service": "lmcache-monitor"} @router.get("/api/heartbeat/status") async def get_heartbeat_status(): """Get heartbeat status""" return heartbeat_service.status() @router.post("/api/heartbeat/start") async def start_heartbeat_api(request: Request): """Start heartbeat service""" try: data = await request.json() heartbeat_url = data.get("heartbeat_url") initial_delay = data.get("initial_delay", 0) interval = data.get("interval", 30) if not heartbeat_url: raise HTTPException(status_code=400, detail="heartbeat_url is required") heartbeat_service.start(heartbeat_url, initial_delay, interval) return {"status": "success", "message": "Heartbeat service started"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/api/heartbeat/stop") async def stop_heartbeat_api(): """Stop heartbeat service""" try: heartbeat_service.stop() return {"status": "success", "message": "Heartbeat service stopped"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e async def load_nodes_from_supplier(node_supplier_url: str | None = None) -> bool: """Load node information from node supplier. Args: node_supplier_url: Discovery service URL, e.g. ``/lmcache_infos``. Returns: ``True`` when a non-empty node list was retrieved and stored. """ if not node_supplier_url: return False print(f"Fetching nodes from supplier: {node_supplier_url}") nodes = await fetch_nodes_from_supplier(node_supplier_url) if nodes: _node_registry.replace(nodes) print(f"Loaded {len(target_nodes)} proxy nodes from supplier") # Child nodes are already populated by fetch_nodes_from_supplier; # no secondary /api/nodes round-trip is needed. for proxy in target_nodes: print(f"Proxy {proxy['name']} loaded {len(proxy['nodes'])} child nodes") return True else: print("Warning: No nodes loaded from supplier") return False async def initialize_nodes(node_supplier_url: str | None = None) -> None: """Initialize node configuration from CLI args or supplier URL. Args: node_supplier_url: Optional discovery service URL. When set, nodes are loaded via :func:`load_nodes_from_supplier`. Otherwise falls back to ``args.nodes`` / ``args.config``. """ global args if args is None: raise ValueError("args is not initialized") if node_supplier_url: await load_nodes_from_supplier(node_supplier_url) elif args.nodes: try: nodes = json.loads(args.nodes) if validate_nodes(nodes): _node_registry.replace( [ { "name": "local_proxy", "host": args.host, "port": args.port, "nodes": nodes, } ] ) print(f"Loaded {len(nodes)} target nodes from command line arguments") except json.JSONDecodeError: print("Failed to parse nodes JSON parameter") elif args.config: load_config(args.config) # Minimum seconds between two supplier refreshes triggered by ``/``. # Each refresh fans out to every registered proxy's ``/api/nodes`` # endpoint, so an unthrottled ``/`` would DOS both the supplier and # every proxy. 30s matches the default heartbeat interval. _SUPPLIER_REFRESH_INTERVAL_SEC = 30.0 _supplier_last_refresh: float = 0.0 _supplier_refresh_lock = asyncio.Lock() async def _maybe_refresh_from_supplier(node_supplier_url: str) -> None: """Refresh the node registry from supplier, at most once per interval. The first caller within the interval performs the refresh; other concurrent callers return immediately (stale-on-read). This keeps the homepage responsive even under high traffic. """ global _supplier_last_refresh now = time.monotonic() if now - _supplier_last_refresh < _SUPPLIER_REFRESH_INTERVAL_SEC: return if _supplier_refresh_lock.locked(): return async with _supplier_refresh_lock: now = time.monotonic() if now - _supplier_last_refresh < _SUPPLIER_REFRESH_INTERVAL_SEC: return await initialize_nodes(node_supplier_url) _supplier_last_refresh = time.monotonic() @router.get("/") async def serve_frontend(): """Return frontend homepage. When a node supplier URL is configured, trigger a throttled background-style refresh so opening the homepage repeatedly does not hammer the supplier or every proxy's ``/api/nodes``. """ if args.node_supplier_url: await _maybe_refresh_from_supplier(args.node_supplier_url) try: # Use package resource path index_path = _package_resource_path("static/index.html") return FileResponse(index_path) except Exception: # Development environment uses local files return FileResponse("static/index.html") # Helper function to fetch metrics from a single node async def _fetch_node_metrics(node): """Fetch metrics from a single node""" try: # Check if port is a socket path is_socket_path = "/" in node["port"] if is_socket_path: # Use UDS transport for socket paths transport = httpx.AsyncHTTPTransport(uds=node["port"]) # Use localhost as host url = "http://localhost/metrics" async with httpx.AsyncClient(transport=transport, timeout=5.0) as client: response = await client.get(url) response.raise_for_status() return response.text else: # Build URL for regular port url = f"http://{node['host']}:{node['port']}/metrics" async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(url) response.raise_for_status() return response.text except Exception as e: return f"# ERROR: Failed to get metrics from {node['name']}: {str(e)}\n" @router.get("/metrics") async def aggregated_metrics(): """Aggregate /metrics from all leaf (child) nodes across every proxy. Previously only nodes under the ``local_proxy`` entry were aggregated, which silently returned nothing in supplier mode (where proxy names follow the ``proxy__`` pattern). Now every proxy's ``nodes`` list is flattened. """ if not target_nodes: return PlainTextResponse("# No nodes configured\n", status_code=404) nodes: list[dict] = [] seen: set[str] = set() for proxy in target_nodes: for child in proxy.get("nodes", []) or []: # De-duplicate by name so the same leaf reported via # multiple proxies is only scraped once. name = child.get("name") if name and name in seen: continue if name: seen.add(name) nodes.append(child) if not nodes: return PlainTextResponse( "# No nodes available for metrics collection\n", status_code=404 ) metrics_results = await asyncio.gather( *[_fetch_node_metrics(node) for node in nodes] ) # Combine all metrics with node name as comment header aggregated = "" for i, metrics in enumerate(metrics_results): node = nodes[i] aggregated += ( f"# Metrics from node: {node['name']} ({node['host']}:{node['port']})\n" ) aggregated += metrics aggregated += "\n\n" return PlainTextResponse(aggregated) def create_app(): """Create and configure FastAPI application""" app = FastAPI( title="Flexible Proxy Server", description="HTTP proxy service supporting specified target hosts and ports", ) app.include_router(router) # Get static file path (prefer package resources) try: static_path = _package_resource_path("static") except Exception: static_path = os.path.join(os.path.dirname(__file__), "static") # Mount static file service app.mount("/static", StaticFiles(directory=static_path), name="static") return app def main(): global args parser = argparse.ArgumentParser(description="LMCache Cluster Monitoring Tool") parser.add_argument( "--port", type=int, default=8000, help="Service port, default 8000" ) parser.add_argument( "--host", type=str, default="0.0.0.0", help="Bind host address, default 0.0.0.0" ) parser.add_argument( "--config", type=str, default=None, help="Specify configuration file path, default uses internal config.json", ) parser.add_argument( "--nodes", type=str, default=None, help="Directly specify target nodes as a JSON string. " 'Example: \'[{"name":"node1","host":"127.0.0.1","port":8001}]\'', ) parser.add_argument( "--heartbeat-url", type=str, default=None, help="Heartbeat service URL, e.g.: http://example.com/heartbeat", ) parser.add_argument( "--heartbeat-initial-delay", type=int, default=0, help="Initial delay before starting heartbeat (seconds), default 0", ) parser.add_argument( "--heartbeat-interval", type=int, default=30, help="Heartbeat interval (seconds), default 30", ) parser.add_argument( "--node-supplier-url", type=str, default=None, help="URL to fetch node information from, e.g.: http://example.com/lmcache_infos", ) parser.add_argument( "--log-level", type=str, default="warning", choices=["critical", "error", "warning", "warn", "info", "debug", "trace"], help="Uvicorn log level, default: warn", ) parser.add_argument( "--no-http", action="store_true", default=False, help="Disable HTTP server startup (heartbeat still runs)", ) args = parser.parse_args() # Initialize node configuration asyncio.run(initialize_nodes(args.node_supplier_url)) app = create_app() print(f"Monitoring service running at http://{args.host}:{args.port}") print(f"Node management: http://{args.host}:{args.port}/static/index.html") # Start heartbeat service if URL is configured if args.heartbeat_url: # Set application configuration for heartbeat service heartbeat_service.set_app_config(args.host, args.port, target_nodes) print("Starting heartbeat service...") print(f"Heartbeat URL: {args.heartbeat_url}") print(f"Initial delay: {args.heartbeat_initial_delay}s") print(f"Interval: {args.heartbeat_interval}s") print(f"API Address: http://{args.host}:{args.port}") print(f"Target nodes count: {len(target_nodes)}") heartbeat_service.start( args.heartbeat_url, args.heartbeat_initial_delay, args.heartbeat_interval ) else: print("Heartbeat URL not configured, heartbeat disabled") if args.no_http: print("HTTP server disabled (--no-http), running heartbeat only") try: stop_event = threading.Event() stop_event.wait() finally: print("Shutting down application...") heartbeat_service.stop() return try: uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level) finally: # Stop heartbeat service when app closes print("Shutting down application...") heartbeat_service.stop() if __name__ == "__main__": main()