#!/usr/bin/env seiscomp-python from getopt import getopt, GetoptError from time import time, gmtime from datetime import datetime import os import sys import signal import glob import re import json from seiscomp.myconfig import MyConfig import seiscomp.slclient import seiscomp.kernel, seiscomp.config from urllib.request import urlopen # A dictionary to store station coordinates station_coordinates = {} def load_station_coordinates(config): """Load station coordinates from FDSN web service""" global station_coordinates # Get base URL from config or use default base_url = config['setup'].get('fdsnws_url', 'http://localhost:8080/fdsnws/') # Create a dictionary in the format needed by data_fetcher stations_config = {} for key in config.station: network = config.station[key]['net'] station = config.station[key]['sta'] station_id = f"{network}.{station}" stations_config[station_id] = { 'network': network, 'station': station, 'location': '', # Default location 'stream': 'HHZ' # Default stream } # Fetch coordinates for each station for station_id, station_info in stations_config.items(): network = station_info['network'] station = station_info['station'] try: with urlopen(base_url + f"station/1/query?net={network}&sta={station}&format=text") as fp: fp.readline() location_info = dict(zip(('lat', 'lon', 'elevation'), map(float, fp.readline().split(b'|')[2:5]))) if location_info: station_coordinates[f"{network}_{station}"] = location_info print(f"Loaded coordinates for {network}_{station}: {location_info}") else: print(f"Could not fetch coordinates for {network}_{station}") except Exception as e: print(f"Error fetching coordinates for {network}_{station}: {str(e)}") # Print summary print(f"Loaded coordinates for {len(station_coordinates)} stations") usage_info = """ Usage: slmon [options] Enhanced SeedLink monitor creating modern, interactive web dashboards Options: -h, --help display this help message -c ini_setup = arg -s ini_stations = arg -t refresh = float(arg) # XXX not yet used -v verbose = 1 -g, --generate generate only template files and exit Examples: Start slmon from the command line slmon -c $SEISCOMP_ROOT/var/lib/slmon/config.ini Restart slmon in order to update the web pages. Use crontab entries for automatic restart, e.g.: */3 * * * * /home/sysop/seiscomp/bin/seiscomp check slmon >/dev/null 2>&1 """ def usage(exitcode=0): sys.stderr.write(usage_info) exit(exitcode) try: seiscompRoot = os.environ["SEISCOMP_ROOT"] except: print("\nSEISCOMP_ROOT must be defined - EXIT\n", file=sys.stderr) usage(exitcode=2) ini_stations = os.path.join(seiscompRoot, 'var/lib/slmon2/stations.ini') ini_setup = os.path.join(seiscompRoot, 'var/lib/slmon2/config.ini') regexStreams = re.compile("[SLBVEH][HNLGD][ZNE123ADHF]") verbose = 0 generate_only = False class Module(seiscomp.kernel.Module): def __init__(self, env): seiscomp.kernel.Module.__init__(self, env, env.moduleName(__file__)) def printCrontab(self): print("3 * * * * %s/bin/seiscomp check slmon >/dev/null 2>&1" % (self.env.SEISCOMP_ROOT)) class Status: def __repr__(self): return "%2s %-5s %2s %3s %1s %s %s" % \ (self.net, self.sta, self.loc, self.cha, self.typ, str(self.last_data), str(self.last_feed)) class StatusDict(dict): def __init__(self, source=None): if source: self.read(source) def fromSlinkTool(self, server="", stations=["AU_ARMA", "AU_BLDU", "AU_YAPP"]): # later this shall use XML cmd = "slinktool -nd 10 -nt 10 -Q %s" % server print(cmd) f = os.popen(cmd) # regex = re.compile("[SLBVEH][HNLG][ZNE123]") regex = regexStreams for line in f: net_sta = line[:2].strip() + "_" + line[3:8].strip() if not net_sta in stations: continue typ = line[16] if typ != "D": continue cha = line[12:15].strip() if not regex.match(cha): continue d = Status() d.net = line[0: 2].strip() d.sta = line[3: 8].strip() d.loc = line[9:11].strip() d.cha = line[12:15] d.typ = line[16] d.last_data = seiscomp.slclient.timeparse(line[47:70]) d.last_feed = d.last_data sec = "%s_%s" % (d.net, d.sta) sec = "%s.%s.%s.%s.%c" % (d.net, d.sta, d.loc, d.cha, d.typ) self[sec] = d def read(self, source): """ Read status data from various source types (file path, file object, or list of lines) Python 3 compatible version """ lines = [] # Handle different source types if isinstance(source, str): # String - treat as file path with open(source, 'r', encoding='utf-8') as f: lines = f.readlines() elif hasattr(source, 'readlines'): # File-like object lines = source.readlines() elif isinstance(source, list): # Already a list of lines lines = source else: raise TypeError(f'Cannot read from {type(source).__name__}') # Process each line for line in lines: line = str(line).rstrip('\n\r') # Skip lines that are too short if len(line) < 65: continue # Create status object and parse fields d = Status() d.net = line[0:2].strip() d.sta = line[3:8].strip() d.loc = line[9:11].strip() d.cha = line[12:15].strip() d.typ = line[16] # Parse timestamps with error handling try: d.last_data = seiscomp.slclient.timeparse(line[18:41]) except: d.last_data = None try: d.last_feed = seiscomp.slclient.timeparse(line[42:65]) except: d.last_feed = None # Ensure last_feed is not earlier than last_data if d.last_feed and d.last_data and d.last_feed < d.last_data: d.last_feed = d.last_data # Create dictionary key and store sec = f"{d.net}_{d.sta}:{d.loc}.{d.cha}.{d.typ}" self[sec] = d def write(self, f): """ Write status data to file or file-like object Python 3 compatible version """ should_close = False if isinstance(f, str): # String - treat as file path f = open(f, "w", encoding='utf-8') should_close = True try: # Prepare and write sorted lines lines = [str(self[key]) for key in sorted(self.keys())] f.write('\n'.join(lines) + '\n') finally: if should_close: f.close() def to_json(self): """Convert status dictionary to JSON for JavaScript use""" global station_coordinates stations_data = {} # Group by network and station for key, value in self.items(): net_sta = f"{value.net}_{value.sta}" if net_sta not in stations_data: stations_data[net_sta] = { "network": value.net, "station": value.sta, "channels": [], "channelGroups": { "HH": [], # High-frequency, High-gain channels "BH": [], # Broadband, High-gain channels "LH": [], # Long-period, High-gain channels "SH": [], # Short-period, High-gain channels "EH": [], # Extremely Short-period, High-gain channels "other": [] # All other channel types } } # Add coordinates if available if net_sta in station_coordinates: stations_data[net_sta]["coordinates"] = station_coordinates[net_sta] # Get latency information now = datetime.utcnow() latency_data = now - value.last_data latency_seconds = total_seconds(latency_data) # Extract channel type (first two characters, e.g., 'LH', 'BH', 'HH', 'EH') channel_type = value.cha[:2] if len(value.cha) >= 2 else "other" # Get status with channel-aware thresholds status = get_status_from_seconds(latency_seconds, channel_type) # Create channel data channel_data = { "location": value.loc, "channel": value.cha, "type": value.typ, "channelType": channel_type, "last_data": value.last_data.isoformat() if value.last_data else None, "last_feed": value.last_feed.isoformat() if value.last_feed else None, "latency": latency_seconds, "status": status } # Add to main channels list stations_data[net_sta]["channels"].append(channel_data) # Add to channel group for separated status calculation if channel_type in stations_data[net_sta]["channelGroups"]: stations_data[net_sta]["channelGroups"][channel_type].append(channel_data) else: stations_data[net_sta]["channelGroups"]["other"].append(channel_data) # Convert to list for easier JavaScript processing stations_list = [] for net_sta, data in stations_data.items(): # Calculate overall station status based on priority channels (non-LH channels) # First try HH channels if data["channelGroups"]["HH"]: worst_latency = max([ch["latency"] for ch in data["channelGroups"]["HH"]]) data["status"] = get_status_from_seconds(worst_latency) data["primaryChannelType"] = "HH" # Then try BH channels elif data["channelGroups"]["BH"]: worst_latency = max([ch["latency"] for ch in data["channelGroups"]["BH"]]) data["status"] = get_status_from_seconds(worst_latency) data["primaryChannelType"] = "BH" # Then try SH channels elif data["channelGroups"]["SH"]: worst_latency = max([ch["latency"] for ch in data["channelGroups"]["SH"]]) data["status"] = get_status_from_seconds(worst_latency) data["primaryChannelType"] = "SH" # Then try EH channels elif data["channelGroups"]["EH"]: worst_latency = max([ch["latency"] for ch in data["channelGroups"]["EH"]]) data["status"] = get_status_from_seconds(worst_latency) data["primaryChannelType"] = "EH" # Only use LH if nothing else is available elif data["channelGroups"]["LH"]: worst_latency = max([ch["latency"] for ch in data["channelGroups"]["LH"]]) data["status"] = get_status_from_seconds(worst_latency, "LH") data["primaryChannelType"] = "LH" # Fall back to other channels elif data["channelGroups"]["other"]: worst_latency = max([ch["latency"] for ch in data["channelGroups"]["other"]]) data["status"] = get_status_from_seconds(worst_latency) data["primaryChannelType"] = "other" else: # Failsafe if no channels data["status"] = "unavailable" data["primaryChannelType"] = "none" worst_latency = 0 data["latency"] = worst_latency data["id"] = net_sta stations_list.append(data) return json.dumps(stations_list) def get_map_settings(config): """Extract map settings from config for JavaScript use""" map_settings = { 'center': { 'lat': -25.6, # Default latitude 'lon': 134.3, # Default longitude 'zoom': 6 # Default zoom }, 'defaultLayer': 'street', 'enableClustering': True, 'showFullscreenControl': True, 'showLayerControl': True, 'showLocateControl': True, 'darkModeLayer': 'dark', 'lightModeLayer': 'street' } # Extract center coordinates from config if 'center_map' in config['setup']: if 'lat' in config['setup']['center_map']: map_settings['center']['lat'] = float(config['setup']['center_map']['lat']) if 'lon' in config['setup']['center_map']: map_settings['center']['lon'] = float(config['setup']['center_map']['lon']) if 'zoom' in config['setup']['center_map']: map_settings['center']['zoom'] = int(config['setup']['center_map']['zoom']) # Extract other map settings if 'map_settings' in config['setup']: map_config = config['setup']['map_settings'] if 'default_layer' in map_config: map_settings['defaultLayer'] = map_config['default_layer'] if 'enable_clustering' in map_config: map_settings['enableClustering'] = map_config['enable_clustering'] == 'true' or map_config['enable_clustering'] is True if 'show_fullscreen_control' in map_config: map_settings['showFullscreenControl'] = map_config['show_fullscreen_control'] == 'true' or map_config['show_fullscreen_control'] is True if 'show_layer_control' in map_config: map_settings['showLayerControl'] = map_config['show_layer_control'] == 'true' or map_config['show_layer_control'] is True if 'show_locate_control' in map_config: map_settings['showLocateControl'] = map_config['show_locate_control'] == 'true' or map_config['show_locate_control'] is True if 'dark_mode_layer' in map_config: map_settings['darkModeLayer'] = map_config['dark_mode_layer'] if 'light_mode_layer' in map_config: map_settings['lightModeLayer'] = map_config['light_mode_layer'] return map_settings def get_status_from_seconds(seconds, channel_type=None): """ Get status code based on latency in seconds with channel-specific thresholds Args: seconds (float): Latency in seconds channel_type (str): Channel type (e.g., 'LH', 'BH', 'HH', 'EH') Returns: str: Status code (good, delayed, etc.) """ # Special handling for LH channels - they're naturally delayed if channel_type == 'LH': # More lenient thresholds for LH channels if seconds > 604800: # > 7 days return "unavailable" elif seconds > 518400: # > 6 days return "four-day" elif seconds > 432000: # > 5 days return "three-day" elif seconds > 345600: # > 4 days return "multi-day" elif seconds > 259200: # > 3 days return "day-delayed" elif seconds > 86400: # > 1 day return "critical" elif seconds > 43200: # > 12 hours return "warning" elif seconds > 21600: # > 6 hours return "hour-delayed" elif seconds > 10800: # > 3 hours return "very-delayed" elif seconds > 3600: # > 1 hour return "long-delayed" elif seconds > 1800: # > 30 minutes return "delayed" else: # <= 30 minutes (LH channels are considered good even with moderate delay) return "good" # Standard thresholds for other channels if seconds > 432000: # > 5 days return "unavailable" elif seconds > 345600: # > 4 days return "four-day" elif seconds > 259200: # > 3 days return "three-day" elif seconds > 172800: # > 2 days return "multi-day" elif seconds > 86400: # > 1 day return "day-delayed" elif seconds > 21600: # > 6 hours return "critical" elif seconds > 7200: # > 2 hours return "warning" elif seconds > 3600: # > 1 hour return "hour-delayed" elif seconds > 1800: # > 30 minutes return "very-delayed" elif seconds > 600: # > 10 minutes return "long-delayed" elif seconds > 60: # > 1 minute return "delayed" else: # <= 1 minute return "good" def getColor(delta): delay = total_seconds(delta) if delay > 432000: return '#666666' # > 5 days elif delay > 345600: return '#999999' # > 4 days elif delay > 259200: return '#CCCCCC' # > 3 days elif delay > 172800: return '#FFB3B3' # > 2 days elif delay > 86400: return '#FF3333' # > 1 day elif delay > 21600: return '#FF9966' # > 6 hours elif delay > 7200: return '#FFFF00' # > 2 hours elif delay > 3600: return '#00FF00' # > 1 hour elif delay > 1800: return '#3399FF' # > 30 minutes elif delay > 600: return '#9470BB' # > 10 minutes elif delay > 60: return '#EBD6FF' # > 1 minute else: return '#FFFFFF' # <= 1 minute def total_seconds(td): return td.seconds + (td.days*86400) def myrename(name1, name2): # fault-tolerant rename that doesn't cause an exception if it fails, which # may happen e.g. if the target is on a non-reachable NFS directory try: os.rename(name1, name2) except OSError: print("failed to rename(%s,%s)" % (name1, name2), file=sys.stderr) def formatLatency(delta): """Format latency for display""" if delta is None: return 'n/a' t = total_seconds(delta) if t > 86400: return f"{t/86400:.1f} d" elif t > 7200: return f"{t/3600:.1f} h" elif t > 120: return f"{t/60:.1f} m" else: return f"{t:.1f} s" def generate_css_file(config): """Generate the CSS file with theme support""" css_content = """ :root { /* Light theme variables */ --primary-color: #4f46e5; --primary-hover: #4338ca; --text-primary: #1f2937; --text-secondary: #6b7280; --bg-primary: #ffffff; --bg-secondary: #f9fafb; --bg-tertiary: #f3f4f6; --border-color: #e5e7eb; --border-radius: 8px; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); /* Status colors */ --status-good: #ffffff; --status-delayed: #c084fc; --status-long-delayed: #8b5cf6; --status-very-delayed: #3b82f6; --status-hour-delayed: #10b981; --status-warning: #fbbf24; --status-critical: #f97316; --status-day-delayed: #ef4444; --status-multi-day: #f87171; --status-three-day: #d1d5db; --status-four-day: #9ca3af; --status-unavailable: #6b7280; } .dark-mode { /* Dark theme variables */ --primary-color: #818cf8; --primary-hover: #a5b4fc; --text-primary: #f9fafb; --text-secondary: #9ca3af; --bg-primary: #1f2937; --bg-secondary: #111827; --bg-tertiary: #374151; --border-color: #374151; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3); /* Dark theme status colors - background stays dark */ --status-good: #1f2937; } /* General Styles */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: var(--text-primary); background-color: var(--bg-secondary); padding: 0; margin: 0; } .container { max-width: 1400px; margin: 20px auto; padding: 30px; background-color: var(--bg-primary); border-radius: var(--border-radius); box-shadow: var(--shadow-md); } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 15px; } h1 { font-size: 28px; font-weight: 600; color: var(--text-primary); letter-spacing: -0.5px; } .subtitle { color: var(--text-secondary); font-size: 16px; margin-bottom: 20px; } /* Navigation Tabs */ .view-toggle { display: flex; gap: 10px; } .view-toggle a { padding: 8px 15px; border-radius: 6px; color: var(--text-secondary); text-decoration: none; transition: all 0.2s ease; font-weight: 500; font-size: 14px; } .view-toggle a:hover { background-color: var(--bg-tertiary); color: var(--primary-color); } .view-toggle a.active { background-color: var(--primary-color); color: white; } /* Controls */ .controls { display: flex; justify-content: space-between; align-items: center; margin: 20px 0; flex-wrap: wrap; gap: 15px; } .actions { display: flex; gap: 10px; } .action-button { padding: 8px 15px; display: flex; align-items: center; gap: 6px; background-color: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-secondary); cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s ease; } .action-button:hover { background-color: var(--bg-primary); color: var(--primary-color); } .action-button svg { width: 16px; height: 16px; } .refresh-control { display: flex; align-items: center; gap: 12px; padding: 10px 15px; background-color: var(--bg-tertiary); border-radius: 6px; } .input-group { display: flex; align-items: center; gap: 8px; } .refresh-control input { width: 60px; padding: 6px 10px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; background-color: var(--bg-primary); color: var(--text-primary); text-align: center; } .refresh-control button { padding: 6px 12px; background-color: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: background-color 0.2s ease; } .refresh-control button:hover { background-color: var(--primary-hover); } .status-counter { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; } #refresh-status { font-size: 13px; color: var(--text-secondary); } .countdown { font-size: 13px; color: var(--text-secondary); } #next-refresh { color: var(--primary-color); font-weight: 500; } /* Filter and Search */ .filters { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; padding: 15px; background-color: var(--bg-tertiary); border-radius: var(--border-radius); } .filter-group { display: flex; align-items: center; gap: 8px; } .filter-group label { font-size: 14px; color: var(--text-secondary); font-weight: 500; } .filter-group select { padding: 6px 10px; border: 1px solid var(--border-color); border-radius: 4px; background-color: var(--bg-primary); color: var(--text-primary); font-size: 14px; } .search-box { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 4px; background-color: var(--bg-primary); color: var(--text-primary); font-size: 14px; min-width: 200px; } /* Table View */ .table-container { overflow-x: auto; margin-bottom: 20px; } table { width: 100%; border-collapse: collapse; } table th { padding: 12px 15px; background-color: var(--bg-tertiary); color: var(--text-secondary); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border-color); position: sticky; top: 0; z-index: 10; } table td { padding: 10px 15px; border-bottom: 1px solid var(--border-color); } table tr:hover { background-color: var(--bg-tertiary); } /* Grid View */ .grid-container { display: table; width: 100%; border-collapse: collapse; margin-top: 20px; } .grid-row { display: table-row; } .network-label { display: table-cell; vertical-align: middle; text-align: center; font-weight: 600; width: 60px; min-width: 60px; height: 34px; background-color: var(--bg-tertiary); border-radius: 6px; color: var(--text-secondary); box-shadow: var(--shadow-sm); padding: 4px; margin: 2px; border: 1px solid var(--border-color); } .stations-container { display: table-cell; padding-left: 6px; } .stations-row { display: flex; flex-wrap: wrap; gap: 4px; margin: 2px 0; } .grid-cell { width: 60px; height: 34px; display: flex; align-items: center; justify-content: center; border-radius: 6px; font-size: 13px; font-weight: 500; box-shadow: var(--shadow-sm); text-decoration: none; color: var(--text-primary); transition: all 0.15s ease; position: relative; border: 1px solid var(--border-color); background-color: var(--status-good); } .grid-cell:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); z-index: 10; } /* Map View */ .map-container { width: 100%; height: 600px; background-color: var(--bg-tertiary); border-radius: var(--border-radius); margin-bottom: 20px; position: relative; } /* Status Colors */ .station-unavailable { background-color: var(--status-unavailable); color: white; border-color: var(--status-unavailable); } .station-warning { background-color: var(--status-warning); color: #7c2d12; border-color: var(--status-warning); } .station-critical { background-color: var(--status-critical); color: white; border-color: var(--status-critical); } .station-delayed { background-color: var(--status-delayed); color: #4a044e; border-color: var(--status-delayed); } .station-long-delayed { background-color: var(--status-long-delayed); color: white; border-color: var(--status-long-delayed); } .station-very-delayed { background-color: var(--status-very-delayed); color: white; border-color: var(--status-very-delayed); } .station-hour-delayed { background-color: var(--status-hour-delayed); color: white; border-color: var(--status-hour-delayed); } .station-day-delayed { background-color: var(--status-day-delayed); color: white; border-color: var(--status-day-delayed); } .station-multi-day { background-color: var(--status-multi-day); color: #7f1d1d; border-color: var(--status-multi-day); } .station-three-day { background-color: var(--status-three-day); color: #1f2937; border-color: var(--status-three-day); } .station-four-day { background-color: var(--status-four-day); color: white; border-color: var(--status-four-day); } .station-good { background-color: var(--status-good); color: var(--text-primary); border-color: var(--border-color); } /* Tooltip */ .grid-cell::after { content: attr(data-tooltip); position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%); background-color: #1f2937; color: white; text-align: center; padding: 8px 12px; border-radius: 6px; font-size: 12px; white-space: nowrap; z-index: 20; opacity: 0; visibility: hidden; transition: all 0.2s ease; pointer-events: none; box-shadow: var(--shadow-md); } .grid-cell::before { content: ''; position: absolute; top: -6px; left: 50%; transform: translateX(-50%); border-width: 6px 6px 0; border-style: solid; border-color: #1f2937 transparent transparent; z-index: 20; opacity: 0; visibility: hidden; transition: all 0.2s ease; pointer-events: none; } .grid-cell:hover::after, .grid-cell:hover::before { opacity: 1; visibility: visible; } /* Stats */ .stats-container { margin: 20px 0; padding: 20px; background-color: var(--bg-tertiary); border-radius: var(--border-radius); display: none; } .stats-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .stats-title { font-weight: 600; font-size: 16px; color: var(--text-primary); } .network-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 15px; } .network-stat { display: flex; flex-direction: column; gap: 8px; } .network-name { font-weight: 600; font-size: 14px; color: var(--text-primary); display: flex; justify-content: space-between; } .progress-bar { height: 8px; background-color: var(--border-color); border-radius: 4px; overflow: hidden; } .progress { height: 100%; background-color: var(--primary-color); border-radius: 4px; } /* Legend */ .legend { display: flex; flex-wrap: wrap; gap: 10px; margin: 25px 0; padding: 15px; background-color: var(--bg-tertiary); border-radius: var(--border-radius); justify-content: center; } .legend-item { display: flex; align-items: center; gap: 5px; font-size: 13px; color: var(--text-secondary); } .legend-color { width: 16px; height: 16px; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.1); } /* Footer */ .footer { margin-top: 30px; padding-top: 15px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; color: var(--text-secondary); font-size: 14px; } .footer a { color: var(--primary-color); text-decoration: none; } .footer a:hover { text-decoration: underline; } /* Loading */ #loading { display: flex; align-items: center; justify-content: center; margin: 30px 0; color: var(--text-secondary); } .loading-spinner { width: 24px; height: 24px; border: 3px solid var(--bg-tertiary); border-top: 3px solid var(--primary-color); border-radius: 50%; margin-right: 12px; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Error Message */ #error-message { padding: 15px; margin: 20px 0; border-radius: var(--border-radius); background-color: #fee2e2; color: #b91c1c; border-left: 4px solid #ef4444; display: none; } /* Responsive Design */ @media (max-width: 768px) { .container { margin: 10px; padding: 15px; border-radius: 6px; } .header { flex-direction: column; align-items: flex-start; gap: 10px; } .view-toggle { align-self: flex-end; } .controls { flex-direction: column; align-items: stretch; } .actions { justify-content: space-between; } .refresh-control { flex-direction: column; align-items: flex-start; gap: 10px; } .filters { flex-direction: column; gap: 10px; } .filter-group { width: 100%; } .search-box { width: 100%; } .network-stats { grid-template-columns: 1fr; } .map-container { height: 400px; } } /* Marker Cluster Styles */ .marker-cluster { background-clip: padding-box; border-radius: 20px; } .marker-cluster div { width: 36px; height: 36px; margin-left: 2px; margin-top: 2px; text-align: center; border-radius: 18px; font-size: 12px; display: flex; align-items: center; justify-content: center; } /* Map Controls */ .leaflet-control-locate { border: 2px solid rgba(0,0,0,0.2); background-clip: padding-box; } .leaflet-control-locate a { background-color: var(--bg-primary); background-position: 50% 50%; background-repeat: no-repeat; display: block; width: 30px; height: 30px; line-height: 30px; color: var(--text-primary); text-align: center; } .leaflet-control-locate a:hover { background-color: var(--bg-tertiary); color: var(--primary-color); } .leaflet-control-locate.active a { color: var(--primary-color); } .leaflet-control-fullscreen { border: 2px solid rgba(0,0,0,0.2); background-clip: padding-box; } .leaflet-control-fullscreen a { background-color: var(--bg-primary); background-position: 50% 50%; background-repeat: no-repeat; display: block; width: 30px; height: 30px; line-height: 30px; color: var(--text-primary); text-align: center; } .leaflet-control-fullscreen a:hover { background-color: var(--bg-tertiary); color: var(--primary-color); } /* Map layers control */ .leaflet-control-layers { border-radius: var(--border-radius); background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); box-shadow: var(--shadow-sm); } .dark-mode .leaflet-control-layers { background-color: var(--bg-tertiary); } .leaflet-control-layers-toggle { width: 36px; height: 36px; background-size: 20px 20px; } .leaflet-control-layers-expanded { padding: 10px; background-color: var(--bg-primary); color: var(--text-primary); border-radius: var(--border-radius); } .dark-mode .leaflet-control-layers-expanded { background-color: var(--bg-tertiary); } .leaflet-control-layers-list { margin-top: 8px; } .leaflet-control-layers label { margin-bottom: 5px; display: block; } /* Map layer selection buttons */ .map-layers-control { position: absolute; top: 10px; right: 10px; z-index: 1000; background: white; padding: 5px; border-radius: 4px; box-shadow: 0 1px 5px rgba(0,0,0,0.65); } .map-layers-control button { display: block; margin: 5px 0; padding: 5px; width: 100%; border: none; background: #f8f8f8; cursor: pointer; } .map-layers-control button:hover { background: #f0f0f0; } .map-layers-control button.active { background: #ddd; font-weight: bold; } /* Map tools control */ .map-tools-control { position: absolute; bottom: 30px; right: 10px; z-index: 1000; display: flex; flex-direction: column; gap: 5px; } .map-tools-control button { width: 34px; height: 34px; background: white; border: 2px solid rgba(0,0,0,0.2); border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #333; } .map-tools-control button:hover { background: #f4f4f4; } .dark-mode .map-tools-control button { background: #333; color: #fff; border-color: rgba(255,255,255,0.2); } .dark-mode .map-tools-control button:hover { background: #444; } /* Map measurement widget */ .leaflet-measure-path-measurement { position: absolute; font-size: 12px; color: black; text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white; white-space: nowrap; transform-origin: 0; pointer-events: none; } .dark-mode .leaflet-measure-path-measurement { color: white; text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; } /* Popup styling */ .leaflet-popup-content-wrapper { border-radius: var(--border-radius); background-color: var(--bg-primary); color: var(--text-primary); box-shadow: var(--shadow-md); } .dark-mode .leaflet-popup-content-wrapper { background-color: var(--bg-tertiary); } .leaflet-popup-content { margin: 12px; line-height: 1.5; } .leaflet-popup-tip { background-color: var(--bg-primary); } .dark-mode .leaflet-popup-tip { background-color: var(--bg-tertiary); } .leaflet-popup-content a { color: var(--primary-color); text-decoration: none; } .leaflet-popup-content a:hover { text-decoration: underline; } /* Make the map more responsive on mobile */ @media (max-width: 768px) { .map-container { height: 450px; } .leaflet-control-layers, .leaflet-control-zoom, .leaflet-control-fullscreen, .leaflet-control-locate { margin-right: 10px !important; } .leaflet-control-scale { margin-bottom: 40px !important; } } """ try: css_path = os.path.join(config['setup']['wwwdir'], 'styles.css') with open(css_path, 'w') as f: f.write(css_content) print(f"CSS file generated at {css_path}") return css_path except Exception as e: print(f"Error generating CSS file: {str(e)}") return None def generate_js_file(config): """Generate the JavaScript file with interactive features""" js_content = """ // Global variables let refreshTimer = null; let currentRefreshInterval = 60; let lastRefreshTime = 0; let isRefreshing = false; let stationsData = []; let viewMode = 'table'; // 'table', 'grid', or 'map' let mapInitialized = false; let map = null; let markers = []; // Function to initialize the application document.addEventListener('DOMContentLoaded', function() { // Load saved preferences loadPreferences(); // Set up event listeners setupEventListeners(); //Add channel type filter setupFilters(); // Set active view based on URL or default setActiveView(); // Initial data load fetchData(); }); // Function to load user preferences from localStorage function loadPreferences() { // Load refresh interval const savedInterval = parseInt(localStorage.getItem('seedlinkRefreshInterval')); if (savedInterval && savedInterval >= 10) { document.getElementById('refresh-interval').value = savedInterval; currentRefreshInterval = savedInterval; } // Load dark mode preference const darkModeEnabled = localStorage.getItem('seedlink-dark-mode') === 'true'; if (darkModeEnabled) { document.body.classList.add('dark-mode'); updateThemeToggleButton(true); } // Load view mode const savedViewMode = localStorage.getItem('seedlink-view-mode'); if (savedViewMode) { viewMode = savedViewMode; } } // Function to set up all event listeners function setupEventListeners() { // View toggle buttons document.querySelectorAll('.view-toggle a').forEach(link => { link.addEventListener('click', function(e) { e.preventDefault(); const view = this.getAttribute('data-view'); switchView(view); }); }); // Refresh controls document.getElementById('apply-refresh').addEventListener('click', function() { const interval = parseInt(document.getElementById('refresh-interval').value); if (interval && interval >= 10) { updateRefreshInterval(interval); } }); document.getElementById('refresh-now').addEventListener('click', function() { if (refreshTimer) { clearTimeout(refreshTimer); } fetchData(); }); // Theme toggle document.getElementById('theme-toggle').addEventListener('click', toggleDarkMode); // Export CSV document.getElementById('export-csv').addEventListener('click', exportToCsv); // Stats toggle document.getElementById('stats-toggle').addEventListener('click', toggleStats); document.getElementById('close-stats').addEventListener('click', function() { document.getElementById('stats-container').style.display = 'none'; }); // Filter inputs document.getElementById('network-filter').addEventListener('change', applyFilters); document.getElementById('status-filter').addEventListener('change', applyFilters); document.getElementById('search-input').addEventListener('input', debounce(applyFilters, 300)); // Sort headers in table view document.querySelectorAll('th[data-sort]').forEach(header => { header.addEventListener('click', function() { sortTable(this.getAttribute('data-sort')); }); }); // Handle visibility changes (tab switching) document.addEventListener('visibilitychange', function() { if (document.visibilityState === 'visible') { // If data is stale (not refreshed in over half the interval) const timeSinceLastRefresh = Date.now() - lastRefreshTime; if (timeSinceLastRefresh > (currentRefreshInterval * 500)) { if (refreshTimer) { clearTimeout(refreshTimer); } fetchData(); } } }); } // Function to set active view based on URL or saved preference function setActiveView() { // Extract view from URL if present const urlParams = new URLSearchParams(window.location.search); const urlView = urlParams.get('view'); if (urlView && ['table', 'grid', 'map'].includes(urlView)) { viewMode = urlView; } // Set active class on the appropriate link document.querySelectorAll('.view-toggle a').forEach(link => { if (link.getAttribute('data-view') === viewMode) { link.classList.add('active'); } else { link.classList.remove('active'); } }); // Show the appropriate view container document.querySelectorAll('.view-container').forEach(container => { if (container.id === `${viewMode}-view`) { container.style.display = 'block'; } else { container.style.display = 'none'; } }); // Initialize map if needed if (viewMode === 'map' && !mapInitialized && typeof L !== 'undefined') { initializeMap(); } // Save preference localStorage.setItem('seedlink-view-mode', viewMode); } // Function to switch between views function switchView(view) { viewMode = view; // Update URL without reloading the page const url = new URL(window.location); url.searchParams.set('view', view); window.history.pushState({}, '', url); setActiveView(); // Refresh data display for the new view renderData(); } // Function to toggle dark mode function toggleDarkMode() { document.body.classList.toggle('dark-mode'); const isDarkMode = document.body.classList.contains('dark-mode'); localStorage.setItem('seedlink-dark-mode', isDarkMode ? 'true' : 'false'); updateThemeToggleButton(isDarkMode); // Update map tiles if map is initialized if (mapInitialized && map) { updateMapTiles(isDarkMode); } } // Function to update theme toggle button appearance function updateThemeToggleButton(isDarkMode) { const themeToggle = document.getElementById('theme-toggle'); if (isDarkMode) { themeToggle.innerHTML = ` Light Mode `; } else { themeToggle.innerHTML = ` Dark Mode `; } } // Function to initialize the map view // 1. Enhanced map initialization function // Updated initializeMap function with markerCluster safety checks function initializeMap() { // Check if Leaflet is available if (typeof L === 'undefined') { console.error('Leaflet library not loaded'); document.getElementById('map-container').innerHTML = '
Map library not available. Please check your internet connection.
'; return; } // Initialize markerCluster as null so it's defined even if the plugin isn't available markerCluster = null; // Read map settings from the page data if available const mapSettings = window.mapSettings || { center: { lat: 20, lon: 0, zoom: 2 }, defaultLayer: 'street', enableClustering: true, showFullscreenControl: true, showLayerControl: true, showLocateControl: true }; // Create map instance map = L.map('map-container', { center: [mapSettings.center.lat, mapSettings.center.lon], zoom: mapSettings.center.zoom, zoomControl: false // We'll add this separately for better positioning }); // Define available base layers const baseLayers = { 'Street': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }), 'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Imagery © Esri © ArcGIS', maxZoom: 19 }), 'Terrain': L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap', maxZoom: 17 }), 'Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 19 }) }; // Add appropriate layer based on settings or dark mode const isDarkMode = document.body.classList.contains('dark-mode'); let defaultLayer = isDarkMode ? 'Dark' : (mapSettings.defaultLayer || 'Street'); defaultLayer = defaultLayer.charAt(0).toUpperCase() + defaultLayer.slice(1); // Capitalize // Add the default layer to the map if (baseLayers[defaultLayer]) { baseLayers[defaultLayer].addTo(map); } else { // Fallback to the first available layer baseLayers[Object.keys(baseLayers)[0]].addTo(map); } // Add layer control if enabled if (mapSettings.showLayerControl !== false) { L.control.layers(baseLayers, {}, { position: 'topright', collapsed: true }).addTo(map); } // Add zoom control in a better position L.control.zoom({ position: 'bottomright' }).addTo(map); // Add scale control L.control.scale().addTo(map); // Add fullscreen control if enabled and the plugin is available if (mapSettings.showFullscreenControl !== false && typeof L.Control.Fullscreen !== 'undefined') { L.control.fullscreen({ position: 'topright', title: { 'false': 'View Fullscreen', 'true': 'Exit Fullscreen' } }).addTo(map); } // Add locate control if enabled and the plugin is available if (mapSettings.showLocateControl !== false && typeof L.Control.Locate !== 'undefined') { L.control.locate({ position: 'bottomright', icon: 'fa fa-location-arrow', strings: { title: 'Show my location' }, locateOptions: { enableHighAccuracy: true, maxZoom: 10 } }).addTo(map); } // Initialize marker cluster group if enabled and the plugin is available if (mapSettings.enableClustering !== false && typeof L.MarkerClusterGroup !== 'undefined') { try { markerCluster = L.markerClusterGroup({ disableClusteringAtZoom: 10, spiderfyOnMaxZoom: true, showCoverageOnHover: false, iconCreateFunction: function(cluster) { const count = cluster.getChildCount(); // Determine color based on worst status in the cluster let worstStatus = 'good'; const markers = cluster.getAllChildMarkers(); for (const marker of markers) { const status = marker.options.status || 'good'; // Simple ordering of statuses from least to most severe const statusOrder = { 'good': 0, 'delayed': 1, 'long-delayed': 2, 'very-delayed': 3, 'hour-delayed': 4, 'warning': 5, 'critical': 6, 'day-delayed': 7, 'multi-day': 8, 'three-day': 9, 'four-day': 10, 'unavailable': 11 }; if ((statusOrder[status] || 0) > (statusOrder[worstStatus] || 0)) { worstStatus = status; } } // Get color for worst status const color = getStatusColor(worstStatus); const textColor = getBestTextColor(color); return L.divIcon({ html: `
${count}
`, className: 'marker-cluster', iconSize: new L.Point(40, 40) }); } }); map.addLayer(markerCluster); console.log("Marker clustering initialized successfully"); } catch (e) { console.error("Error initializing marker clustering:", e); markerCluster = null; // Reset to null if initialization failed } } else { console.log("Marker clustering is disabled or not available"); } // Mark as initialized mapInitialized = true; // Update markers if we already have data if (stationsData.length > 0) { updateMapMarkers(); } } // Helper function to determine best text color (black or white) based on background color function getBestTextColor(bgColor) { // Handle named colors if (bgColor.toLowerCase() === '#ffffff') return '#000000'; if (bgColor.toLowerCase() === '#000000') return '#ffffff'; // Convert hex to rgb let hex = bgColor.replace('#', ''); let r, g, b; if (hex.length === 3) { r = parseInt(hex.charAt(0) + hex.charAt(0), 16); g = parseInt(hex.charAt(1) + hex.charAt(1), 16); b = parseInt(hex.charAt(2) + hex.charAt(2), 16); } else { r = parseInt(hex.substring(0, 2), 16); g = parseInt(hex.substring(2, 4), 16); b = parseInt(hex.substring(4, 6), 16); } // Calculate luminance const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; // Return white for dark backgrounds, black for light backgrounds return luminance > 0.5 ? '#000000' : '#ffffff'; } function updateMapTiles(isDarkMode) { if (!map) return; // Get available layers from map's layer control const baseLayers = {}; map.eachLayer(layer => { if (layer instanceof L.TileLayer) { map.removeLayer(layer); } }); // Add the default layer based on theme if (isDarkMode) { if (window.mapSettings && window.mapSettings.darkModeLayer) { // Use configured dark mode layer const darkLayer = window.mapSettings.darkModeLayer.toLowerCase(); if (darkLayer === 'satellite') { L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Imagery © Esri © ArcGIS', maxZoom: 19 }).addTo(map); } else { L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 19 }).addTo(map); } } else { // Default dark theme L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 19 }).addTo(map); } } else { if (window.mapSettings && window.mapSettings.lightModeLayer) { // Use configured light mode layer const lightLayer = window.mapSettings.lightModeLayer.toLowerCase(); if (lightLayer === 'satellite') { L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Imagery © Esri © ArcGIS', maxZoom: 19 }).addTo(map); } else if (lightLayer === 'terrain') { L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap', maxZoom: 17 }).addTo(map); } else { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); } } else { // Default light theme L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); } } } function updateMapMarkers() { if (!mapInitialized || !map) return; // Clear existing markers if (markerCluster) { try { markerCluster.clearLayers(); } catch (e) { console.error("Error clearing marker cluster:", e); // Fall back to standard markers if cluster fails markerCluster = null; markers.forEach(marker => { try { map.removeLayer(marker); } catch(e) {} }); } } else { markers.forEach(marker => { try { map.removeLayer(marker); } catch(e) {} }); } markers = []; // Variables to track bounds for auto-zooming let validCoordinates = false; const bounds = L.latLngBounds(); // Add markers for each station stationsData.forEach(station => { // Skip stations without coordinates if (!station.coordinates || !station.coordinates.lat || !station.coordinates.lon) { console.log(`Station ${station.network}_${station.station} has no coordinates`); return; } validCoordinates = true; const lat = station.coordinates.lat; const lon = station.coordinates.lon; // Add to bounds for auto-zooming bounds.extend([lat, lon]); // Create marker with appropriate color based on status const markerColor = getStatusColor(station.status); // Create marker with a badge if it's using LH channels const isLH = station.primaryChannelType === 'LH'; const markerIcon = L.divIcon({ html: isLH ? `
L
` : `
`, className: 'station-marker', iconSize: [18, 18], iconAnchor: [9, 9] }); const marker = L.marker([lat, lon], { icon: markerIcon, title: `${station.network}_${station.station}`, status: station.status // Store status for cluster coloring }); // Create channel group summary for popup const channelGroupsText = station.channels.reduce((groups, channel) => { const type = channel.channelType || 'other'; if (!groups[type]) groups[type] = 0; groups[type]++; return groups; }, {}); const channelGroupsHTML = Object.entries(channelGroupsText) .map(([type, count]) => `${type}: ${count}`) .join(', '); // Add popup with station info marker.bindPopup(` ${station.network}_${station.station}
Primary channel type: ${station.primaryChannelType || 'N/A'}
Status: ${formatStatus(station.status, station.primaryChannelType)}
Latency: ${formatLatency(station.latency)}
Channels: ${channelGroupsHTML}
Coordinates: ${lat.toFixed(4)}, ${lon.toFixed(4)} ${station.coordinates.elevation ? '
Elevation: ' + station.coordinates.elevation.toFixed(1) + ' m' : ''}
View Details `); // Add to the cluster group or directly to the map try { if (markerCluster) { markerCluster.addLayer(marker); } else { marker.addTo(map); } markers.push(marker); } catch (e) { console.error("Error adding marker:", e); // If cluster fails, try adding directly to map try { marker.addTo(map); markers.push(marker); } catch (e2) { console.error("Also failed to add directly to map:", e2); } } }); // Auto-zoom to fit all markers if we have valid coordinates if (validCoordinates && markers.length > 0) { // Don't zoom too close if there's only one station if (markers.length === 1) { map.setView(bounds.getCenter(), 8); } else { try { map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 }); } catch (e) { console.error("Error fitting bounds:", e); // Fallback to a default view map.setView([20, 0], 2); } } } else if (!validCoordinates && markers.length === 0) { // Show message if no stations have coordinates const noCoordinatesMsg = document.createElement('div'); noCoordinatesMsg.className = 'error-message'; noCoordinatesMsg.style.position = 'absolute'; noCoordinatesMsg.style.top = '50%'; noCoordinatesMsg.style.left = '50%'; noCoordinatesMsg.style.transform = 'translate(-50%, -50%)'; noCoordinatesMsg.style.background = 'rgba(255, 255, 255, 0.9)'; noCoordinatesMsg.style.padding = '15px'; noCoordinatesMsg.style.borderRadius = '8px'; noCoordinatesMsg.style.zIndex = 1000; noCoordinatesMsg.innerHTML = `

No station coordinates available

Make sure your FDSNWS service is properly configured and accessible.

`; document.getElementById('map-container').appendChild(noCoordinatesMsg); } } // Custom legend for the map function addMapLegend() { if (!mapInitialized || !map) return; // Remove existing legend if any const existingLegend = document.querySelector('.map-legend'); if (existingLegend) { existingLegend.remove(); } // Create a custom legend const legend = L.control({position: 'bottomright'}); legend.onAdd = function() { const div = L.DomUtil.create('div', 'map-legend'); div.innerHTML = `

Station Status

Good (≤ 1 min)
> 1 min
> 10 min
> 30 min
> 1 hour
> 2 hours
> 6 hours
> 1 day
`; // Add custom styles to the legend const style = document.createElement('style'); style.textContent = ` .map-legend { padding: 10px; background: white; background: rgba(255, 255, 255, 0.9); border-radius: 5px; line-height: 1.8; color: #333; box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); } .dark-mode .map-legend { background: rgba(31, 41, 55, 0.9); color: #f9fafb; } .map-legend h4 { margin: 0 0 5px; font-size: 14px; font-weight: 600; } .map-legend div { display: flex; align-items: center; font-size: 12px; margin-bottom: 3px; } .map-legend span { display: inline-block; width: 16px; height: 16px; margin-right: 8px; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.2); } .dark-mode .map-legend span { border-color: rgba(255, 255, 255, 0.2); } `; div.appendChild(style); return div; }; legend.addTo(map); } function setupFilters() { // Add a channel type filter to the filters area const filtersArea = document.querySelector('.filters'); if (!filtersArea) return; // Check if the filter already exists if (!document.getElementById('channel-filter')) { const channelFilterGroup = document.createElement('div'); channelFilterGroup.className = 'filter-group'; channelFilterGroup.innerHTML = ` `; filtersArea.appendChild(channelFilterGroup); // Add event listener document.getElementById('channel-filter').addEventListener('change', applyFilters); } } // Enhanced station filters for the map function setupMapFilters() { if (!mapInitialized || !map) return; const mapFilters = L.control({position: 'topleft'}); mapFilters.onAdd = function() { const div = L.DomUtil.create('div', 'map-filters'); div.innerHTML = `
`; // Add styles const style = document.createElement('style'); style.textContent = ` .map-filters { padding: 10px; background: rgba(255, 255, 255, 0.9); border-radius: 5px; box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); width: 200px; } .dark-mode .map-filters { background: rgba(31, 41, 55, 0.9); color: #f9fafb; } .map-filters .filter-select { margin-bottom: 8px; } .map-filters label { display: block; margin-bottom: 3px; font-weight: 500; font-size: 12px; } .map-filters select { width: 100%; padding: 4px 8px; border-radius: 4px; border: 1px solid #ddd; font-size: 12px; } .dark-mode .map-filters select { background: #374151; color: #f9fafb; border-color: #4b5563; } `; div.appendChild(style); // Prevent map interactions when using the filters L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); // Setup network filter options const networkFilter = div.querySelector('#map-network-filter'); const networks = [...new Set(stationsData.map(station => station.network))].sort(); networks.forEach(network => { const option = document.createElement('option'); option.value = network; option.textContent = network; networkFilter.appendChild(option); }); // Add event listeners networkFilter.addEventListener('change', function() { const selectedNetwork = this.value; updateMapMarkersFilter(selectedNetwork, div.querySelector('#map-status-filter').value); }); div.querySelector('#map-status-filter').addEventListener('change', function() { const selectedStatus = this.value; updateMapMarkersFilter(networkFilter.value, selectedStatus); }); return div; }; mapFilters.addTo(map); } // Filter map markers based on selected criteria function updateMapMarkersFilter(network, status) { if (!mapInitialized || !map) return; // Clear existing markers markers.forEach(marker => map.removeLayer(marker)); markers = []; // Apply filters to data let filteredData = stationsData; if (network) { filteredData = filteredData.filter(station => station.network === network); } if (status) { filteredData = filteredData.filter(station => { if (status === 'good') { return station.status === 'good'; } else if (status === 'warning') { return ['delayed', 'long-delayed', 'very-delayed', 'hour-delayed', 'warning'].includes(station.status); } else if (status === 'critical') { return ['critical', 'day-delayed', 'multi-day', 'three-day', 'four-day'].includes(station.status); } else if (status === 'unavailable') { return station.status === 'unavailable'; } return true; }); } // Add filtered markers const bounds = L.latLngBounds(); let validCoordinates = false; filteredData.forEach(station => { // Skip stations without coordinates if (!station.coordinates || !station.coordinates.lat || !station.coordinates.lon) return; validCoordinates = true; const lat = station.coordinates.lat; const lon = station.coordinates.lon; // Add to bounds for auto-zooming bounds.extend([lat, lon]); // Create marker with appropriate color const markerColor = getStatusColor(station.status); const markerIcon = L.divIcon({ html: `
`, className: 'station-marker', iconSize: [18, 18], iconAnchor: [9, 9] }); const marker = L.marker([lat, lon], { icon: markerIcon, title: `${station.network}_${station.station}` }); // Add popup with station info marker.bindPopup(` ${station.network}_${station.station}
Status: ${formatStatus(station.status)}
Latency: ${formatLatency(station.latency)}
Coordinates: ${lat.toFixed(4)}, ${lon.toFixed(4)} ${station.coordinates.elevation ? '
Elevation: ' + station.coordinates.elevation.toFixed(1) + ' m' : ''}
View Details `); marker.addTo(map); markers.push(marker); }); // Auto-zoom to fit all markers if we have valid coordinates if (validCoordinates && markers.length > 0) { // Don't zoom too close if there's only one station if (markers.length === 1) { map.setView(bounds.getCenter(), 8); } else { map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 }); } } } // Enhanced version of the setActiveView function to handle map initialization function setActiveView() { // Extract view from URL if present const urlParams = new URLSearchParams(window.location.search); const urlView = urlParams.get('view'); if (urlView && ['table', 'grid', 'map'].includes(urlView)) { viewMode = urlView; } // Set active class on the appropriate link document.querySelectorAll('.view-toggle a').forEach(link => { if (link.getAttribute('data-view') === viewMode) { link.classList.add('active'); } else { link.classList.remove('active'); } }); // Show the appropriate view container document.querySelectorAll('.view-container').forEach(container => { if (container.id === `${viewMode}-view`) { container.style.display = 'block'; } else { container.style.display = 'none'; } }); // Initialize map if needed if (viewMode === 'map') { if (!mapInitialized && typeof L !== 'undefined') { initializeMap(); // Add map-specific UI elements after initialization setTimeout(() => { addMapLegend(); setupMapFilters(); }, 100); } else if (mapInitialized) { // If map is already initialized, ensure it's up to date map.invalidateSize(); updateMapMarkers(); } } // Save preference localStorage.setItem('seedlink-view-mode', viewMode); } // Function to fetch data from the server function fetchData() { if (isRefreshing) return; isRefreshing = true; lastRefreshTime = Date.now(); // Show loading state //document.getElementById('loading').style.display = 'flex'; document.getElementById('error-message').style.display = 'none'; document.getElementById('refresh-status').textContent = 'Refreshing...'; // Use cache-busting to prevent stale data const timestamp = Date.now(); // Fetch the JSON data fetch(`stations_data.json?_=${timestamp}`, { cache: 'no-cache', headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' } }) .then(response => { if (!response.ok) { throw new Error(`Server returned ${response.status}: ${response.statusText}`); } return response.json(); }) .then(data => { stationsData = data; // Update the filter dropdowns updateFilters(); // Render the data based on current view renderData(); // Update timestamp const updateTime = new Date().toUTCString(); document.getElementById('update-time').textContent = updateTime; document.getElementById('refresh-status').textContent = 'Last refresh: ' + new Date().toLocaleTimeString(); // Hide loading state document.getElementById('loading').style.display = 'none'; document.getElementById('error-message').style.display = 'none'; // Setup next refresh setupNextRefresh(); }) .catch(error => { console.error('Error fetching data:', error); document.getElementById('error-message').textContent = `Error loading data: ${error.message}`; document.getElementById('error-message').style.display = 'block'; document.getElementById('loading').style.display = 'none'; // Still setup next refresh to try again setupNextRefresh(); }) .finally(() => { isRefreshing = false; }); } // Function to update the filter dropdowns based on available data function updateFilters() { // Get unique networks const networks = [...new Set(stationsData.map(station => station.network))].sort(); // Update network filter const networkFilter = document.getElementById('network-filter'); const selectedNetwork = networkFilter.value; // Clear existing options except the first one while (networkFilter.options.length > 1) { networkFilter.remove(1); } // Add new options networks.forEach(network => { const option = document.createElement('option'); option.value = network; option.textContent = network; networkFilter.appendChild(option); }); // Restore selection if possible if (selectedNetwork && networks.includes(selectedNetwork)) { networkFilter.value = selectedNetwork; } } // Function to apply filters to the data function applyFilters() { const networkFilter = document.getElementById('network-filter').value; const statusFilter = document.getElementById('status-filter').value; const channelFilter = document.getElementById('channel-filter')?.value || ''; const searchText = document.getElementById('search-input').value.toLowerCase(); // Apply filters to data let filteredData = stationsData; if (networkFilter) { filteredData = filteredData.filter(station => station.network === networkFilter); } if (statusFilter) { filteredData = filteredData.filter(station => { if (statusFilter === 'good') { return station.status === 'good'; } else if (statusFilter === 'warning') { return ['delayed', 'long-delayed', 'very-delayed', 'hour-delayed', 'warning'].includes(station.status); } else if (statusFilter === 'critical') { return ['critical', 'day-delayed', 'multi-day', 'three-day', 'four-day'].includes(station.status); } else if (statusFilter === 'unavailable') { return station.status === 'unavailable'; } return true; }); } if (channelFilter) { filteredData = filteredData.filter(station => station.primaryChannelType === channelFilter || (channelFilter === 'other' && !['HH', 'BH', 'LH', 'SH', 'EH'].includes(station.primaryChannelType)) ); } if (searchText) { filteredData = filteredData.filter(station => `${station.network}_${station.station}`.toLowerCase().includes(searchText) ); } // Render filtered data renderData(filteredData); } // Function to render the data in the current view function renderData(data = stationsData) { // Default to all data if not specified const displayData = data || stationsData; // Render based on current view mode if (viewMode === 'table') { renderTableView(displayData); } else if (viewMode === 'grid') { renderGridView(displayData); } else if (viewMode === 'map') { // Update map markers if map is initialized if (mapInitialized) { updateMapMarkers(); } } } // Function to render table view function renderTableView(data) { const tableBody = document.getElementById('table-body'); tableBody.innerHTML = ''; data.forEach(station => { const row = document.createElement('tr'); // Network-Station cell const nameCell = document.createElement('td'); nameCell.innerHTML = `${station.network} ${station.station}`; // Add a badge for primary channel type if (station.primaryChannelType) { nameCell.innerHTML += ` ${station.primaryChannelType}`; } row.appendChild(nameCell); // Status cell const statusCell = document.createElement('td'); statusCell.classList.add(`station-${station.status}`); statusCell.textContent = formatStatus(station.status, station.primaryChannelType); row.appendChild(statusCell); // Latency cell const latencyCell = document.createElement('td'); latencyCell.textContent = formatLatency(station.latency); latencyCell.style.backgroundColor = getStatusColor(station.status); row.appendChild(latencyCell); // Channels cell const channelsCell = document.createElement('td'); // Create channel type summary const channelGroups = {}; station.channels.forEach(channel => { const type = channel.channelType || 'other'; if (!channelGroups[type]) { channelGroups[type] = 0; } channelGroups[type]++; }); // Format channel groups const groupsHTML = Object.keys(channelGroups).map(type => `${type}: ${channelGroups[type]}` ).join(' '); channelsCell.innerHTML = `
${station.channels.length} total
${groupsHTML}
`; row.appendChild(channelsCell); // Last updated cell const lastDataCell = document.createElement('td'); if (station.channels.length > 0 && station.channels[0].last_data) { const lastDataTime = new Date(station.channels[0].last_data); lastDataCell.textContent = lastDataTime.toLocaleString(); } else { lastDataCell.textContent = 'Unknown'; } row.appendChild(lastDataCell); tableBody.appendChild(row); }); // Add CSS for channel badges if not already added if (!document.getElementById('channel-badges-css')) { const style = document.createElement('style'); style.id = 'channel-badges-css'; style.textContent = ` .channel-badge { background-color: var(--bg-tertiary); color: var(--text-secondary); font-size: 10px; padding: 2px 4px; border-radius: 4px; margin-left: 4px; font-weight: 500; vertical-align: middle; } .channel-groups { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; font-size: 11px; } .channel-group { background-color: var(--bg-tertiary); border-radius: 3px; padding: 1px 4px; color: var(--text-secondary); } `; document.head.appendChild(style); } } // Function to render grid view function renderGridView(data) { const gridContainer = document.getElementById('grid-container'); gridContainer.innerHTML = ''; // Group stations by network const networks = {}; data.forEach(station => { if (!networks[station.network]) { networks[station.network] = []; } networks[station.network].push(station); }); // Sort networks by name const sortedNetworks = Object.keys(networks).sort(); for (const network of sortedNetworks) { const stations = networks[network]; // Create a row for the network const networkRow = document.createElement('div'); networkRow.className = 'grid-row'; // Add network label as a separate cell const networkLabel = document.createElement('div'); networkLabel.className = 'network-label'; networkLabel.textContent = network; networkRow.appendChild(networkLabel); // Create a container for the stations const stationsContainer = document.createElement('div'); stationsContainer.className = 'stations-container'; // Create a row for the stations const stationsRow = document.createElement('div'); stationsRow.className = 'stations-row'; // Sort stations by name stations.sort((a, b) => a.station.localeCompare(b.station)); // Add stations for (const station of stations) { const stationCell = document.createElement('a'); stationCell.className = `grid-cell station-${station.status}`; stationCell.href = `${station.network}_${station.station}.html`; stationCell.textContent = station.station; stationCell.setAttribute('data-tooltip', `${station.network}_${station.station}: ${formatLatency(station.latency)}`); stationCell.setAttribute('data-network', station.network); stationCell.setAttribute('data-station', station.station); stationCell.setAttribute('data-status', station.status); stationCell.setAttribute('data-latency', formatLatency(station.latency)); stationsRow.appendChild(stationCell); } stationsContainer.appendChild(stationsRow); networkRow.appendChild(stationsContainer); gridContainer.appendChild(networkRow); } } // Function to format latency for display function formatLatency(seconds) { if (seconds === null || seconds === undefined) return 'n/a'; if (seconds > 86400) return `${(seconds/86400).toFixed(1)} d`; if (seconds > 3600) return `${(seconds/3600).toFixed(1)} h`; if (seconds > 60) return `${(seconds/60).toFixed(1)} m`; return `${seconds.toFixed(1)} s`; } // Function to format status for display function formatStatus(status, channelType) { const isLH = channelType === 'LH'; // Add (LH) tag to status labels for LH channels const lhSuffix = isLH ? ' (LH)' : ''; if (status === 'good') return 'Good' + lhSuffix; if (status === 'delayed') return 'Delayed (>1m)' + lhSuffix; if (status === 'long-delayed') return 'Delayed (>10m)' + lhSuffix; if (status === 'very-delayed') return 'Delayed (>30m)' + lhSuffix; if (status === 'hour-delayed') return 'Delayed (>1h)' + lhSuffix; if (status === 'warning') return 'Warning (>2h)' + lhSuffix; if (status === 'critical') return 'Critical (>6h)' + lhSuffix; if (status === 'day-delayed') return 'Delayed (>1d)' + lhSuffix; if (status === 'multi-day') return 'Delayed (>2d)' + lhSuffix; if (status === 'three-day') return 'Delayed (>3d)' + lhSuffix; if (status === 'four-day') return 'Delayed (>4d)' + lhSuffix; if (status === 'unavailable') return 'Unavailable (>5d)' + lhSuffix; return status.charAt(0).toUpperCase() + status.slice(1) + lhSuffix; } // Function to get color for a status function getStatusColor(status) { const colors = { 'good': '#FFFFFF', 'delayed': '#EBD6FF', 'long-delayed': '#9470BB', 'very-delayed': '#3399FF', 'hour-delayed': '#00FF00', 'warning': '#FFFF00', 'critical': '#FF9966', 'day-delayed': '#FF3333', 'multi-day': '#FFB3B3', 'three-day': '#CCCCCC', 'four-day': '#999999', 'unavailable': '#666666' }; return colors[status] || '#FFFFFF'; } // Function to update the refresh interval function updateRefreshInterval(seconds) { // Update current refresh interval currentRefreshInterval = seconds; // Clear existing timer if (refreshTimer) { clearTimeout(refreshTimer); } // Store the preference in localStorage localStorage.setItem('seedlinkRefreshInterval', seconds); // Set up next refresh setupNextRefresh(); return seconds; } // Function to set up the next refresh function setupNextRefresh() { // Calculate time until next refresh const timeUntilNextRefresh = Math.max(1000, (currentRefreshInterval * 1000)); // Clear existing timer if (refreshTimer) { clearTimeout(refreshTimer); } // Set new timer refreshTimer = setTimeout(fetchData, timeUntilNextRefresh); // Update the display document.getElementById('refresh-interval').value = currentRefreshInterval; document.getElementById('next-refresh').textContent = currentRefreshInterval; // Start countdown startRefreshCountdown(); } // Function to start countdown to next refresh function startRefreshCountdown() { const countdownElement = document.getElementById('next-refresh'); const currentValue = parseInt(countdownElement.textContent); // Clear any existing interval if (window.countdownInterval) { clearInterval(window.countdownInterval); } // Set new interval window.countdownInterval = setInterval(() => { const newValue = parseInt(countdownElement.textContent) - 1; if (newValue > 0) { countdownElement.textContent = newValue; } else { clearInterval(window.countdownInterval); } }, 1000); } // Function to export data to CSV function exportToCsv() { // Create CSV content let csvContent = 'Network,Station,Status,Latency,Channels,Last Updated\\n'; stationsData.forEach(station => { const lastUpdate = station.channels.length > 0 && station.channels[0].last_data ? new Date(station.channels[0].last_data).toISOString() : 'Unknown'; csvContent += `${station.network},${station.station},${station.status},${station.latency},${station.channels.length},${lastUpdate}\\n`; }); // Create download link const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); link.setAttribute('download', `seedlink-status-${new Date().toISOString().split('T')[0]}.csv`); link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); // Clean up setTimeout(() => { URL.revokeObjectURL(url); }, 100); } // Function to toggle stats display function toggleStats() { const statsContainer = document.getElementById('stats-container'); if (statsContainer.style.display === 'block') { statsContainer.style.display = 'none'; return; } // Show the stats container statsContainer.style.display = 'block'; // Calculate statistics calculateStats(); } // Function to calculate and display statistics function calculateStats() { const networks = {}; let totalStations = stationsData.length; let goodStations = 0; let warningStations = 0; let criticalStations = 0; let unavailableStations = 0; // Group by network and count statuses stationsData.forEach(station => { // Create network entry if it doesn't exist if (!networks[station.network]) { networks[station.network] = { total: 0, good: 0, warning: 0, critical: 0, unavailable: 0 }; } // Count by network networks[station.network].total++; // Count by status if (station.status === 'good') { networks[station.network].good++; goodStations++; } else if (['delayed', 'long-delayed', 'very-delayed', 'hour-delayed', 'warning'].includes(station.status)) { networks[station.network].warning++; warningStations++; } else if (['critical', 'day-delayed', 'multi-day', 'three-day', 'four-day'].includes(station.status)) { networks[station.network].critical++; criticalStations++; } else if (station.status === 'unavailable') { networks[station.network].unavailable++; unavailableStations++; } }); // Update status counter const statusCounter = document.getElementById('status-counter'); statusCounter.innerHTML = `
${totalStations - unavailableStations} active of ${totalStations} total stations
${goodStations} good
${warningStations} warning
${criticalStations} critical
${unavailableStations} unavailable
`; // Update network stats const networkStats = document.getElementById('network-stats'); networkStats.innerHTML = ''; Object.keys(networks).sort().forEach(network => { const stats = networks[network]; const activePercentage = Math.round(((stats.total - stats.unavailable) / stats.total) * 100); const networkStat = document.createElement('div'); networkStat.className = 'network-stat'; // Create the name and count display const nameContainer = document.createElement('div'); nameContainer.className = 'network-name'; nameContainer.innerHTML = ` ${network} ${stats.total - stats.unavailable}/${stats.total} `; // Create the progress bar const progressContainer = document.createElement('div'); progressContainer.className = 'progress-bar'; const progressBar = document.createElement('div'); progressBar.className = 'progress'; progressBar.style.width = `${activePercentage}%`; progressContainer.appendChild(progressBar); networkStat.appendChild(nameContainer); networkStat.appendChild(progressContainer); networkStats.appendChild(networkStat); }); } function sortTable(column) { // Get current sort direction from the header const header = document.querySelector(`th[data-sort="${column}"]`); const currentDirection = header.getAttribute('data-direction') || 'asc'; const newDirection = currentDirection === 'asc' ? 'desc' : 'asc'; // Update all headers to remove sort indicators document.querySelectorAll('th[data-sort]').forEach(th => { th.setAttribute('data-direction', ''); th.classList.remove('sort-asc', 'sort-desc'); }); // Set direction on current header header.setAttribute('data-direction', newDirection); header.classList.add(`sort-${newDirection}`); // Sort data based on column stationsData.sort((a, b) => { let valueA, valueB; if (column === 'name') { valueA = `${a.network}_${a.station}`; valueB = `${b.network}_${b.station}`; } else if (column === 'status') { valueA = a.status; valueB = b.status; } else if (column === 'latency') { valueA = a.latency; valueB = b.latency; } else if (column === 'channels') { valueA = a.channels.length; valueB = b.channels.length; } else if (column === 'updated') { valueA = a.channels.length > 0 ? new Date(a.channels[0].last_data || 0).getTime() : 0; valueB = b.channels.length > 0 ? new Date(b.channels[0].last_data || 0).getTime() : 0; } // Handle string comparison if (typeof valueA === 'string') { if (newDirection === 'asc') { return valueA.localeCompare(valueB); } else { return valueB.localeCompare(valueA); } } // Handle number comparison else { if (newDirection === 'asc') { return valueA - valueB; } else { return valueB - valueA; } } }); // Re-render the table with sorted data renderTableView(stationsData); } // Utility function for debouncing function debounce(func, wait) { let timeout; return function(...args) { const context = this; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), wait); }; } """ try: js_path = os.path.join(config['setup']['wwwdir'], 'script.js') with open(js_path, 'w') as f: f.write(js_content) print(f"JavaScript file generated at {js_path}") return js_path except Exception as e: print(f"Error generating JavaScript file: {str(e)}") return None def generate_html_base(config, title, active_view): """Generate base HTML structure for all pages""" # Determine if map plugins should be included include_map_plugins = 'enable_map' in config['setup'] and config['setup']['enable_map'] # Start with the first part using proper variable interpolation html = f""" {config['setup']['title']} - {title} """ # Include additional map plugins if map is enabled if include_map_plugins: html += """ """ # Continue with the second part, but using f-string instead of .format() html += f"""

{config['setup']['title']}

Real-time seismic station monitoring dashboard

seconds
Last refresh: -
Next in {int(config['setup']['refresh'])} seconds
""" return html def generate_main_html(config, status): """Generate the main index.html with all three views""" html = generate_html_base(config, "Dashboard", "table") map_settings = get_map_settings(config) # Add filters html += f"""
Station Statistics
Loading station data...
Station Status Latency Channels Last Updated
""" # Add legend html += """
Good (≤ 1 min)
> 1 min
> 10 min
> 30 min
> 1 hour
> 2 hours
> 6 hours
> 1 day
> 2 days
> 3 days
> 4 days
> 5 days
""" # Add footer and close tags html += f"""
""" try: html_path = os.path.join(config['setup']['wwwdir'], 'index.html') with open(html_path, 'w') as f: f.write(html) print(f"Main HTML file generated at {html_path}") return html_path except Exception as e: print(f"Error generating main HTML file: {str(e)}") return None def generate_station_html(net_sta, config, status): """Generate individual station HTML page""" try: network, station = net_sta.split("_") except: print(f"Invalid station identifier: {net_sta}") return None html = generate_html_base(config, f"Station {station}", "table") # Add station info html += f"""

{network}_{station}

""" # Add station information if available try: if 'info' in config.station[net_sta]: html += f'
{config.station[net_sta]["info"]}
' except: pass html += """
""" # Add custom text if available try: if 'text' in config.station[net_sta]: html += f'

{config.station[net_sta]["text"]}

' except: pass # Station details table html += """
""" now = datetime.utcnow() netsta2 = net_sta.replace("_", ".") streams = [x for x in list(status.keys()) if x.find(netsta2) == 0] streams.sort() for label in streams: tim1 = status[label].last_data tim2 = status[label].last_feed lat1, lat2, lat3 = now-tim1, now-tim2, tim2-tim1 col1, col2, col3 = getColor(lat1), getColor(lat2), getColor(lat3) if lat1 == lat2: lat2 = lat3 = None if label[-2] == '.' and label[-1] in "DE": label = label[:-2] n, s, loc, c = label.split(".") c = ("%s.%s" % (loc, c)).strip(".") time1_str = tim1.strftime("%Y/%m/%d %H:%M:%S") if tim1 else "n/a" time2_str = tim2.strftime("%Y/%m/%d %H:%M:%S") if tim2 else "n/a" html += f""" """ html += """
Channel Last Sample Data Latency Last Received Feed Latency Diff
{s} {c} {time1_str} {formatLatency(lat1)} {time2_str} {formatLatency(lat2)} {formatLatency(lat3)}
""" # Legend html += """
Good (≤ 1 min)
> 1 min
> 10 min
> 30 min
> 1 hour
> 2 hours
""" # Links html += '\n' # Add footer and close tags html += f""" """ try: html_path = os.path.join(config['setup']['wwwdir'], f'{net_sta}.html') with open(html_path, 'w') as f: f.write(html) print(f"Station HTML file generated at {html_path}") return html_path except Exception as e: print(f"Error generating station HTML file: {str(e)}") return None def generate_json_data(status): """Generate a JSON file with station data for JavaScript use""" try: json_data = status.to_json() json_path = os.path.join(config['setup']['wwwdir'], 'stations_data.json') with open(json_path, 'w') as f: f.write(json_data) print(f"JSON data file generated at {json_path}") return json_path except Exception as e: print(f"Error generating JSON data file: {str(e)}") return None def generate_all_files(config, status): """Generate all the static files needed for the web interface""" # Create the directory if it doesn't exist try: os.makedirs(config['setup']['wwwdir'], exist_ok=True) except Exception as e: print(f"Error creating directory: {str(e)}") return False # Generate files css_path = generate_css_file(config) js_path = generate_js_file(config) json_path = generate_json_data(status) main_html = generate_main_html(config, status) # Generate station pages - Get UNIQUE station identifiers unique_stations = set() for k in status: net_sta = f"{status[k].net}_{status[k].sta}" unique_stations.add(net_sta) # Now generate each station page exactly once station_htmls = [] for net_sta in unique_stations: html_path = generate_station_html(net_sta, config, status) station_htmls.append(html_path is not None) # Return success only if all files were generated all_stations_success = len(station_htmls) > 0 and all(station_htmls) # Log success or failure if all_stations_success: print(f"Successfully generated {len(station_htmls)} station HTML files") else: print(f"ERROR: Failed to generate some station HTML files") # Return success if all files were generated return all([css_path, js_path, json_path, main_html, all_stations_success]) def read_ini(): """Read configuration files""" global config, ini_setup, ini_stations print("reading setup config from '%s'" % ini_setup) if not os.path.isfile(ini_setup): print("[error] setup config '%s' does not exist" % ini_setup, file=sys.stderr) usage(exitcode=2) config = MyConfig(ini_setup) print("reading station config from '%s'" % ini_stations) if not os.path.isfile(ini_stations): print("[error] station config '%s' does not exist" % ini_stations, file=sys.stderr) usage(exitcode=2) config.station = MyConfig(ini_stations) def SIGINT_handler(signum, frame): """Handle interruption signals""" global status print("received signal #%d => will write status file and exit" % signum) sys.exit(0) def main(): """Main function to run the program""" global config, status, verbose, generate_only, ini_setup, ini_stations # Parse command line arguments try: opts, args = getopt(sys.argv[1:], "c:s:t:hvg", ["help", "generate"]) except GetoptError: print("\nUnknown option in "+str(sys.argv[1:])+" - EXIT.", file=sys.stderr) usage(exitcode=2) for flag, arg in opts: if flag == "-c": ini_setup = arg elif flag == "-s": ini_stations = arg elif flag == "-t": refresh = float(arg) # XXX not yet used elif flag in ("-h", "--help"): usage(exitcode=0) elif flag == "-v": verbose = 1 elif flag in ("-g", "--generate"): generate_only = True # Set up signal handlers signal.signal(signal.SIGHUP, SIGINT_handler) signal.signal(signal.SIGINT, SIGINT_handler) signal.signal(signal.SIGQUIT, SIGINT_handler) signal.signal(signal.SIGTERM, SIGINT_handler) # Read configuration read_ini() # Load station coordinates from the FDSN web service try: load_station_coordinates(config) except Exception as e: print(f"Warning: Failed to load station coordinates: {str(e)}") # Prepare station information s = config.station net_sta = ["%s_%s" % (s[k]['net'], s[k]['sta']) for k in s] s_arg = ','.join(net_sta) # Set server from config or use default if 'server' in config['setup']: server = config['setup']['server'] else: server = "localhost" # Initialize status dictionary status = StatusDict() print("generating output to '%s'" % config['setup']['wwwdir']) if generate_only: # Generate template files without fetching data print("Generating template files only...") # Create dummy data for template rendering for net_sta_item in net_sta: net, sta = net_sta_item.split('_') d = Status() d.net = net d.sta = sta d.loc = "" d.cha = "HHZ" d.typ = "D" d.last_data = datetime.utcnow() d.last_feed = datetime.utcnow() sec = "%s.%s.%s.%s.%c" % (d.net, d.sta, d.loc, d.cha, d.typ) status[sec] = d # Generate all files if generate_all_files(config, status): print("Template files generated successfully.") else: print("Error generating template files.") sys.exit(0) # Get initial data print("getting initial time windows from SeedLink server '%s'" % server) status.fromSlinkTool(server, stations=net_sta) if verbose: status.write(sys.stderr) # Generate initial files generate_all_files(config, status) # Set up the next time to generate files nextTimeGenerateHTML = time() print("setting up connection to SeedLink server '%s'" % server) # Connect to the SeedLink server and start receiving data input = seiscomp.slclient.Input(server, [(s[k]['net'], s[k]['sta'], "", "???") for k in s]) for rec in input: id = '.'.join([rec.net, rec.sta, rec.loc, rec.cha, rec.rectype]) try: status[id].last_data = rec.end_time status[id].last_feed = datetime.utcnow() except: continue if time() > nextTimeGenerateHTML: generate_all_files(config, status) nextTimeGenerateHTML = time() + int(config['setup']['refresh']) if __name__ == "__main__": main()