From b7663d5a313f92d1279a379d069090a40e986374 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 29 Jan 2026 21:08:17 +0000 Subject: [PATCH] Add idle auto-shutdown for self-hosted deployment - IDLE_TIMEOUT_MINUTES env var to configure shutdown after inactivity - Background thread monitors activity and exits when timeout reached - Activity tracked via simulation_display fragment (runs while page open) - Set to 0 (default) to disable auto-shutdown --- src/py_dvt_ate/app/dashboard/app.py | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/py_dvt_ate/app/dashboard/app.py b/src/py_dvt_ate/app/dashboard/app.py index 38abcc8..f8f099a 100644 --- a/src/py_dvt_ate/app/dashboard/app.py +++ b/src/py_dvt_ate/app/dashboard/app.py @@ -7,6 +7,8 @@ thermal-electrical coupling in real-time using instrument interfaces. import asyncio import atexit +import os +import sys import threading import time from collections import deque @@ -27,6 +29,41 @@ from py_dvt_ate.tests.thermal.tempco import TempCoTest # Thread pool for background test execution _test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner") +# Idle shutdown configuration +# Set IDLE_TIMEOUT_MINUTES=0 to disable auto-shutdown +IDLE_TIMEOUT_MINUTES = int(os.environ.get("IDLE_TIMEOUT_MINUTES", "0")) +_last_activity_time: float = time.time() +_idle_monitor_started = False + + +def _update_activity() -> None: + """Update the last activity timestamp.""" + global _last_activity_time + _last_activity_time = time.time() + + +def _idle_monitor() -> None: + """Background thread that exits the app after idle timeout.""" + global _last_activity_time + while True: + time.sleep(60) # Check every minute + if IDLE_TIMEOUT_MINUTES <= 0: + continue + idle_minutes = (time.time() - _last_activity_time) / 60 + if idle_minutes >= IDLE_TIMEOUT_MINUTES: + print(f"Idle timeout reached ({IDLE_TIMEOUT_MINUTES} minutes). Shutting down.") + os._exit(0) + + +def _start_idle_monitor() -> None: + """Start the idle monitor thread if timeout is configured.""" + global _idle_monitor_started + if IDLE_TIMEOUT_MINUTES > 0 and not _idle_monitor_started: + _idle_monitor_started = True + thread = threading.Thread(target=_idle_monitor, daemon=True) + thread.start() + print(f"Idle auto-shutdown enabled: {IDLE_TIMEOUT_MINUTES} minutes") + # History buffer size for charts HISTORY_SIZE = 500 @@ -124,6 +161,10 @@ def start_embedded_server() -> tuple[SimulationServer, threading.Thread]: def init_session_state() -> None: """Initialise Streamlit session state.""" + # Start idle monitor for auto-shutdown + _start_idle_monitor() + _update_activity() + if "server" not in st.session_state: with st.spinner("Starting simulation server..."): st.session_state.server, st.session_state.server_thread = start_embedded_server() @@ -386,6 +427,8 @@ def display_controls() -> None: @st.fragment(run_every=0.1) def simulation_display() -> None: """Fragment that displays and updates simulation state.""" + _update_activity() # Track that someone is viewing the dashboard + if "server" not in st.session_state: st.warning("Initializing simulation server...") return