#!/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 = '
'; 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: `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 = `Real-time seismic station monitoring dashboard
| Station | Status | Latency | Channels | Last Updated | 
|---|
{config.station[net_sta]["text"]}
' except: pass # Station details table html += """| Channel | Last Sample | Data Latency | Last Received | Feed Latency | Diff | 
|---|---|---|---|---|---|
| {s} {c} | {time1_str} | {formatLatency(lat1)} | {time2_str} | {formatLatency(lat2)} | {formatLatency(lat3)} | 
Click here to view in Grid View
\n'
    if 'liveurl' in config['setup']:
        # Substitute '%s' in live_url by station name
        s = net_sta.split("_")[-1]
        url = config['setup']['liveurl'] % s
        html += f'View a live seismogram of station {s}