diff --git a/.gitignore b/.gitignore index 8c26256e2..72089c9df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Active log configuration symlink (auto-created at startup) +python/pifinder_logconf.json +python/logconf_*.json + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/python/PiFinder/catalog_imports/database.py b/python/PiFinder/catalog_imports/database.py index 8814b1308..91b88cf2c 100644 --- a/python/PiFinder/catalog_imports/database.py +++ b/python/PiFinder/catalog_imports/database.py @@ -5,7 +5,11 @@ """ from typing import Optional -from .catalog_import_utils import init_databases, ObjectsDatabase, ObservationsDatabase + +from PiFinder.db.objects_db import ObjectsDatabase +from PiFinder.db.observations_db import ObservationsDatabase + +from .catalog_import_utils import init_databases # Global database objects shared across all catalog loaders objects_db: Optional[ObjectsDatabase] = None diff --git a/python/PiFinder/catalog_imports/harris_loader.py b/python/PiFinder/catalog_imports/harris_loader.py index 4148a95f8..72649f02d 100644 --- a/python/PiFinder/catalog_imports/harris_loader.py +++ b/python/PiFinder/catalog_imports/harris_loader.py @@ -386,6 +386,11 @@ def load_harris() -> None: logging.info("Loading Harris Globular Cluster catalog") catalog: str = "Har" obj_type: str = "Gb" # Globular Cluster + + if objects_db is None: + raise RuntimeError( + "Database not initialized. Call init_shared_database() first." + ) conn, _ = objects_db.get_conn_cursor() # Enable bulk mode to prevent commits during insert operations diff --git a/python/PiFinder/catalog_imports/lynga_loader.py b/python/PiFinder/catalog_imports/lynga_loader.py index 7c7af7b02..09f7df853 100644 --- a/python/PiFinder/catalog_imports/lynga_loader.py +++ b/python/PiFinder/catalog_imports/lynga_loader.py @@ -528,7 +528,8 @@ def create_cluster_object(entry: npt.NDArray, seq: int) -> Dict[str, Any]: return result -def load_lynga(): +def load_lynga() -> None: + assert objects_db is not None, "Database not initialized before load_lynga()" logging.info("Loading Lynga Open Cluster catalog") catalog: str = "Lyn" obj_type: str = "OC" # Open Cluster diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index fe1d4b579..c1f546ea9 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -873,25 +873,41 @@ def main( if __name__ == "__main__": + import sys + + # Ensure the active log config symlink exists, defaulting to logconf_default.json + _logconf_link = Path("pifinder_logconf.json") + if not _logconf_link.exists(): + _logconf_link.symlink_to("logconf_default.json") + + debug_no_file_logs = "--debug-no-file-logs" in sys.argv + if debug_no_file_logs: + os.environ["PIFINDER_DEBUG_NO_FILE_LOGS"] = "1" + print("Bootstrap logging configuration ...") logging.basicConfig(format="%(asctime)s BASIC %(name)s: %(levelname)s %(message)s") rlogger = logging.getLogger() - rlogger.setLevel(logging.INFO) - log_path = utils.data_dir / "pifinder.log" - try: - log_helper = MultiprocLogging( - Path("pifinder_logconf.json"), - log_path, - ) + rlogger.setLevel(logging.DEBUG if debug_no_file_logs else logging.INFO) + + if debug_no_file_logs: + log_helper = MultiprocLogging(Path("pifinder_logconf.json"), console_only=True) MultiprocLogging.configurer(log_helper.get_queue()) - except FileNotFoundError: - rlogger.warning( - "Cannot find log configuration file, proceeding with basic configuration." - ) - rlogger.warning("Logs will not be stored on disk, unless you use --log") - logging.getLogger("PIL.PngImagePlugin").setLevel(logging.WARNING) - logging.getLogger("tetra3.Tetra3").setLevel(logging.WARNING) - logging.getLogger("picamera2.picamera2").setLevel(logging.WARNING) + else: + log_path = utils.data_dir / "pifinder.log" + try: + log_helper = MultiprocLogging( + Path("pifinder_logconf.json"), + log_path, + ) + MultiprocLogging.configurer(log_helper.get_queue()) + except FileNotFoundError: + rlogger.warning( + "Cannot find log configuration file, proceeding with basic configuration." + ) + rlogger.warning("Logs will not be stored on disk, unless you use --log") + logging.getLogger("PIL.PngImagePlugin").setLevel(logging.WARNING) + logging.getLogger("tetra3.Tetra3").setLevel(logging.WARNING) + logging.getLogger("picamera2.picamera2").setLevel(logging.WARNING) rlogger.info("Starting PiFinder ...") parser = argparse.ArgumentParser(description="eFinder") @@ -953,6 +969,11 @@ def main( "-x", "--verbose", help="Set logging to debug mode", action="store_true" ) parser.add_argument("-l", "--log", help="Log to file", action="store_true") + parser.add_argument( + "--debug-no-file-logs", + help="Debug: log everything at DEBUG level to console only, bypassing log configuration and file output", + action="store_true", + ) parser.add_argument( "--lang", help="Force user interface language (iso2 code). Changes configuration", diff --git a/python/PiFinder/multiproclogging.py b/python/PiFinder/multiproclogging.py index 83b7f4376..46c780a12 100644 --- a/python/PiFinder/multiproclogging.py +++ b/python/PiFinder/multiproclogging.py @@ -67,14 +67,18 @@ def __init__( log_conf: Optional[Path] = None, out_file: Optional[Path] = None, formatter: str = "%(asctime)s %(processName)s-%(name)s:%(levelname)s:%(message)s", + console_only: bool = False, ): self._queues: List[Queue] = [] self._log_conf_file = log_conf self._log_output_file = out_file self._formatter = formatter + self._console_only = console_only self._proc: Optional[Process] = None self.apply_config() + if console_only: + logging.getLogger().setLevel(logging.DEBUG) def apply_config(self): if self._log_conf_file is not None: @@ -94,7 +98,7 @@ def start(self, initial_queue: Optional[Queue] = None): self._proc = Process( target=self._run_sink, args=( - self._log_output_file, + None if self._console_only else self._log_output_file, self._queues, ), ) @@ -109,7 +113,7 @@ def join(self): self._queues[0].put(None) self._proc.join() - def _run_sink(self, output: Path, queues: List[Queue]): + def _run_sink(self, output: Optional[Path], queues: List[Queue]): """ This is the process that consumes every log message (sink) @@ -124,15 +128,22 @@ def _run_sink(self, output: Path, queues: List[Queue]): for hdlr in hdlrs: rLogger.removeHandler(hdlr) - # Set maxBytes to 50MB (50 * 1024 * 1024 bytes) and keep 5 backup files - h = logging.handlers.RotatingFileHandler( - output, maxBytes=50 * 1024 * 1024, backupCount=5, encoding="utf-8" - ) + if output is None: + import sys + + h: logging.Handler = logging.StreamHandler(sys.stderr) + rLogger.setLevel(logging.DEBUG) + rLogger.warning("Starting logging process (console only, no file output)") + else: + # Set maxBytes to 50MB (50 * 1024 * 1024 bytes) and keep 5 backup files + h = logging.handlers.RotatingFileHandler( + output, maxBytes=50 * 1024 * 1024, backupCount=5, encoding="utf-8" + ) + rLogger.warning("Starting logging process") + rLogger.warning("Logging to %s", output) f = logging.Formatter(self._formatter) h.setFormatter(f) rLogger.addHandler(h) - rLogger.warning("Starting logging process") - rLogger.warning("Logging to %s", output) # import logging_tree # logging_tree.printout() @@ -172,6 +183,8 @@ def configurer(queue: Queue): This method needs to be called once in each process, so that log records get forwarded to the single process writing log messages. """ + import os + assert queue is not None, "You passed a None to configurer! You cannot do that" assert isinstance( queue, multiprocessing.queues.Queue @@ -182,8 +195,11 @@ def configurer(queue: Queue): config = json5.load(logconf) logging.config.dictConfig(config) - h = logging.handlers.QueueHandler(queue) root = logging.getLogger() + if os.environ.get("PIFINDER_DEBUG_NO_FILE_LOGS"): + root.setLevel(logging.DEBUG) + + h = logging.handlers.QueueHandler(queue) root.addHandler(h) def set_log_conf_file(self, config: Path) -> None: diff --git a/python/PiFinder/server.py b/python/PiFinder/server.py index 7e334a32e..af069031a 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -803,8 +803,8 @@ def stream_logs(): else: return {"logs": [], "position": position} except FileNotFoundError: - logger.error(f"Log file not found: {log_file}") - return {"logs": [], "position": 0} + logger.warning(f"Log file not found: {log_file}") + return {"logs": [], "position": 0, "file_not_found": True} except Exception as e: logger.error(f"Error streaming logs: {e}") @@ -845,6 +845,83 @@ def get_component_levels(): logging.error(f"Error reading log configuration: {e}") return {"status": "error", "message": str(e)} + @app.route("/logs/configs") + @auth_required + def list_log_configs(): + """Return all available logconf_*.json files with display names.""" + import glob + + configs = [] + active = ( + os.path.realpath("pifinder_logconf.json") + if os.path.exists("pifinder_logconf.json") + else None + ) + for path in sorted(glob.glob("logconf_*.json")): + stem = path[len("logconf_") : -len(".json")] + display = stem.replace("_", " ").title() + configs.append( + { + "file": path, + "name": display, + "active": os.path.realpath(path) == active, + } + ) + return {"configs": configs} + + @app.route("/logs/switch_config", method="post") + @auth_required + def switch_log_config(): + """Atomically repoint pifinder_logconf.json to the chosen config, then restart.""" + logconf_file = request.forms.get("logconf_file", "").strip() + if ( + not logconf_file + or not logconf_file.startswith("logconf_") + or not logconf_file.endswith(".json") + ): + return {"status": "error", "message": "Invalid log config file name"} + if not os.path.exists(logconf_file): + return { + "status": "error", + "message": f"Log config file not found: {logconf_file}", + } + try: + link = "pifinder_logconf.json" + tmp = link + ".tmp" + os.symlink(logconf_file, tmp) + os.replace(tmp, link) + logger.info("Switched log config to %s", logconf_file) + except Exception as e: + logger.error("Failed to switch log config: %s", e) + return {"status": "error", "message": str(e)} + return template("restart_pifinder") + + @app.route("/logs/upload_config", method="post") + @auth_required + def upload_log_config(): + """Upload a new logconf_*.json file.""" + upload = request.files.get("config_file") + if not upload: + return {"status": "error", "message": "No file provided"} + filename = upload.filename + if not filename.startswith("logconf_") or not filename.endswith(".json"): + return { + "status": "error", + "message": "File must be named logconf_.json", + } + if os.path.exists(filename): + return { + "status": "error", + "message": f"File already exists: {filename}", + } + try: + upload.save(filename, overwrite=False) + logger.info("Uploaded log config: %s", filename) + return {"status": "ok", "file": filename} + except Exception as e: + logger.error("Failed to save uploaded log config: %s", e) + return {"status": "error", "message": str(e)} + @app.route("/logs/download") @auth_required def download_logs(): diff --git a/python/PiFinder/sqm/save_sweep_metadata.py b/python/PiFinder/sqm/save_sweep_metadata.py index 518434a97..293131716 100644 --- a/python/PiFinder/sqm/save_sweep_metadata.py +++ b/python/PiFinder/sqm/save_sweep_metadata.py @@ -80,7 +80,8 @@ def save_sweep_metadata( # Noise floor estimation details (from NoiseFloorEstimator) if noise_floor_details is not None: metadata["noise_floor_estimator"] = { - k: v for k, v in noise_floor_details.items() + k: v + for k, v in noise_floor_details.items() if k != "request_zero_sec_sample" # Exclude internal flags } if camera_type is not None: diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index b37f3bcb2..d5426a020 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -65,7 +65,9 @@ def _load_calibration(self) -> bool: ) if not calibration_file.exists(): - logger.debug(f"No calibration file found at {calibration_file}, using defaults") + logger.debug( + f"No calibration file found at {calibration_file}, using defaults" + ) return False try: @@ -204,7 +206,6 @@ def _measure_star_flux_with_local_background( n_saturated += 1 continue - # Total flux in aperture (includes background) total_flux = np.sum(aperture_pixels) diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index d1a985d36..52c3bad7a 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -1122,7 +1122,10 @@ def _(key: str) -> Any: "custom_callback": callbacks.set_time, }, {"name": _("Reset Location"), "callback": callbacks.gps_reset}, - {"name": _("Reset Time/Date"), "callback": callbacks.datetime_reset}, + { + "name": _("Reset Time/Date"), + "callback": callbacks.datetime_reset, + }, ], }, {"name": _("Console"), "class": UIConsole}, diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index 6ce73240b..fe797b23b 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -364,19 +364,18 @@ def create_shortname_text(self, obj: CompositeObject) -> str: if obj.catalog_code == "PL" and obj.names: planet_abbrevs = { "Mercury": "MER", - "Venus": "VEN", - "Moon": "MON", - "Mars": "MAR", + "Venus": "VEN", + "Moon": "MON", + "Mars": "MAR", "Jupiter": "JUP", - "Saturn": "SAT", - "Uranus": "URA", + "Saturn": "SAT", + "Uranus": "URA", "Neptune": "NEP", - "Pluto": "PLU", + "Pluto": "PLU", } return planet_abbrevs.get(obj.names[0], obj.names[0]) return f"{obj.catalog_code}{obj.sequence}" - def create_locate_text(self, obj: CompositeObject) -> str: az, alt = aim_degrees( self.shared_state, self.mount_type, self.screen_direction, obj diff --git a/python/PiFinder/ui/sqm_sweep.py b/python/PiFinder/ui/sqm_sweep.py index 56e8310b2..277e47b7b 100644 --- a/python/PiFinder/ui/sqm_sweep.py +++ b/python/PiFinder/ui/sqm_sweep.py @@ -288,7 +288,8 @@ def _add_detailed_metadata(self): # Find the sweep directory captures_dir = Path(utils.data_dir) / "captures" sweep_dirs = [ - d for d in captures_dir.glob("sweep_*") + d + for d in captures_dir.glob("sweep_*") if d.stat().st_ctime >= (self.start_time - 1) ] if not sweep_dirs: @@ -313,7 +314,8 @@ def _add_detailed_metadata(self): "pifinder_value": sqm_state.value, "reference_value": self.reference_sqm, "difference": (self.reference_sqm - sqm_state.value) - if self.reference_sqm and sqm_state.value else None, + if self.reference_sqm and sqm_state.value + else None, "source": sqm_state.source, } @@ -327,7 +329,8 @@ def _add_detailed_metadata(self): if image_metadata: metadata["image"] = { "exposure_us": image_metadata.get("exposure_time"), - "exposure_sec": image_metadata.get("exposure_time", 0) / 1_000_000.0, + "exposure_sec": image_metadata.get("exposure_time", 0) + / 1_000_000.0, "gain": image_metadata.get("gain"), "imu_delta": image_metadata.get("imu_delta"), } @@ -348,8 +351,11 @@ def _add_detailed_metadata(self): # Add NoiseFloorEstimator output camera_type = self.shared_state.camera_type() camera_type_processed = f"{camera_type}_processed" - exposure_sec = (image_metadata.get("exposure_time", 500000) / 1_000_000.0 - if image_metadata else 0.5) + exposure_sec = ( + image_metadata.get("exposure_time", 500000) / 1_000_000.0 + if image_metadata + else 0.5 + ) if self.camera_image is not None: image_array = np.array(self.camera_image.convert("L")) diff --git a/python/PiFinder/ui/textentry.py b/python/PiFinder/ui/textentry.py index aab62d65b..75b9b7a8e 100644 --- a/python/PiFinder/ui/textentry.py +++ b/python/PiFinder/ui/textentry.py @@ -1,4 +1,5 @@ from PIL import Image, ImageDraw +from PiFinder.composite_object import CompositeObject from PiFinder.ui.base import UIModule from PiFinder.db.objects_db import ObjectsDatabase from PiFinder.ui.object_list import UIObjectList @@ -6,7 +7,7 @@ import time import threading from typing import Any, List, TYPE_CHECKING -from PiFinder.composite_object import CompositeObject +import logging if TYPE_CHECKING: @@ -232,8 +233,6 @@ def update_search_results(self): Debounced async search - waits 250ms after last keystroke before searching. Only updates search results in search mode. """ - import logging - logger = logging.getLogger("TextEntry") if self.text_entry_mode: @@ -272,8 +271,6 @@ def _perform_search(self, search_text, search_version): Perform the actual search in background thread. Only updates results if this search version is still current. """ - import logging - logger = logging.getLogger("TextEntry") try: diff --git a/python/logconf_debug.json b/python/logconf_debug.json new file mode 100644 index 000000000..bb47960c2 --- /dev/null +++ b/python/logconf_debug.json @@ -0,0 +1,151 @@ +{ + // Note that the JSON5 library that we use for configuration supports comments in JSON files. + "version": 1, + "disable_existing_loggers": false, // THIS MUST BE FALSE for logging to work + "formatters": { + "default": { + "format": "%(asctime)s %(processName)s-%(name)s:%(levelname)s:%(message)s" + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "default", + "stream": "ext://sys.stdout" + }, + // Used for GRPC, avoids a "No handlers can be found" warning. + "null": { + "class": "logging.NullHandler", + "level": "ERROR" + } + }, + "loggers": { + + ///////////////////////////////////////////////////////////////// + ////// root logger + // + "": { + "level": "DEBUG", + "handlers": ["console"] + // The file handler is added automatically by code + }, + + ///////////////////////////////////////////////////////////////// + ////// State shared between Subsystems + // + "SharedState": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// User Interface + // + "UI": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Camera Subsystem + // + "Camera": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Platesolver Subsystem + // + "Solver": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// GPS Subsystem + // + "GPS": { + "level": "DEBUG" + }, + "GPS.parser": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Catalog Subsystem + // + "Catalog": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Database + // + "Database": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// IMU Subsystem + // + "IMU": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Keyboard Subsystem + // + "Keyboard": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Observations Subsystem + // + "Observation": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Web Server Subsystem + // + "Server": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Pos Server Subsystem (SkySafari LX200 Interface) + // + "PosServer": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Utils Libraries + // + "SysUtils": { + "level": "DEBUG" + }, + "Utils": { + "level": "DEBUG" + }, + + ///////////////////////////////////////////////////////////////// + ////// Third party Libraries + // + // Keep these suppressed to avoid noise. + // + "PIL.PngImagePlugin": { + "level": "WARNING" + }, + "tetra3.Tetra3": { + "level": "WARNING" + }, + "picamera2.picamera2": { + "level": "WARNING" + }, + "grpc": { + "level": "ERROR", + "propagate": false, + "handlers": ["null"] + }, + } +} diff --git a/python/pifinder_logconf.json b/python/pifinder_logconf.json deleted file mode 120000 index cb17ea4b3..000000000 --- a/python/pifinder_logconf.json +++ /dev/null @@ -1 +0,0 @@ -logconf_default.json \ No newline at end of file diff --git a/python/views/logs.tpl b/python/views/logs.tpl index c8c0cf957..64a997752 100644 --- a/python/views/logs.tpl +++ b/python/views/logs.tpl @@ -122,20 +122,12 @@ - - - +
@@ -164,17 +156,41 @@ const LINE_HEIGHT = 20; let updateInterval; let lastLine = ''; +// Backoff state for when the log file is not yet available. +// This page intentionally does NOT fetch the home route, so iwgetid is never triggered here. +const MIN_POLL_INTERVAL = 1000; +const MAX_POLL_INTERVAL = 10000; +let currentPollInterval = MIN_POLL_INTERVAL; + +function scheduleFetch() { + clearInterval(updateInterval); + updateInterval = setInterval(fetchLogs, currentPollInterval); +} + function fetchLogs() { if (isPaused) return; - + fetch(`/logs/stream?position=${currentPosition}`) .then(response => response.json()) .then(data => { + if (data.file_not_found) { + // Back off exponentially up to MAX_POLL_INTERVAL + currentPollInterval = Math.min(currentPollInterval * 2, MAX_POLL_INTERVAL); + scheduleFetch(); + return; + } + + // Reset backoff on a successful response + if (currentPollInterval !== MIN_POLL_INTERVAL) { + currentPollInterval = MIN_POLL_INTERVAL; + scheduleFetch(); + } + if (!data.logs || data.logs.length === 0) return; - + currentPosition = data.position; const logContent = document.getElementById('logContent'); - + // Add new logs to buffer, skipping duplicates data.logs.forEach(line => { if (line !== lastLine) { @@ -182,12 +198,12 @@ function fetchLogs() { lastLine = line; } }); - + // Trim buffer if it exceeds size if (logBuffer.length > BUFFER_SIZE) { logBuffer = logBuffer.slice(-BUFFER_SIZE); } - + // Update display updateLogDisplay(); }) @@ -239,8 +255,10 @@ function restartFromEnd() { currentPosition = 0; logBuffer = []; isPaused = false; + currentPollInterval = MIN_POLL_INTERVAL; document.getElementById('pauseButton').textContent = 'Pause'; fetchLogs(); + scheduleFetch(); } // Start fetching logs when page loads @@ -251,7 +269,7 @@ document.addEventListener('DOMContentLoaded', () => { // Start log fetching fetchLogs(); - updateInterval = setInterval(fetchLogs, 1000); + scheduleFetch(); // Hide loading message after first logs appear const observer = new MutationObserver((mutations) => { @@ -299,94 +317,69 @@ document.getElementById('copyButton').addEventListener('click', function() { }); }); -// Log level management -function updateComponentLevels() { - fetch('/logs/components') +// Log configuration management +function loadLogConfigs() { + fetch('/logs/configs') .then(response => response.json()) .then(data => { - const componentSelect = document.getElementById('componentSelect'); - componentSelect.innerHTML = ''; - - // Sort components alphabetically - const sortedComponents = Object.entries(data.components).sort(([a], [b]) => a.localeCompare(b)); - - sortedComponents.forEach(([component, levels]) => { + const configSelect = document.getElementById('configSelect'); + configSelect.innerHTML = ''; + data.configs.forEach(cfg => { const option = document.createElement('option'); - option.value = component; - option.textContent = component; - componentSelect.appendChild(option); + option.value = cfg.file; + option.textContent = cfg.name; + if (cfg.active) option.selected = true; + configSelect.appendChild(option); }); }) - .catch(error => console.error('Error fetching component levels:', error)); + .catch(error => console.error('Error fetching log configs:', error)); } -// Handle global level change -document.getElementById('globalLevel').addEventListener('change', function(e) { - const newLevel = e.target.value; - fetch('/logs/level', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: `level=${encodeURIComponent(newLevel)}` - }) - .then(response => response.json()) - .then(result => { - if (result.status === 'success') { - console.log(`Changed global log level to ${newLevel}`); - } else { - console.error('Failed to update global log level:', result.message); - } - }) - .catch(error => console.error('Error updating global log level:', error)); +document.getElementById('configSelect').addEventListener('change', function(e) { + const configFile = e.target.value; + if (!configFile) return; + if (!confirm(`Switch log configuration to "${e.target.options[e.target.selectedIndex].text}" and restart PiFinder?`)) { + loadLogConfigs(); // reset selection + return; + } + // Use a real form POST so the browser navigates to the restart page HTML + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/logs/switch_config'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'logconf_file'; + input.value = configFile; + form.appendChild(input); + document.body.appendChild(form); + form.submit(); }); -// Handle component selection -document.getElementById('componentSelect').addEventListener('change', function(e) { - const component = e.target.value; - if (!component) { - document.getElementById('componentLevel').style.display = 'none'; +document.getElementById('uploadLogConfInput').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + if (!file.name.startsWith('logconf_') || !file.name.endsWith('.json')) { + alert('File must be named logconf_.json'); + e.target.value = ''; return; } - - // Show level select and set current level - const levelSelect = document.getElementById('componentLevel'); - levelSelect.style.display = 'block'; - - // Get current level for selected component - fetch('/logs/components') + const formData = new FormData(); + formData.append('config_file', file); + fetch('/logs/upload_config', { method: 'POST', body: formData }) .then(response => response.json()) - .then(data => { - const currentLevel = data.components[component].current_level; - levelSelect.value = currentLevel; - }); -}); - -// Handle component level change -document.getElementById('componentLevel').addEventListener('change', function(e) { - const component = document.getElementById('componentSelect').value; - const newLevel = e.target.value; - - fetch('/logs/component_level', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: `component=${encodeURIComponent(component)}&level=${encodeURIComponent(newLevel)}` - }) - .then(response => response.json()) - .then(result => { - if (result.status === 'success') { - console.log(`Changed ${component} log level to ${newLevel}`); - } else { - console.error('Failed to update log level:', result.message); - } - }) - .catch(error => console.error('Error updating log level:', error)); + .then(result => { + if (result.status === 'ok') { + loadLogConfigs(); + } else { + alert('Upload failed: ' + result.message); + } + }) + .catch(error => console.error('Error uploading log config:', error)); + e.target.value = ''; }); -// Initial load of components -updateComponentLevels(); +// Initial load of configs +loadLogConfigs(); // Set up button event listeners document.getElementById('pauseButton').addEventListener('click', function() {