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
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:
"""
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.
"""
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})")
return False
# 2. Forward to Render Cloud Server
async def _forward_render() -> bool:
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_render:
# 2. Forward to Cloud Server
async def _forward_cloud() -> bool:
cloud_base = get_cloud_base_url()
cloud_url = f"{cloud_base}/api/push-vitals"
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
try:
url_with_key = render_url
url_with_key = cloud_url
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(
url_with_key,
@@ -76,28 +81,29 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
headers=headers
)
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
except Exception as e:
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
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))
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.
"""
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_render:
cloud_base = get_cloud_base_url()
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
client = get_http_client()
render_assign_url = "https://contectfiveparamonitor.onrender.com/api/patient/assign"
cloud_assign_url = f"{cloud_base}/api/patient/assign"
payload = {
"device_id": device_id,
"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")
}
try:
url_with_key = render_assign_url
url_with_key = cloud_assign_url
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.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
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
+2 -1
View File
@@ -7,7 +7,8 @@
"device_models": {
"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": "",
"retry_interval_seconds": 60,
"log_file": "hl7_forwarder.log",
+2 -1
View File
@@ -9,7 +9,8 @@ class Settings(BaseSettings):
monitor_ip: str = ""
monitor_model: str = "CMS7000PLUS"
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 = ""
retry_interval_seconds: int = 60
log_file: str = "hl7_forwarder.log"
+25 -19
View File
@@ -19,10 +19,14 @@ def get_http_client() -> httpx.AsyncClient:
)
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:
"""
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.
"""
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})")
return False
# 2. Forward to Render Cloud Server
async def _forward_render() -> bool:
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_render:
# 2. Forward to Cloud Server
async def _forward_cloud() -> bool:
cloud_base = get_cloud_base_url()
cloud_url = f"{cloud_base}/api/push-vitals"
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
try:
url_with_key = render_url
url_with_key = cloud_url
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(
url_with_key,
@@ -76,28 +81,29 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
headers=headers
)
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
except Exception as e:
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
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))
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.
"""
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if is_on_render:
cloud_base = get_cloud_base_url()
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
client = get_http_client()
render_assign_url = "https://contectfiveparamonitor.onrender.com/api/patient/assign"
cloud_assign_url = f"{cloud_base}/api/patient/assign"
payload = {
"device_id": device_id,
"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")
}
try:
url_with_key = render_assign_url
url_with_key = cloud_assign_url
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.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
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
+2 -1
View File
@@ -7,7 +7,8 @@
"device_models": {
"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": "",
"retry_interval_seconds": 60,
"log_file": "hl7_forwarder.log",
+2 -1
View File
@@ -9,7 +9,8 @@ class Settings(BaseSettings):
monitor_ip: str = ""
monitor_model: str = "CMS7000PLUS"
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 = ""
retry_interval_seconds: int = 60
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" Monitor Model: {model}")
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" ---------------------------------------------------------")
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
@@ -196,22 +197,31 @@ app.add_middleware(
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)
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
@app.get("/", 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():
return dashboard.serve_dashboard()
# Serve static folder
from fastapi.staticfiles import StaticFiles
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
@app.get("/favicon.ico", include_in_schema=False)
@app.get("/v1/contec-monitor/favicon.ico", include_in_schema=False)
async def favicon():
return Response(status_code=204, media_type="image/x-icon")
@@ -220,6 +230,9 @@ async def favicon():
# === WebSocket endpoint for live 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):
manager = get_ws_manager()
await manager.connect(websocket)
+9 -2
View File
@@ -3432,9 +3432,16 @@
}
// === 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 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 ===
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" Monitor Model: {model}")
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" ---------------------------------------------------------")
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
@@ -196,22 +197,31 @@ app.add_middleware(
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)
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
@app.get("/", 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():
return dashboard.serve_dashboard()
# Serve static folder
from fastapi.staticfiles import StaticFiles
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
@app.get("/favicon.ico", include_in_schema=False)
@app.get("/v1/contec-monitor/favicon.ico", include_in_schema=False)
async def favicon():
return Response(status_code=204, media_type="image/x-icon")
@@ -220,6 +230,9 @@ async def favicon():
# === WebSocket endpoint for live 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):
manager = get_ws_manager()
await manager.connect(websocket)
+9 -2
View File
@@ -3432,9 +3432,16 @@
}
// === 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 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 ===
const VITAL_RANGES = {