diff --git a/defaults/main.yaml b/defaults/main.yaml index 56114ad..52c5640 100644 --- a/defaults/main.yaml +++ b/defaults/main.yaml @@ -2,7 +2,7 @@ # required system packages cosmostat_packages: - - "{{ 'docker' if x64_arch else 'wmdocker' }}" + - "{{ '' if x64_arch else 'wmdocker' }}" - docker.io - docker-compose - python3 @@ -35,6 +35,9 @@ cosmostat_sudoers_content: | # subnet for service docker_subnet: "192.168.37.0/24" docker_gateway: "192.168.37.1" +cosmostat_server_ip: "10.200.27.20" +api_bind_ip: "{{ docker_gateway }}" + # cosmostat service folder root service_folder: "/opt/cosmostat" @@ -54,12 +57,13 @@ custom_api_port: "5000" service_control_web_folder: "{{ service_folder }}/web" public_dashboard: true custom_port: "80" +web_src: "/web" # other vars quick_refresh: false x64_arch: true -# cosmostat_settings +# cosmostat_settings, will be for special_server defaults noisy_test: false debug_output: true secure_api: true @@ -67,7 +71,7 @@ push_redis: true run_background : true log_output: true update_frequency: "1" -cosmostat_server: false -cosmostat_server_api: "http://10.200.27.20/" +cosmostat_server: true +cosmostat_server_api: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/" cosmostat_server_reporter: false ... \ No newline at end of file diff --git a/files/api/Cosmos_Settings.py b/files/api/Cosmos_Settings.py index 785c50c..bab2c72 100644 --- a/files/api/Cosmos_Settings.py +++ b/files/api/Cosmos_Settings.py @@ -1,4 +1,5 @@ import yaml +from urllib.parse import urlparse ####################################################################### ### Settings Handler Functions ####################################################################### @@ -32,8 +33,9 @@ with open('cosmostat_settings.yaml', 'r') as f: app_settings[setting] = cosmos_setting print("...Done") + # this returns the docker gateway from the settings -def docker_gateway_settings() -> str: +def cosmostat_bind_ip() -> str: return cosmostat_settings["docker_gateway"] # this returns the jenkins user that ran the pipeline @@ -51,9 +53,23 @@ def jenkins_inventory_generation_timestamp_settings() -> str: def run_cosmostat_server(): return cosmostat_settings["cosmostat_server"] +def run_cosmostat_reporter(): + result = False + if not cosmostat_settings["cosmostat_server"] and cosmostat_settings["cosmostat_server_reporter"]: + result = True + return result + def service_gateway_ip(): + result = "0.0.0.0" + if cosmostat_settings["cosmostat_server"]: + result = urlparse(cosmostat_settings["cosmostat_server_api"]).hostname + elif cosmostat_settings["secure_api"]: + result = cosmostat_bind_ip() + return result + +def redis_gateway_ip(): if cosmostat_settings["secure_api"]: - return docker_gateway_settings() + return cosmostat_bind_ip() else: return "0.0.0.0" @@ -63,7 +79,7 @@ def cosmostat_server_api(): def service_api_port(): return cosmostat_settings["custom_api_port"] -def log_data(log_output:str, log_level = cosmostat_settings["noisy_test"]): +def log_data(log_output:str, log_level = "noisy_test"): log_levels = [ "noisy_test", "debug_output", @@ -76,6 +92,9 @@ def log_data(log_output:str, log_level = cosmostat_settings["noisy_test"]): else: print(f"Warning - {log_level} not valid log level") +if app_settings["cosmostat_server"]: + app_settings["cosmostat_server_reporter"] = False + log_data(log_output = "Warning - server and reporter cannot run concurrently, server is prioritized.", log_level = cosmostat_settings["log_output"]) diff --git a/files/api/Cosmostat.py b/files/api/Cosmostat.py index 5b7771a..8ddcb26 100644 --- a/files/api/Cosmostat.py +++ b/files/api/Cosmostat.py @@ -21,7 +21,7 @@ from Cosmos_Settings import * ################################################################# ################################################################# -class Cosmostat: +class CosmostatServer: ############################################################ # instantiate new Cosmostat server @@ -32,7 +32,7 @@ class Cosmostat: self.name = name self.short_id = self.short_uuid(self.name) log_data(log_output = f"Cosmostat Server {self.short_id} initializing", log_level = "log_output") - # system contains an array of keys with component objects + # system contains an array of CosmostatClient Objects self.systems = [] def __str__(self): @@ -43,29 +43,38 @@ class Cosmostat: self_string = f"Cosmostat Server {self.short_id}" def add_system(self, system_dictionary: dict): - new_system_key = { - "data_timestamp": time.time(), - "uuid": system_dictionary["uuid"], - "short_id": system_dictionary["short_id"], - "client_properties": system_dictionary["client_properties"], - "redis_data": {} - } - log_data(log_output = f"Client system {system_dictionary["short_id"]} added", log_level = "log_output") - self.systems.append(new_system_key) + if not self.check_uuid(system_dictionary["uuid"]): + new_cosmostat_clilent = CosmostatClient( + name = system_dictionary["short_id"], + uuid = system_dictionary["uuid"], + hostname = system_dictionary["hostname"], + data_timestamp = time.time(), + client_properties = system_dictionary["client_properties"], + redis_data = {} + ) + self.systems.append(new_cosmostat_clilent) + log_data(log_output = f'Client system {system_dictionary["short_id"]} added', log_level = "log_output") + return new_cosmostat_clilent.data_timestamp - def update_system(self, system_state: {}, system_uuid: str): + + def update_system(self, system_dictionary: {}, system_uuid: str): this_system = self.get_system(system_uuid) - this_system["redis_data"] = system_state - this_system["data_timestamp"] = time.time() - log_data(log_output = f"Client system {this_system["short_id"]} addupdateded", log_level = "log_output") - return this_system["data_timestamp"] + this_system.redis_data = system_dictionary + this_system.data_timestamp = time.time() + log_data(log_output = f"Client system {this_system.name} update requested, {this_system.uuid}", log_level = "log_output") + data_age = time.time() - this_system.data_timestamp + if int(data_age) > 60: + self.systems.remove(this_system) + return this_system.data_timestamp - def get_system(self, system_uuid: str) -> dict: - result = {} + def get_system(self, system_uuid: str): + log_data(log_output = f'Cosmostat - get_system - {system_uuid}', log_level = "debug_output") + result = None for system in self.systems: - if system["uuid"] == system_uuid: - return system + if system.uuid == system_uuid: + result = system + break return result def short_uuid(self, value: str, length=8): @@ -73,3 +82,53 @@ class Cosmostat: hasher.update(value.encode('utf-8')) full_hex = hasher.hexdigest() return full_hex[:length] + + def check_uuid(self, uuid: str): + uuid_exists = False + for system in self.systems: + if system.uuid == uuid: + uuid_exists = True + return uuid_exists + + def get_client_hostname(self, system_uuid: str): + client = self.get_system(system_uuid) + return client.hostname + + def get_client_hostnames(self, send_age = False): + result = [] + for system in self.systems: + data_age = time.time() - system.data_timestamp + if int(data_age) > 60: + self.systems.remove(system) + else: + result.append(system.hostname) + return result + + +class CosmostatClient: + + ############################################################ + # instantiate new Cosmostat server + ############################################################ + + def __init__(self, name: str, uuid: str, hostname: str, data_timestamp: float, client_properties: dict, redis_data: dict): + self.name = name + self.uuid = uuid + self.hostname = hostname + self.data_timestamp = data_timestamp + self.client_properties = client_properties + self.redis_data = redis_data + + def __str__(self): + self_string = f'Cosmostat Client {self.name} - Hostname {self.hostname}' + return self_string + + def __repr__(self): + self_string = f'Cosmostat Client {self.name} - Hostname {self.hostname}' + return self_string + + def get_properties(self): + return self.client.properties + + def get_redis(self): + return self.redis_data \ No newline at end of file diff --git a/files/api/app.py b/files/api/app.py index d10bb9d..6ea1886 100644 --- a/files/api/app.py +++ b/files/api/app.py @@ -1,12 +1,17 @@ -from flask import Flask, jsonify, request +from flask import Flask, jsonify, request, Response from flask_apscheduler import APScheduler from typing import Dict, Union import json, time, redis, yaml import base64, hashlib +import requests +from requests import RequestException, Response + from Components import * from Cosmos_Settings import * +from Cosmostat import * + # declare flask apps app = Flask(__name__) scheduler = APScheduler() @@ -16,7 +21,7 @@ scheduler = APScheduler() ####################################################################### # Redis client - will publish updates -r = redis.Redis(host=service_gateway_ip(), port=6379) +r = redis.Redis(host=redis_gateway_ip(), port=6379) def update_redis_channel(redis_channel, data): # Publish to the specified Redis channel @@ -25,14 +30,14 @@ def update_redis_channel(redis_channel, data): def update_redis_server(): # Client Redis Tree - if not run_cosmostat_server(): - if cosmostat_client.check_system_timer(): + if cosmostat_client.check_system_timer(): update_redis_channel("host_metrics", get_client_redis_data(human_readable = False)) if run_cosmostat_server(): update_redis_channel("client_summary", get_server_redis_data()) + update_redis_channel("client_hostnames", get_server_hostnames()) - # Server Redis Tree + # History Redis Tree # Update history_stats Redis Channel # update_redis_channel("history_stats", get_component_list()) @@ -48,13 +53,17 @@ def get_server_redis_data(): result = [] for client in cosmostat_server.systems: this_client_key = { - "uuid": client["uuid"], - "short_id": client["short_id"], - "redis_data": client["redis_data"] + "hostname": client.hostname, + "uuid": client.uuid, + "short_id": client.name, + "redis_data": client.redis_data } result.append(this_client_key) return result +def get_server_hostnames(): + return cosmostat_server.get_client_hostnames() + ####################################################################### ### Client Flask Routes ####################################################################### @@ -175,30 +184,56 @@ def generate_state_definition(): ####################################################################### # update client on server -@app.route('/update_client', methods=['GET']) +@app.route('/update_client', methods=['POST']) def update_client(): result = {} - # check the request and return payload if all good + # check the request and return payload dict {} if all good payload = client_submit_check(request = request, dict_name = "redis_data") - this_client = cosmostat_server.get_system(uuid = payload["uuid"]) - result = run_update_client(this_client) + result = run_update_client(payload) return jsonify(result), 200 # create client on server -@app.route('/create_client', methods=['GET']) +@app.route('/create_client', methods=['POST']) def create_client(): result = {} - # check the request and return payload if all good + # check the request and return payload dict {} if all good payload = client_submit_check(request = request, dict_name = "client_properties") - this_client = cosmostat_server.get_system(uuid = payload["uuid"]) - result = run_create_client(this_client) + if not cosmostat_server.check_uuid(payload["uuid"]): + result = run_create_client(payload) + else: + result = {"message": "object already exists, skipping creation"} return jsonify(result), 200 # api to validate Cosmostat Class @app.route('/client_summary', methods=['GET']) def client_summary(): - client_summary = get_client_summary() - return jsonify() + result = [] + if run_cosmostat_server(): + result = get_client_summary() + else: + result = {"message": "server not running on this endpoint"} + return jsonify(result) + +# api to pull all data +@app.route('/client_details', methods=['GET']) +def client_details(): + result = [] + if run_cosmostat_server(): + result = get_client_details() + else: + result = {"message": "server not running on this endpoint"} + return jsonify(result) + +# api to get all hostnames +@app.route('/client_hostnames', methods=['GET']) +def client_hostnames(): + result = [] + if run_cosmostat_server(): + result = cosmostat_server.get_client_hostnames() + else: + result = {"message": "server not running on this endpoint"} + return jsonify(result) + ####################################################################### ### Server Flask Helpers @@ -206,29 +241,34 @@ def client_summary(): # update client on server def run_update_client(this_client): - if this_client == {}: + if not cosmostat_server.check_uuid(this_client["uuid"]): return { "message": "client not found" } - update_status = f"updated client {this_client.short_id}" - timestamp_update = cosmostat_server.update_system(system_state = payload, system_uuid = payload["uuid"]) - return { - "status": update_status, - "uuid": payload["uuid"], - "timestamp": timestamp_update - } + else: + timestamp_update = cosmostat_server.update_system(system_dictionary = this_client["redis_data"], system_uuid = this_client["uuid"]) + update_status = f'updated client {this_client["short_id"]}' + + return { + "status": update_status, + "uuid": this_client["uuid"], + "redis_data": this_client, + "timestamp_update": timestamp_update + } # create client on server def run_create_client(this_client): - update_status = f"created client {this_client.short_id}" - timestamp_update = cosmostat_server.create_system(system_state = payload, system_uuid = payload["uuid"]) + timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) + update_status = f'created client {this_client["short_id"]}' return { "status": update_status, - "uuid": payload["uuid"], - "timestamp": timestamp_update + "uuid": this_client["uuid"], + "client_properties": this_client, + "timestamp_update": timestamp_update } -# flask submission check fucntion +# flask submission check function def client_submit_check(request, dict_name: str): - required_keys = {"uuid", "short_id", "data_timestamp", dict_name} + payload = {} + required_keys = {"uuid", "short_id", "hostname", dict_name} if not request.is_json: logging.warning("Received non-JSON request") return jsonify({"error": "Content-type must be application/json"}), 400 @@ -239,97 +279,98 @@ def client_submit_check(request, dict_name: str): missing = required_keys - payload.keys() if missing: raise ValueError(f"Missing required keys: {', '.join(sorted(missing))}") - return payload # generate cosmostat server summary def get_client_summary(): result = [] for client in cosmostat_server.systems: - this_client_properties = client.get_system_properties(human_readable = True) - this_client_components = [] - for component in client.get_components(): - this_component = { - "component_name": component.name, - "info_strings": component.get_properties_strings(return_simple = True) - } - this_client_components.append(this_component) + data_age = time.time() - client.data_timestamp this_client = { - "client_properties": this_client_properties, - "client_components": this_client_components + "uuid": client.uuid, + "short_id": client.name, + "data_age": data_age, + "hostname": client.hostname } result.append(this_client) + if result == []: + result = {"message": "no clients reporting"} + return result + +# no redis data needed here +def get_client_details(): + result = [] + for client in cosmostat_server.systems: + data_age = time.time() - client.data_timestamp + this_client = { + "uuid": client.uuid, + "short_id": client.name, + "client_properties": client.client_properties, + # "redis_data": client.redis_data, + "hostname": client.hostname + } + result.append(this_client) + if result == []: + result = {"message": "no clients reporting"} return result ####################################################################### ### Cosmostat Client Subroutines ####################################################################### +# since the API isn't running +# def local_client_update(): + # Cosmostat Client Reporter -def client_update(this_client: dict, api_endpoint = "update_client"): - # set variables for API call - this_uuid = cosmostat_client.uuid - this_short_id = cosmostat_client.short_id - this_timestamp = time.time() - api_url = f"{cosmostat_server_api()}{api_endpoint}" - # generate payload - payload = { - "uuid": this_uuid, - "short_id": this_short_id, - "data_timestamp": this_timestamp, # Unix epoch float - "redis_data": get_client_redis_data(human_readable = False), - } +def client_update(): + api_url = f"{cosmostat_server_api()}update_client" + payload = get_client_payload(get_client_redis_data(human_readable = False), "redis_data") + log_data(log_output = "client_update - redis data from local client:", log_level = "noisy_test") + log_data(log_output = payload, log_level = "noisy_test") # execute API call - result = client_submission_handler() - if ( - isinstance(result, dict) - and result.get("message", "").lower() == "client not found" - ): - # if client not found, create client - if api_endpoint == "update_client": - client_initialize() - raise RuntimeError("Client not found - initializing") + result = client_submission_handler(api_url, payload) + client_initialize() return result # Cosmostat Client Initializer def client_initialize(): - # set variables for API call - this_uuid = cosmostat_client.uuid - this_short_id = cosmostat_client.short_id - this_timestamp = time.time() api_url = f"{cosmostat_server_api()}create_client" # generate payload - payload = { - "uuid": this_uuid, - "short_id": this_short_id, - "data_timestamp": this_timestamp, # Unix epoch float - "client_properties": get_php_summary(), - } + payload = get_client_payload(get_php_summary(), "client_properties") # execute API call - result = client_submission_handler() + result = client_submission_handler(api_url, payload) return result # Cosmostat Client API Reporting Handler -def client_submission_handler(): +def client_submission_handler(api_url: str, payload: dict): result = None try: # `json=` automatically sets Content-Type to application/json - response: Response = requests.post(api_url, json=payload, timeout=timeout) + response: Response = requests.post(api_url, json=payload, timeout=4) response.raise_for_status() # raise HTTPError for 4xx/5xx except RequestException as exc: # Wrap the low-level exception in a more descriptive one - raise RuntimeError( - f"Failed to POST to {url!r}: {exc}" - ) from exc + log_data(log_output = f"Failed to POST to {api_url!r}: {exc}", log_level = "log_output") # process reply from API try: result = response.json() except ValueError as exc: - raise RuntimeError( - f"Server responded with non-JSON payload: {response.text!r}" - ) from exc + log_data(log_output = "Server responded with non-JSON payload: {response.text!r}", log_level = "log_output") return result +def get_client_payload(system_dictionary: dict, dictionary_name: str): + this_uuid = cosmostat_client.uuid + this_short_id = cosmostat_client.short_id + this_hostname = cosmostat_client.name + payload = { + "uuid": this_uuid, + "short_id": this_short_id, + "hostname": this_hostname, + dictionary_name: system_dictionary + + } + return payload + ####################################################################### ####################################################################### @@ -353,9 +394,9 @@ if __name__ == '__main__': return new_client # instantiate and return the Cosmoserver System object - def new_cosmos_server(): - new_server = Cosmoserver(cosmostat_client.uuid) - log_data(log_output = f"New Cosmostat object name: {new_server.name}", log_level = "log_output") + def new_cosmostat_server(): + new_server = CosmostatServer(cosmostat_client.uuid) + log_data(log_output = f"New Cosmostat serverobject name: {new_server.name}", log_level = "log_output") return new_server # Background Loop Function @@ -366,15 +407,26 @@ if __name__ == '__main__': if app_settings["push_redis"]: update_redis_server() - - if app_settings["cosmostat_server_reporter"]: + + if run_cosmostat_reporter(): + if int(time.time()) % 5 == 0 and not cosmostat_client.check_system_timer(): + cosmostat_client.update_system_state() client_update() + if run_cosmostat_server(): + this_client = get_client_payload(get_client_redis_data(human_readable = False), "redis_data") + run_update_client(this_client) + + + + + time.sleep(0.5) + ###################################### # instantiate client ###################################### cosmostat_client = new_cosmos_client() - if app_settings["cosmostat_server_reporter"]: + if app_settings["cosmostat_server_reporter"] and not app_settings["cosmostat_server"]: client_initialize() ###################################### @@ -383,7 +435,9 @@ if __name__ == '__main__': cosmostat_server = None if run_cosmostat_server(): - cosmostat_server = new_cosmos_server() + cosmostat_server = new_cosmostat_server() + this_client = get_client_payload(get_php_summary(), "client_properties") + timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) ###################################### # send initial stats update to redis diff --git a/files/api/descriptors.json b/files/api/descriptors.json index 0659abf..9dbd9ab 100644 --- a/files/api/descriptors.json +++ b/files/api/descriptors.json @@ -118,7 +118,7 @@ }, "metrics": { "MB Used": "free -m | grep Mem | awk '{print $3}'", - "MB Free": "free -m | grep Mem | awk '{print $4}'" + "MB Available": "free -m | grep Mem | awk '{print $7}'" }, "virt_ignore": [ "RAM Type", @@ -136,13 +136,13 @@ }, { "name": "LAN", - "description": "{Device ID} - {Device Name} - {MAC Address}", + "description": "{Device Name} - {Device ID} - {MAC Address}", "multi_check": "True", "device_list": "ip link | grep default | grep -v -e docker -e 127.0.0.1 -e br- -e veth -e lo -e tun | cut -d ':' -f 2 | awk '{{print $1}}' ", "properties": { "MAC Address": "ip link | grep -A1 ' {this_device}' | grep ether | awk '{{print $2}}'", "Device Name": "echo {this_device}", - "Device ID": "udevadm info -q property -p $(ls -l /sys/class/net/ | grep {this_device} | cut -d '>' -f2 | cut -b 8- ) | grep ID_MODEL_FROM_DATABASE | cut -d '=' -f2 " + "Device ID": "( udevadm info -q property -p $(ls -l /sys/class/net/ | grep {this_device} | cut -d '>' -f2 | cut -b 8-) | grep ID_MODEL_FROM_DATABASE || echo 'ID_MODEL_FROM_DATABASE=missing' ) | cut -d '=' -f2" }, "metrics": { "IP Address": "ip -o -4 ad | grep -v -e docker -e 127.0.0.1 -e br- | grep {this_device} | awk '{{print $4}}'", @@ -185,7 +185,7 @@ "properties": { "Device Name": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{{print $1}}' | grep {this_device}", "Device Path": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{{print \"/dev/\"$1}}' | grep {this_device}", - "Drive Type": "lsblk -d -o NAME,TRAN | grep {this_device} | awk '{{print $2}}'", + "Drive Type": "lsblk -d -o NAME,TRAN | grep {this_device} | awk '{{print ($2 != \"\" ? $2 : \"missing\")}}'", "Total Capacity": "lsblk -d -o NAME,SIZE | grep {this_device} | awk '{{print $2}}'", "SMART Check": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r .smart_status.passed" }, diff --git a/files/server/server.php b/files/server/server.php new file mode 100644 index 0000000..21125ff --- /dev/null +++ b/files/server/server.php @@ -0,0 +1,161 @@ +"; +$context = stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n" + ] +]); + +$json = @file_get_contents($apiUrl, false, $context); +if ($json === false) { + die('

