add configurable cloud api url and subpath routing support for reverse proxies

This commit is contained in:
2026-09-19 12:00:48 +05:30
parent ce16210525
commit cc45a2f270
10 changed files with 106 additions and 50 deletions
+25 -19
View File
@@ -19,10 +19,14 @@ def get_http_client() -> httpx.AsyncClient:
) )
return _http_client return _http_client
def get_cloud_base_url() -> str:
url = getattr(settings, "cloud_api_url", None) or os.getenv("CLOUD_API_URL") or "https://services-api.a2zhealth.in/v1/contec-monitor"
return url.rstrip("/")
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool: async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
""" """
Attempts to POST the normalized vitals to the configured target API Attempts to POST the normalized vitals to the configured target API
and to the Render cloud database concurrently. and to the Cloud server concurrently.
Returns True if at least one forward succeeds. Returns True if at least one forward succeeds.
""" """
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -59,16 +63,17 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
logger.warning(f"Error forwarding to primary API: {type(e).__name__} ({err_msg})") logger.warning(f"Error forwarding to primary API: {type(e).__name__} ({err_msg})")
return False return False
# 2. Forward to Render Cloud Server # 2. Forward to Cloud Server
async def _forward_render() -> bool: async def _forward_cloud() -> bool:
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals" cloud_base = get_cloud_base_url()
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "") cloud_url = f"{cloud_base}/api/push-vitals"
if is_on_render: is_on_cloud = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_cloud and ("onrender.com" in cloud_base):
return True return True
try: try:
url_with_key = render_url url_with_key = cloud_url
if settings.api_token: if settings.api_token:
url_with_key = f"{render_url}?api_key={settings.api_token}" url_with_key = f"{cloud_url}?api_key={settings.api_token}"
response = await client.post( response = await client.post(
url_with_key, url_with_key,
@@ -76,28 +81,29 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
headers=headers headers=headers
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}") logger.info(f"Successfully forwarded vitals to Cloud API for device {vitals.device_id}")
return True return True
except Exception as e: except Exception as e:
err_msg = str(e).strip() or "timeout / connection reset" err_msg = str(e).strip() or "timeout / connection reset"
logger.warning(f"Error forwarding to Render Cloud: {type(e).__name__} ({err_msg})") logger.warning(f"Error forwarding to Cloud API: {type(e).__name__} ({err_msg})")
return False return False
results = await asyncio.gather(_forward_primary(), _forward_render(), return_exceptions=True) results = await asyncio.gather(_forward_primary(), _forward_cloud(), return_exceptions=True)
return any(r is True for r in results if not isinstance(r, Exception)) return any(r is True for r in results if not isinstance(r, Exception))
async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -> bool: async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -> bool:
""" """
Synchronizes patient demographics assignment to Render Cloud Server. Synchronizes patient demographics assignment to Cloud Server.
Ensures local and remote dashboards always show the exact same patient. Ensures local and remote dashboards always show the exact same patient.
""" """
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "") cloud_base = get_cloud_base_url()
if is_on_render: is_on_cloud = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_cloud and ("onrender.com" in cloud_base):
return True return True
client = get_http_client() client = get_http_client()
render_assign_url = "https://contectfiveparamonitor.onrender.com/api/patient/assign" cloud_assign_url = f"{cloud_base}/api/patient/assign"
payload = { payload = {
"device_id": device_id, "device_id": device_id,
"mrn": patient_data.get("mrn") or patient_data.get("patient_id") or "", "mrn": patient_data.get("mrn") or patient_data.get("patient_id") or "",
@@ -108,13 +114,13 @@ async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -
"patient_type": patient_data.get("patient_type") "patient_type": patient_data.get("patient_type")
} }
try: try:
url_with_key = render_assign_url url_with_key = cloud_assign_url
if settings.api_token: if settings.api_token:
url_with_key = f"{render_assign_url}?api_key={settings.api_token}" url_with_key = f"{cloud_assign_url}?api_key={settings.api_token}"
resp = await client.post(url_with_key, json=payload, headers={"Content-Type": "application/json"}) resp = await client.post(url_with_key, json=payload, headers={"Content-Type": "application/json"})
resp.raise_for_status() resp.raise_for_status()
logger.info(f"Successfully synced patient {payload['mrn']} ({payload['name']}) to Render Cloud for {device_id}") logger.info(f"Successfully synced patient {payload['mrn']} ({payload['name']}) to Cloud for {device_id}")
return True return True
except Exception as e: except Exception as e:
logger.warning(f"Could not sync patient assignment to Render Cloud: {type(e).__name__} ({e})") logger.warning(f"Could not sync patient assignment to Cloud: {type(e).__name__} ({e})")
return False return False
+2 -1
View File
@@ -7,7 +7,8 @@
"device_models": { "device_models": {
"192.168.100.120": "CMS7000PLUS" "192.168.100.120": "CMS7000PLUS"
}, },
"target_api_url": "https://innov-dev.beta.injomo.com/workflow.trigger/69b2712a7866bf86ca0060c3", "target_api_url": "https://services-api.a2zhealth.in/v1/contec-monitor",
"cloud_api_url": "https://services-api.a2zhealth.in/v1/contec-monitor",
"api_token": "", "api_token": "",
"retry_interval_seconds": 60, "retry_interval_seconds": 60,
"log_file": "hl7_forwarder.log", "log_file": "hl7_forwarder.log",
+2 -1
View File
@@ -9,7 +9,8 @@ class Settings(BaseSettings):
monitor_ip: str = "" monitor_ip: str = ""
monitor_model: str = "CMS7000PLUS" monitor_model: str = "CMS7000PLUS"
device_models: dict = {} device_models: dict = {}
target_api_url: str = "https://api.example.com/vitals" target_api_url: str = "https://services-api.a2zhealth.in/v1/contec-monitor"
cloud_api_url: str = "https://services-api.a2zhealth.in/v1/contec-monitor"
api_token: str = "" api_token: str = ""
retry_interval_seconds: int = 60 retry_interval_seconds: int = 60
log_file: str = "hl7_forwarder.log" log_file: str = "hl7_forwarder.log"
+25 -19
View File
@@ -19,10 +19,14 @@ def get_http_client() -> httpx.AsyncClient:
) )
return _http_client return _http_client
def get_cloud_base_url() -> str:
url = getattr(settings, "cloud_api_url", None) or os.getenv("CLOUD_API_URL") or "https://services-api.a2zhealth.in/v1/contec-monitor"
return url.rstrip("/")
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool: async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
""" """
Attempts to POST the normalized vitals to the configured target API Attempts to POST the normalized vitals to the configured target API
and to the Render cloud database concurrently. and to the Cloud server concurrently.
Returns True if at least one forward succeeds. Returns True if at least one forward succeeds.
""" """
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -59,16 +63,17 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
logger.warning(f"Error forwarding to primary API: {type(e).__name__} ({err_msg})") logger.warning(f"Error forwarding to primary API: {type(e).__name__} ({err_msg})")
return False return False
# 2. Forward to Render Cloud Server # 2. Forward to Cloud Server
async def _forward_render() -> bool: async def _forward_cloud() -> bool:
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals" cloud_base = get_cloud_base_url()
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "") cloud_url = f"{cloud_base}/api/push-vitals"
if is_on_render: is_on_cloud = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_cloud and ("onrender.com" in cloud_base):
return True return True
try: try:
url_with_key = render_url url_with_key = cloud_url
if settings.api_token: if settings.api_token:
url_with_key = f"{render_url}?api_key={settings.api_token}" url_with_key = f"{cloud_url}?api_key={settings.api_token}"
response = await client.post( response = await client.post(
url_with_key, url_with_key,
@@ -76,28 +81,29 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
headers=headers headers=headers
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}") logger.info(f"Successfully forwarded vitals to Cloud API for device {vitals.device_id}")
return True return True
except Exception as e: except Exception as e:
err_msg = str(e).strip() or "timeout / connection reset" err_msg = str(e).strip() or "timeout / connection reset"
logger.warning(f"Error forwarding to Render Cloud: {type(e).__name__} ({err_msg})") logger.warning(f"Error forwarding to Cloud API: {type(e).__name__} ({err_msg})")
return False return False
results = await asyncio.gather(_forward_primary(), _forward_render(), return_exceptions=True) results = await asyncio.gather(_forward_primary(), _forward_cloud(), return_exceptions=True)
return any(r is True for r in results if not isinstance(r, Exception)) return any(r is True for r in results if not isinstance(r, Exception))
async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -> bool: async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -> bool:
""" """
Synchronizes patient demographics assignment to Render Cloud Server. Synchronizes patient demographics assignment to Cloud Server.
Ensures local and remote dashboards always show the exact same patient. Ensures local and remote dashboards always show the exact same patient.
""" """
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "") cloud_base = get_cloud_base_url()
if is_on_render: is_on_cloud = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_cloud and ("onrender.com" in cloud_base):
return True return True
client = get_http_client() client = get_http_client()
render_assign_url = "https://contectfiveparamonitor.onrender.com/api/patient/assign" cloud_assign_url = f"{cloud_base}/api/patient/assign"
payload = { payload = {
"device_id": device_id, "device_id": device_id,
"mrn": patient_data.get("mrn") or patient_data.get("patient_id") or "", "mrn": patient_data.get("mrn") or patient_data.get("patient_id") or "",
@@ -108,13 +114,13 @@ async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -
"patient_type": patient_data.get("patient_type") "patient_type": patient_data.get("patient_type")
} }
try: try:
url_with_key = render_assign_url url_with_key = cloud_assign_url
if settings.api_token: if settings.api_token:
url_with_key = f"{render_assign_url}?api_key={settings.api_token}" url_with_key = f"{cloud_assign_url}?api_key={settings.api_token}"
resp = await client.post(url_with_key, json=payload, headers={"Content-Type": "application/json"}) resp = await client.post(url_with_key, json=payload, headers={"Content-Type": "application/json"})
resp.raise_for_status() resp.raise_for_status()
logger.info(f"Successfully synced patient {payload['mrn']} ({payload['name']}) to Render Cloud for {device_id}") logger.info(f"Successfully synced patient {payload['mrn']} ({payload['name']}) to Cloud for {device_id}")
return True return True
except Exception as e: except Exception as e:
logger.warning(f"Could not sync patient assignment to Render Cloud: {type(e).__name__} ({e})") logger.warning(f"Could not sync patient assignment to Cloud: {type(e).__name__} ({e})")
return False return False
+2 -1
View File
@@ -7,7 +7,8 @@
"device_models": { "device_models": {
"192.168.100.120": "CMS7000PLUS" "192.168.100.120": "CMS7000PLUS"
}, },
"target_api_url": "https://innov-dev.beta.injomo.com/workflow.trigger/69b2712a7866bf86ca0060c3", "target_api_url": "https://services-api.a2zhealth.in/v1/contec-monitor",
"cloud_api_url": "https://services-api.a2zhealth.in/v1/contec-monitor",
"api_token": "", "api_token": "",
"retry_interval_seconds": 60, "retry_interval_seconds": 60,
"log_file": "hl7_forwarder.log", "log_file": "hl7_forwarder.log",
+2 -1
View File
@@ -9,7 +9,8 @@ class Settings(BaseSettings):
monitor_ip: str = "" monitor_ip: str = ""
monitor_model: str = "CMS7000PLUS" monitor_model: str = "CMS7000PLUS"
device_models: dict = {} device_models: dict = {}
target_api_url: str = "https://api.example.com/vitals" target_api_url: str = "https://services-api.a2zhealth.in/v1/contec-monitor"
cloud_api_url: str = "https://services-api.a2zhealth.in/v1/contec-monitor"
api_token: str = "" api_token: str = ""
retry_interval_seconds: int = 60 retry_interval_seconds: int = 60
log_file: str = "hl7_forwarder.log" log_file: str = "hl7_forwarder.log"
+15 -2
View File
@@ -127,6 +127,7 @@ async def lifespan(app: FastAPI):
print(f" Contec Ports: {settings.contec_ports}") print(f" Contec Ports: {settings.contec_ports}")
print(f" Monitor Model: {model}") print(f" Monitor Model: {model}")
print(f" API Target: {settings.target_api_url}") print(f" API Target: {settings.target_api_url}")
print(f" Cloud Sync: {getattr(settings, 'cloud_api_url', settings.target_api_url)}")
print(f" Database: {settings.database_url}") print(f" Database: {settings.database_url}")
print(f" ---------------------------------------------------------") print(f" ---------------------------------------------------------")
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):") print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
@@ -196,22 +197,31 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Include dashboard routes # Include dashboard routes under multiple prefixes so reverse proxies (Nginx)
# work whether they strip /v1/contec-monitor or forward it as-is
app.include_router(dashboard.router, prefix="/api") app.include_router(dashboard.router, prefix="/api")
app.include_router(dashboard.router)
app.include_router(dashboard.router, prefix="/v1/contec-monitor/api")
app.include_router(dashboard.router, prefix="/v1/contec-monitor")
# Also serve dashboard at root and /dashboard directly for convenience # Also serve dashboard directly for convenience across various path styles
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@app.get("/dashboard", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse)
@app.get("/v1/contec-monitor", response_class=HTMLResponse)
@app.get("/v1/contec-monitor/dashboard", response_class=HTMLResponse)
@app.get("/v1/contec-monitor/api/dashboard", response_class=HTMLResponse)
def serve_dashboard_root(): def serve_dashboard_root():
return dashboard.serve_dashboard() return dashboard.serve_dashboard()
# Serve static folder # Serve static folder
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/v1/contec-monitor/static", StaticFiles(directory="static"), name="static_subpath")
from fastapi.responses import Response from fastapi.responses import Response
@app.get("/favicon.ico", include_in_schema=False) @app.get("/favicon.ico", include_in_schema=False)
@app.get("/v1/contec-monitor/favicon.ico", include_in_schema=False)
async def favicon(): async def favicon():
return Response(status_code=204, media_type="image/x-icon") return Response(status_code=204, media_type="image/x-icon")
@@ -220,6 +230,9 @@ async def favicon():
# === WebSocket endpoint for live vitals === # === WebSocket endpoint for live vitals ===
@app.websocket("/ws/vitals") @app.websocket("/ws/vitals")
@app.websocket("/api/ws/vitals")
@app.websocket("/v1/contec-monitor/ws/vitals")
@app.websocket("/v1/contec-monitor/api/ws/vitals")
async def websocket_vitals(websocket: WebSocket): async def websocket_vitals(websocket: WebSocket):
manager = get_ws_manager() manager = get_ws_manager()
await manager.connect(websocket) await manager.connect(websocket)
+9 -2
View File
@@ -3432,9 +3432,16 @@
} }
// === Configuration === // === Configuration ===
const API_BASE = window.location.origin + '/api'; function getAppPrefix() {
const p = window.location.pathname;
if (p.includes('/api/')) return p.substring(0, p.indexOf('/api/'));
if (p.includes('/dashboard')) return p.substring(0, p.indexOf('/dashboard'));
return '';
}
const APP_PREFIX = getAppPrefix();
const API_BASE = window.location.origin + APP_PREFIX + '/api';
const POLL_INTERVAL = 2000; // 2 seconds const POLL_INTERVAL = 2000; // 2 seconds
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/vitals`; const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${APP_PREFIX}/ws/vitals`;
// === Vital ranges for status calculation === // === Vital ranges for status calculation ===
const VITAL_RANGES = { const VITAL_RANGES = {
+15 -2
View File
@@ -127,6 +127,7 @@ async def lifespan(app: FastAPI):
print(f" Contec Ports: {settings.contec_ports}") print(f" Contec Ports: {settings.contec_ports}")
print(f" Monitor Model: {model}") print(f" Monitor Model: {model}")
print(f" API Target: {settings.target_api_url}") print(f" API Target: {settings.target_api_url}")
print(f" Cloud Sync: {getattr(settings, 'cloud_api_url', settings.target_api_url)}")
print(f" Database: {settings.database_url}") print(f" Database: {settings.database_url}")
print(f" ---------------------------------------------------------") print(f" ---------------------------------------------------------")
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):") print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
@@ -196,22 +197,31 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Include dashboard routes # Include dashboard routes under multiple prefixes so reverse proxies (Nginx)
# work whether they strip /v1/contec-monitor or forward it as-is
app.include_router(dashboard.router, prefix="/api") app.include_router(dashboard.router, prefix="/api")
app.include_router(dashboard.router)
app.include_router(dashboard.router, prefix="/v1/contec-monitor/api")
app.include_router(dashboard.router, prefix="/v1/contec-monitor")
# Also serve dashboard at root and /dashboard directly for convenience # Also serve dashboard directly for convenience across various path styles
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@app.get("/dashboard", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse)
@app.get("/v1/contec-monitor", response_class=HTMLResponse)
@app.get("/v1/contec-monitor/dashboard", response_class=HTMLResponse)
@app.get("/v1/contec-monitor/api/dashboard", response_class=HTMLResponse)
def serve_dashboard_root(): def serve_dashboard_root():
return dashboard.serve_dashboard() return dashboard.serve_dashboard()
# Serve static folder # Serve static folder
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/v1/contec-monitor/static", StaticFiles(directory="static"), name="static_subpath")
from fastapi.responses import Response from fastapi.responses import Response
@app.get("/favicon.ico", include_in_schema=False) @app.get("/favicon.ico", include_in_schema=False)
@app.get("/v1/contec-monitor/favicon.ico", include_in_schema=False)
async def favicon(): async def favicon():
return Response(status_code=204, media_type="image/x-icon") return Response(status_code=204, media_type="image/x-icon")
@@ -220,6 +230,9 @@ async def favicon():
# === WebSocket endpoint for live vitals === # === WebSocket endpoint for live vitals ===
@app.websocket("/ws/vitals") @app.websocket("/ws/vitals")
@app.websocket("/api/ws/vitals")
@app.websocket("/v1/contec-monitor/ws/vitals")
@app.websocket("/v1/contec-monitor/api/ws/vitals")
async def websocket_vitals(websocket: WebSocket): async def websocket_vitals(websocket: WebSocket):
manager = get_ws_manager() manager = get_ws_manager()
await manager.connect(websocket) await manager.connect(websocket)
+9 -2
View File
@@ -3432,9 +3432,16 @@
} }
// === Configuration === // === Configuration ===
const API_BASE = window.location.origin + '/api'; function getAppPrefix() {
const p = window.location.pathname;
if (p.includes('/api/')) return p.substring(0, p.indexOf('/api/'));
if (p.includes('/dashboard')) return p.substring(0, p.indexOf('/dashboard'));
return '';
}
const APP_PREFIX = getAppPrefix();
const API_BASE = window.location.origin + APP_PREFIX + '/api';
const POLL_INTERVAL = 2000; // 2 seconds const POLL_INTERVAL = 2000; // 2 seconds
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/vitals`; const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${APP_PREFIX}/ws/vitals`;
// === Vital ranges for status calculation === // === Vital ranges for status calculation ===
const VITAL_RANGES = { const VITAL_RANGES = {