Files
2025/bin/slmon2

3674 lines
117 KiB
Plaintext
Executable File

#!/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 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
Light Mode
`;
} else {
themeToggle.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
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 = '<div class="error-message">Map library not available. Please check your internet connection.</div>';
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}),
'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Imagery &copy; Esri &copy; ArcGIS',
maxZoom: 19
}),
'Terrain': L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a>',
maxZoom: 17
}),
'Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
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: `<div style="background-color: ${color}; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 2px solid white; color: ${textColor}; font-weight: bold;">${count}</div>`,
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 &copy; Esri &copy; ArcGIS',
maxZoom: 19
}).addTo(map);
} else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
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 &copy; Esri &copy; ArcGIS',
maxZoom: 19
}).addTo(map);
} else if (lightLayer === 'terrain') {
L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a>',
maxZoom: 17
}).addTo(map);
} else {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
}
} else {
// Default light theme
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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
? `<div style="background-color: ${markerColor}; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white; position: relative;">
<span style="position: absolute; top: -8px; right: -8px; background: #f3f4f6; border-radius: 50%; width: 10px; height: 10px; font-size: 7px; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #1f2937;">L</span>
</div>`
: `<div style="background-color: ${markerColor}; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white;"></div>`,
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(`
<strong>${station.network}_${station.station}</strong><br>
Primary channel type: <b>${station.primaryChannelType || 'N/A'}</b><br>
Status: ${formatStatus(station.status, station.primaryChannelType)}<br>
Latency: ${formatLatency(station.latency)}<br>
Channels: ${channelGroupsHTML}<br>
Coordinates: ${lat.toFixed(4)}, ${lon.toFixed(4)}
${station.coordinates.elevation ? '<br>Elevation: ' + station.coordinates.elevation.toFixed(1) + ' m' : ''}
<br><a href="${station.network}_${station.station}.html" target="_blank">View Details</a>
`);
// 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 = `
<p><strong>No station coordinates available</strong></p>
<p>Make sure your FDSNWS service is properly configured and accessible.</p>
`;
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 = `
<h4>Station Status</h4>
<div><span style="background-color: #FFFFFF"></span> Good (&le; 1 min)</div>
<div><span style="background-color: #c084fc"></span> &gt; 1 min</div>
<div><span style="background-color: #8b5cf6"></span> &gt; 10 min</div>
<div><span style="background-color: #3b82f6"></span> &gt; 30 min</div>
<div><span style="background-color: #10b981"></span> &gt; 1 hour</div>
<div><span style="background-color: #fbbf24"></span> &gt; 2 hours</div>
<div><span style="background-color: #f97316"></span> &gt; 6 hours</div>
<div><span style="background-color: #ef4444"></span> &gt; 1 day</div>
`;
// 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 = `
<label for="channel-filter">Channel Type:</label>
<select id="channel-filter">
<option value="">All Types</option>
<option value="HH">HH (High Frequency)</option>
<option value="BH">BH (Broadband)</option>
<option value="LH">LH (Long Period)</option>
<option value="SH">SH (Short Period)</option>
<option value="EH">EH (Extremely Short Period)</option>
<option value="other">Other</option>
</select>
`;
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 = `
<div class="filter-select">
<label for="map-network-filter">Network:</label>
<select id="map-network-filter">
<option value="">All Networks</option>
</select>
</div>
<div class="filter-select">
<label for="map-status-filter">Status:</label>
<select id="map-status-filter">
<option value="">All Statuses</option>
<option value="good">Good</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
<option value="unavailable">Unavailable</option>
</select>
</div>
`;
// 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: `<div style="background-color: ${markerColor}; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white;"></div>`,
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(`
<strong>${station.network}_${station.station}</strong><br>
Status: ${formatStatus(station.status)}<br>
Latency: ${formatLatency(station.latency)}<br>
Coordinates: ${lat.toFixed(4)}, ${lon.toFixed(4)}
${station.coordinates.elevation ? '<br>Elevation: ' + station.coordinates.elevation.toFixed(1) + ' m' : ''}
<br><a href="${station.network}_${station.station}.html" target="_blank">View Details</a>
`);
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 = `<small>${station.network}</small> <a href="${station.network}_${station.station}.html">${station.station}</a>`;
// Add a badge for primary channel type
if (station.primaryChannelType) {
nameCell.innerHTML += ` <span class="channel-badge">${station.primaryChannelType}</span>`;
}
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 =>
`<span class="channel-group">${type}: ${channelGroups[type]}</span>`
).join(' ');
channelsCell.innerHTML = `<div>${station.channels.length} total</div><div class="channel-groups">${groupsHTML}</div>`;
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 = `
<div style="font-weight: 600; margin-bottom: 10px; font-size: 16px;">
${totalStations - unavailableStations} active of ${totalStations} total stations
</div>
<div style="display: flex; gap: 20px; margin-bottom: 15px; justify-content: center; flex-wrap: wrap;">
<div>
<span style="color: var(--text-primary); font-weight: 500;">${goodStations}</span> good
</div>
<div>
<span style="color: var(--status-warning); font-weight: 500;">${warningStations}</span> warning
</div>
<div>
<span style="color: var(--status-critical); font-weight: 500;">${criticalStations}</span> critical
</div>
<div>
<span style="color: var(--status-unavailable); font-weight: 500;">${unavailableStations}</span> unavailable
</div>
</div>
`;
// 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 = `
<span>${network}</span>
<span>${stats.total - stats.unavailable}/${stats.total}</span>
`;
// 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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{config['setup']['title']} - {title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<link rel="shortcut icon" href="{config['setup']['icon']}">
<!-- meta http-equiv="refresh" content="{int(config['setup']['refresh'])}" -->
<!-- Include Leaflet for map view -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"></script>
"""
# Include additional map plugins if map is enabled
if include_map_plugins:
html += """
<!-- Leaflet plugins -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.fullscreen@1.6.0/Control.FullScreen.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.locatecontrol@0.76.0/dist/L.Control.Locate.min.css" />
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/leaflet.fullscreen@1.6.0/Control.FullScreen.js"></script>
<script src="https://unpkg.com/leaflet.locatecontrol@0.76.0/dist/L.Control.Locate.min.js"></script>
<script src="https://unpkg.com/leaflet-measure-path@1.5.0/leaflet-measure-path.js"></script>
"""
# Continue with the second part, but using f-string instead of .format()
html += f"""
</head>
<body>
<div class="container">
<div class="header">
<h1>{config['setup']['title']}</h1>
<div class="view-toggle">
<a href="index.html" data-view="table" class="{'active' if active_view == 'table' else ''}">Table View</a>
<a href="index.html?view=grid" data-view="grid" class="{'active' if active_view == 'grid' else ''}">Grid View</a>
<a href="index.html?view=map" data-view="map" class="{'active' if active_view == 'map' else ''}">Map View</a>
</div>
</div>
<p class="subtitle">Real-time seismic station monitoring dashboard</p>
<div class="controls">
<div class="actions">
<button id="stats-toggle" class="action-button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
Station Stats
</button>
<button id="export-csv" class="action-button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export CSV
</button>
<button id="theme-toggle" class="action-button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
Dark Mode
</button>
</div>
<div class="refresh-control">
<div class="input-group">
<label for="refresh-interval">Auto refresh:</label>
<input type="number" id="refresh-interval" min="10" value="{int(config['setup']['refresh'])}">
<span>seconds</span>
</div>
<button id="apply-refresh">Apply</button>
<button id="refresh-now">Refresh Now</button>
<div class="status-counter">
<div id="refresh-status">Last refresh: -</div>
<div class="countdown">Next in <span id="next-refresh">{int(config['setup']['refresh'])}</span> seconds</div>
</div>
</div>
</div>
"""
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"""
<script>
//Map configuration settings
windows.mapSettings = {json.dumps(map_settings)};
</script>
<div class="filters">
<div class="filter-group">
<label for="network-filter">Network:</label>
<select id="network-filter">
<option value="">All Networks</option>
</select>
</div>
<div class="filter-group">
<label for="status-filter">Status:</label>
<select id="status-filter">
<option value="">All Statuses</option>
<option value="good">Good</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
<option value="unavailable">Unavailable</option>
</select>
</div>
<div class="filter-group">
<input type="text" id="search-input" placeholder="Search stations..." class="search-box">
</div>
</div>
<div id="stats-container" class="stats-container">
<div class="stats-header">
<div class="stats-title">Station Statistics</div>
<button id="close-stats" class="action-button">Close</button>
</div>
<div id="status-counter"></div>
<div id="network-stats" class="network-stats">
<!-- Network stats will be inserted here -->
</div>
</div>
<div id="loading">
<div class="loading-spinner"></div>
Loading station data...
</div>
<div id="error-message"></div>
<!-- Table View -->
<div id="table-view" class="view-container">
<div class="table-container">
<table>
<thead>
<tr>
<th data-sort="name">Station</th>
<th data-sort="status">Status</th>
<th data-sort="latency">Latency</th>
<th data-sort="channels">Channels</th>
<th data-sort="updated">Last Updated</th>
</tr>
</thead>
<tbody id="table-body">
<!-- Table rows will be inserted here -->
</tbody>
</table>
</div>
</div>
<!-- Grid View -->
<div id="grid-view" class="view-container" style="display: none;">
<div id="grid-container" class="grid-container">
<!-- Grid will be inserted here -->
</div>
</div>
<!-- Map View -->
<div id="map-view" class="view-container" style="display: none;">
<div id="map-container" class="map-container">
<!-- Map will be rendered here -->
</div>
</div>
"""
# Add legend
html += """
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background-color: #ffffff; border: 1px solid #e5e7eb;"></div>
<span>Good (&le; 1 min)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #c084fc;"></div>
<span>&gt; 1 min</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #8b5cf6;"></div>
<span>&gt; 10 min</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #3b82f6;"></div>
<span>&gt; 30 min</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #10b981;"></div>
<span>&gt; 1 hour</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #fbbf24;"></div>
<span>&gt; 2 hours</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #f97316;"></div>
<span>&gt; 6 hours</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ef4444;"></div>
<span>&gt; 1 day</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #f87171;"></div>
<span>&gt; 2 days</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #d1d5db;"></div>
<span>&gt; 3 days</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #9ca3af;"></div>
<span>&gt; 4 days</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #6b7280;"></div>
<span>&gt; 5 days</span>
</div>
</div>
"""
# Add footer and close tags
html += f"""
<div class="footer">
<div>Last updated <span id="update-time">{gmtime()[:6][0]:04d}/{gmtime()[:6][1]:02d}/{gmtime()[:6][2]:02d} {gmtime()[:6][3]:02d}:{gmtime()[:6][4]:02d}:{gmtime()[:6][5]:02d} UTC</span></div>
<div><a href="{config['setup']['linkurl']}" target="_top">{config['setup']['linkname']}</a></div>
</div>
</div>
<!-- Export JSON data for JavaScript -->
<script>
// Initialize stationsData with server-side rendered data
const initialStationsData = {status.to_json()};
</script>
<!-- Include the main JavaScript file -->
<script src="script.js"></script>
</body>
</html>
"""
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"""
<div class="station-header">
<h2>{network}_{station}</h2>
"""
# Add station information if available
try:
if 'info' in config.station[net_sta]:
html += f'<div class="station-info">{config.station[net_sta]["info"]}</div>'
except:
pass
html += """
</div>
"""
# Add custom text if available
try:
if 'text' in config.station[net_sta]:
html += f'<p>{config.station[net_sta]["text"]}</p>'
except:
pass
# Station details table
html += """
<div class="table-container">
<table>
<thead>
<tr>
<th>Channel</th>
<th>Last Sample</th>
<th>Data Latency</th>
<th>Last Received</th>
<th>Feed Latency</th>
<th>Diff</th>
</tr>
</thead>
<tbody>
"""
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"""
<tr>
<td>{s} {c}</td>
<td>{time1_str}</td>
<td style="background-color:{col1}">{formatLatency(lat1)}</td>
<td>{time2_str}</td>
<td style="background-color:{col2}">{formatLatency(lat2)}</td>
<td style="background-color:{col3}">{formatLatency(lat3)}</td>
</tr>
"""
html += """
</tbody>
</table>
</div>
"""
# Legend
html += """
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background-color: #ffffff; border: 1px solid #e5e7eb;"></div>
<span>Good (&le; 1 min)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #c084fc;"></div>
<span>&gt; 1 min</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #8b5cf6;"></div>
<span>&gt; 10 min</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #3b82f6;"></div>
<span>&gt; 30 min</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #10b981;"></div>
<span>&gt; 1 hour</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #fbbf24;"></div>
<span>&gt; 2 hours</span>
</div>
</div>
"""
# Links
html += '<div class="links">\n'
html += '<p>Click here to <a href="index.html?view=grid" target="_blank">view in Grid View</a><br>\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 <a href="{url}" target="_blank">live seismogram</a> of station {s}</p>\n'
html += '</div>\n'
# Add footer and close tags
html += f"""
<div class="footer">
<div>Last updated {gmtime()[:6][0]:04d}/{gmtime()[:6][1]:02d}/{gmtime()[:6][2]:02d} {gmtime()[:6][3]:02d}:{gmtime()[:6][4]:02d}:{gmtime()[:6][5]:02d} UTC</div>
<div><a href="{config['setup']['linkurl']}" target="_top">{config['setup']['linkname']}</a></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
"""
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()