Could not fetch data from the API.

'); +} +$clients = json_decode($json, true); +if ($clients === null || !is_array($clients)) { + die('

Malformed JSON returned from the API.

'); +} + +/* --------------------- 2. Resolve selected host ------------- */ +$selectedHost = $_GET['host'] ?? ''; +$selectedIdx = null; +foreach ($clients as $idx => $client) { + if (strtolower($client['hostname']) === strtolower($selectedHost)) { + $selectedIdx = $idx; + break; + } +} +if ($selectedIdx === null) { + // no match - default to the first host (or none) + $selectedIdx = 0; + $selectedHost = $clients[$selectedIdx]['hostname'] ?? ''; +} +$client = $clients[$selectedIdx] ?? null; +$properties = $client['client_properties'][0] ?? []; +$systemProperties = $properties['system_properties'] ?? []; +$systemComponents = $properties['system_components'] ?? []; + +?> + + + + + +Cosmostat - <?= h($selectedHost) ?> + + + + +
+ + + + + +
+ +
+

Matt-Cloud Cosmostat Dashboard

+

This dashboard shows the local Matt-Cloud system stats.

+ +
+ +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///descriptor

+

This will return the entire JSON descriptor variable

+
+ +
+
+ + +

System Properties

+
+ + + + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ + + +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ + +
+
+
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/files/server/sidebar.js b/files/server/sidebar.js new file mode 100644 index 0000000..87a7f01 --- /dev/null +++ b/files/server/sidebar.js @@ -0,0 +1,134 @@ + +// Helper - return the value of the ?host= query‑string +function getSelectedHost() { + const params = new URLSearchParams(window.location.search); + return params.get('host') || ''; +} + +// Build the endpoints list when we receive data +socket.on('client_hostnames', rawMsg => { + // rawMsg is the JSON string that redis-cli prints + let hosts; + try { + hosts = JSON.parse(rawMsg); + } catch (e) { + console.warn('Could not parse client_hostnames message', rawMsg); + return; + } + + // Sanity‑check + if (!Array.isArray(hosts)) { return; } + + const ol = document.getElementById('endpointList'); + const selected = getSelectedHost().toLowerCase(); + + // Clear old list + ol.innerHTML = ''; + + hosts.forEach(host => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = '?host=' + encodeURIComponent(host); + a.textContent = host; + if (host.toLowerCase() === selected) { + a.classList.add('active'); + } + li.appendChild(a); + ol.appendChild(li); + }); +}); + +/* ----------------------------------------------- + 2. (Optional) Re‑build the list if the URL changes + ----------------------------------------------- */ +window.addEventListener('popstate', () => { + // When the user navigates via back/forward the page + // still holds the old list, so we rebuild it. + const currentSelected = getSelectedHost().toLowerCase(); + const anchors = document.querySelectorAll('#endpointList a'); + anchors.forEach(a => { + a.classList.toggle('active', a.textContent.toLowerCase() === currentSelected); + }); +}); + +(function () { + /* ---------------------------------------------------------- + Use the socket that system_metrics.js already created. + If for some reason it isn’t defined, create a new one. + ---------------------------------------------------------- */ + const sock = typeof socket !== 'undefined' ? socket : io(); + + /* ---------------------------------------------------------- + Return the hostname that is currently selected in the URL + (the value of the “?host=…” query string). + ---------------------------------------------------------- */ + function getSelectedHost() { + const params = new URLSearchParams(window.location.search); + return params.get('host') || ''; + } + + /* ---------------------------------------------------------- + Populate