cosmostat working

This commit is contained in:
2026-03-29 09:39:43 -07:00
parent 97fdb3d5d8
commit 4c4d9e4d6f
19 changed files with 813 additions and 491 deletions

View File

@ -14,6 +14,7 @@ cosmostat_packages:
- jc
- smartmontools
- inxi
- easy-rsa
# python venv packages
cosmostat_venv_packages: |
@ -38,7 +39,6 @@ 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"
@ -52,6 +52,7 @@ api_service_folder: "{{ service_folder }}/api"
venv_folder: "{{ service_folder }}/venv"
api_service_exe: "{{ venv_folder }}/bin/python -u {{ api_service_folder }}/app.py"
custom_api_port: "5000"
REAL_API_KEY: "DEADBEEF"
# dashboard vars
service_control_web_folder: "{{ service_folder }}/web"
@ -67,11 +68,14 @@ x64_arch: true
noisy_test: false
debug_output: true
secure_api: true
push_redis: true
push_redis: false
run_background : true
log_output: true
update_frequency: "1"
cosmostat_server: true
cosmostat_server_api: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/"
cosmostat_server_api: "https://cosmostat.testy-cal.com/"
local_api_address: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/"
cosmostat_server_reporter: false
# setting this to true for default install
disable_local_api: true
...

View File

@ -69,6 +69,7 @@ class Component:
self.virt_ignore = self._descriptor.get('virt_ignore', [])
self.multi_metrics = self._descriptor.get('multi_metrics', [])
self.arch_check = self._descriptor.get('arch_check', [])
self.php_extra_list = self._descriptor.get('php_extra', [])
if self.is_virtual:
self.virt_ignore = []
@ -173,6 +174,13 @@ class Component:
log_data(log_output = f"result - {result_command}", log_level = "debug_output")
return result_command
# check if this property should show in the System Properties box
def check_php_extra(self, property_name):
result = False
if property_name in self.php_extra_list:
result = True
return result
########################################################
# keyed data functions
########################################################
@ -407,7 +415,6 @@ class System:
if multi_check:
log_data(log_output = f"Creating one component of type {component_name} for each one found", log_level = "log_output")
component_type_device_list = get_device_list(component_name)
component_id = 0
for this_device in component_type_device_list:
this_component_ID = component_type_device_list.index(this_device)
this_component_name = f"{component_name} {this_component_ID}"
@ -486,20 +493,36 @@ class System:
result.append(metric)
return result
def get_system_properties(self, human_readable = False):
def get_system_properties(self, human_readable = False, php_extra = False):
result = []
for name, value in self._properties.items():
if human_readable:
result.append({
"Source": "System",
"Property": f"{name}: {value}"
})
})
else:
result.append({
"Source": "System",
"Property": name,
"Value": value
})
if php_extra and human_readable:
for component_result in self.php_component_data():
result.append(component_result)
return result
def php_component_data(self):
result = []
for component in self.components:
for this_property in component._properties:
if component.check_php_extra(this_property):
result_string = f"{this_property}: {component._properties[this_property]}"
result.append({
"Source": "System",
"Property": result_string
})
return result
########################################################
@ -584,7 +607,12 @@ def run_command(cmd, zero_only=False, use_shell=True, req_check = True):
except:
return output_lines
# need to add a archticture checker for this
# i also want to make the loop cleaner
# i don't need to iterate over the component class tree
# to get what I want, i think
def get_device_list(device_type_name: str):
result = []
for component in component_class_tree:
precheck_value = 1
@ -594,6 +622,7 @@ def get_device_list(device_type_name: str):
precheck_value = int(precheck_value_output)
log_data(log_output = f"Precheck found - {precheck_command} - {precheck_value}", log_level = "log_output")
if component["name"] == device_type_name and precheck_value != 0:
device_list_command = component["device_list"]
device_list_result = run_command(device_list_command)
result = device_list_result

View File

@ -1,5 +1,6 @@
import yaml
from urllib.parse import urlparse
import secrets, string
#######################################################################
### Settings Handler Functions
#######################################################################
@ -16,7 +17,11 @@ app_settings = {
"cosmostat_server_reporter": False,
"update_frequency": 1,
"custom_api_port": "5000",
"cosmostat_server_api": "http://10.200.27.20:5000/"
"cosmostat_server_api": "http://10.200.27.20:5000/",
"REAL_API_KEY": ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(256)),
"disable_local_api": False,
"local_api_address": "http://10.200.27.20:5000/",
"cosmostat_server_ip": "10.200.27.20"
}
with open('cosmostat_settings.yaml', 'r') as f:
@ -61,9 +66,7 @@ def run_cosmostat_reporter():
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"]:
if cosmostat_settings["secure_api"] and not cosmostat_settings["cosmostat_server"]:
result = cosmostat_bind_ip()
return result

View File

@ -93,15 +93,33 @@ class CosmostatServer:
def get_client_hostname(self, system_uuid: str):
client = self.get_system(system_uuid)
return client.hostname
def get_client_timestamp(self, system_hostname: str):
client = self.get_system(get_uuid_from_hostname(system_hostname))
return client.data_timestamp
def get_client_hostnames(self, send_age = False):
result = []
def get_uuid_from_hostname(self, system_hostname):
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)
if system.hostname == system_hostname:
result = system.uuid
return result
def get_client_hostnames(self, send_age = False):
now = time.time()
fresh_systems = []
result = []
for system in self.systems:
age = now - system.data_timestamp
if age <= 60: # keep only fresh servers
fresh_systems.append(system)
if send_age:
result.append({"hostname": system.hostname, "data_age": age})
else:
result.append(system.hostname)
self.systems = fresh_systems # replace the old list
return result

View File

@ -4,6 +4,7 @@ from typing import Dict, Union
import json, time, redis, yaml
import base64, hashlib
import secrets, string
import requests
from requests import RequestException, Response
@ -35,7 +36,7 @@ def update_redis_server():
if run_cosmostat_server():
update_redis_channel("client_summary", get_server_redis_data())
update_redis_channel("client_hostnames", get_server_hostnames())
#update_redis_channel("client_hostnames", get_server_hostnames())
# History Redis Tree
# Update history_stats Redis Channel
@ -54,6 +55,7 @@ def get_server_redis_data():
for client in cosmostat_server.systems:
this_client_key = {
"hostname": client.hostname,
"data_timestamp": client.data_timestamp,
"uuid": client.uuid,
"short_id": client.name,
"redis_data": client.redis_data
@ -64,6 +66,7 @@ def get_server_redis_data():
def get_server_hostnames():
return cosmostat_server.get_client_hostnames()
#######################################################################
### Client Flask Routes
#######################################################################
@ -152,7 +155,7 @@ def get_static_data(human_readable = False):
return cosmostat_client.get_static_metrics(human_readable)
def get_php_summary():
system_properties = cosmostat_client.get_system_properties(human_readable = True)
system_properties = cosmostat_client.get_system_properties(human_readable = True, php_extra = True)
system_components = []
for component in cosmostat_client.get_components():
this_component = {
@ -161,6 +164,18 @@ def get_php_summary():
}
system_components.append(this_component)
if run_cosmostat_server():
print(cosmostat_client.name)
client_uuid = cosmostat_server.get_uuid_from_hostname(cosmostat_client.name)
print(client_uuid)
data_timestamp = cosmostat_server.get_system(client_uuid)
print(data_timestamp)
component_age = {
"component_name": "Data Timestamp",
"info_strings": f"Data is {data_timestamp} seconds old"
}
system_components.append(component_age)
result = [{
"system_properties": system_properties,
"system_components": system_components
@ -229,7 +244,17 @@ def client_details():
def client_hostnames():
result = []
if run_cosmostat_server():
result = cosmostat_server.get_client_hostnames()
result = cosmostat_server.get_client_hostnames(send_age = True)
else:
result = {"message": "server not running on this endpoint"}
return jsonify(result)
# api to get server redis data
@app.route('/get_server_redis', methods=['GET'])
def get_server_redis():
result = []
if run_cosmostat_server():
result = get_server_redis_data()
else:
result = {"message": "server not running on this endpoint"}
return jsonify(result)
@ -241,30 +266,48 @@ def client_hostnames():
# update client on server
def run_update_client(this_client):
if not cosmostat_server.check_uuid(this_client["uuid"]):
return { "message": "client not found" }
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"]}'
if public_api_check(this_client):
if not cosmostat_server.check_uuid(this_client["uuid"]):
return { "message": "client not found" }
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
}
return {
"status": update_status,
"uuid": this_client["uuid"],
"redis_data": this_client,
"timestamp_update": timestamp_update
}
else:
return{
"status": "api failure"
}
# create client on server
def run_create_client(this_client):
timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
update_status = f'created client {this_client["short_id"]}'
return {
"status": update_status,
"uuid": this_client["uuid"],
"client_properties": this_client,
"timestamp_update": timestamp_update
if public_api_check(this_client):
timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
update_status = f'created client {this_client["short_id"]}'
return {
"status": update_status,
"uuid": this_client["uuid"],
"client_properties": this_client,
"timestamp_update": timestamp_update
}
else:
return{
"status": "api failure"
}
def public_api_check(this_client):
result = False
default_key = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(256))
api_key = this_client.get('API_KEY', default_key)
if api_key == app_settings["REAL_API_KEY"]:
result = True
return result
# flask submission check function
def client_submit_check(request, dict_name: str):
payload = {}
@ -324,6 +367,7 @@ def get_client_details():
# Cosmostat Client Reporter
def client_update():
api_url = f"{cosmostat_server_api()}update_client"
print(api_url)
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")
@ -366,7 +410,8 @@ def get_client_payload(system_dictionary: dict, dictionary_name: str):
"uuid": this_uuid,
"short_id": this_short_id,
"hostname": this_hostname,
dictionary_name: system_dictionary
dictionary_name: system_dictionary,
"API_KEY": app_settings["REAL_API_KEY"]
}
return payload
@ -401,25 +446,26 @@ if __name__ == '__main__':
# Background Loop Function
def background_loop():
# Update all data on the System object
if cosmostat_client.check_system_timer():
# Update all data on the System object unless this is the server
if cosmostat_client.check_system_timer() and not run_cosmostat_server():
cosmostat_client.update_system_state()
if app_settings["push_redis"]:
if app_settings["push_redis"] and not app_settings["disable_local_api"]:
update_redis_server()
if run_cosmostat_reporter():
if int(time.time()) % 5 == 0 and not cosmostat_client.check_system_timer():
cosmostat_client.update_system_state()
client_update()
client_update()
if run_cosmostat_server():
# update the client state since that was skipped
cosmostat_client.update_system_state()
this_client = get_client_payload(get_client_redis_data(human_readable = False), "redis_data")
if app_settings["noisy_test"]:
print(this_client)
run_update_client(this_client)
time.sleep(0.5)
######################################
@ -443,14 +489,14 @@ if __name__ == '__main__':
# send initial stats update to redis
######################################
if app_settings["push_redis"]:
if app_settings["push_redis"] and not app_settings["disable_local_api"]:
update_redis_server()
######################################
# Flask scheduler for scanner
######################################
if app_settings["run_background"]:
if app_settings["run_background"] and not app_settings["disable_local_api"]:
log_data(log_output = "Loading flask background subroutine...", log_level = "log_output")
scheduler.add_job(id='background_loop',
@ -467,11 +513,15 @@ if __name__ == '__main__':
######################################
# Flask API
######################################
app.run(debug=False, host=service_gateway_ip(), port=service_api_port())
print(f"gateway: {service_gateway_ip()} - port: {service_api_port()}")
if not app_settings["disable_local_api"]:
app.run(debug=False, host=service_gateway_ip(), port=service_api_port())
else:
print("Internal API Disabled.")
while True:
if int(time.time()) % 5 == 0 and not cosmostat_client.check_system_timer():
cosmostat_client.update_system_state()
client_update()
time.sleep(0.5)

View File

@ -89,6 +89,9 @@
"arch_variance": [
"current_mhz",
"Clock Speed"
],
"php_extra" :[
"CPU Model"
]
},
{
@ -132,20 +135,23 @@
"RAM Type",
"RAM Speed",
"RAM Voltage"
],
"php_extra" :[
"Total GB"
]
},
{
"name": "LAN",
"description": "{Device Name} - {Device ID} - {MAC Address}",
"description": "{Device Name} - {Device ID}",
"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}}'",
"MAC Address": "ip link | grep -A1 ' {this_device}' | grep ether | awk '{{print $2}}' || echo MAC missing",
"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 || 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}}'",
"IP Address": "ip -o -4 ad | grep -v -e docker -e 127.0.0.1 -e br- -e tun | grep {this_device} | awk '{{print $4}}'",
"Data Transmitted": "ifconfig {this_device} | grep RX | grep bytes | cut -d '(' -f2 | tr -d ')'",
"Data Received": "ifconfig {this_device} | grep TX | grep bytes | cut -d '(' -f2 | tr -d ')'",
"Link State": "cat /sys/class/net/{this_device}/operstate",
@ -155,13 +161,28 @@
"IP Address"
]
},
{
"name": "VPN",
"description": "{Device Name} - VPN Tunnel",
"multi_check": "True",
"precheck": "ip link | grep tun | wc -l",
"device_list": "ip link | grep default | grep tun | cut -d ':' -f 2 | awk '{{print $1}}' ",
"properties": {
"Device Name": "echo {this_device}"
},
"metrics": {
"IP Address": "ip -o -4 ad | grep -v -e docker -e 127.0.0.1 -e br- | grep {this_device} | awk '{{print $4}}'",
"Data Transmitted": "ifconfig {this_device} | grep RX | grep bytes | cut -d '(' -f2 | tr -d ')'",
"Data Received": "ifconfig {this_device} | grep TX | grep bytes | cut -d '(' -f2 | tr -d ')'"
}
},
{
"name": "NVGPU",
"description": "NVGPU{Device ID} - {Device Model} with {Memory Size}, Max Power {Maximum Power}",
"description": "NVGPU{Device ID} - {GPU Model} with {Memory Size}, Max Power {Maximum Power}",
"multi_check": "True",
"device_list": "nvidia-smi --query-gpu=index --format=csv,noheader,nounits",
"properties": {
"Device Model": "nvidia-smi --id={this_device} --query-gpu=name --format=csv,noheader,nounits",
"GPU Model": "nvidia-smi --id={this_device} --query-gpu=name --format=csv,noheader,nounits",
"Device ID": "echo NVGPU{this_device}",
"Driver Version": "nvidia-smi --id={this_device} --query-gpu=driver_version --format=csv,noheader,nounits",
"Maximum Power": "nvidia-smi --id={this_device} --query-gpu=power.limit --format=csv,noheader,nounits",
@ -175,16 +196,19 @@
"GPU Load": "nvidia-smi --id={this_device} --query-gpu=utilization.gpu --format=csv,noheader,nounits"
},
"precheck": "lspci | grep NVIDIA | wc -l"
"precheck": "lspci | grep NVIDIA | wc -l",
"php_extra" :[
"GPU Model"
]
},
{
"name": "STOR",
"description": "{Device Path} is of type {Drive Type} with capacity of {Total Capacity}.",
"multi_check": "True",
"device_list": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{print $1}'",
"device_list": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME -e sr0| awk '{print $1}'",
"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}",
"Device Name": "echo {this_device}",
"Device Path": "echo /dev/{this_device}",
"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"
@ -192,5 +216,34 @@
"metrics": {
"placeholder": ""
}
},
{
"name": "MOUNT",
"description": "Storage device {Device Location} mounted at {Storage Path} with {Total Space} total space",
"multi_check": "True",
"device_list": "df -h | grep -v -e 'Use%' -e tmpfs -e overlay -e efi -e udev | awk '{{print $1}}'",
"properties": {
"Device Location": "echo {this_device}",
"Storage Path": "df -h | grep '{this_device} ' | awk '{{print $6}}'",
"Total Space": "df -h | grep '{this_device} ' | awk '{{print $2}}'"
},
"metrics": {
"Free Space": "df -h | grep '{this_device} ' | awk '{{print $4}}' ",
"Used Space": "df -h | grep '{this_device} ' | awk '{{print $3}}' "
}
},
{
"name": "DVD",
"description": "{Device Path} is a DVD or Virtual DVD drive.",
"multi_check": "True",
"device_list": "lsblk -d -o NAME,SIZE | grep sr0| awk '{print $1}'",
"properties": {
"Device Name": "echo {this_device}",
"Device Path": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{{print \"/dev/\"$1}}' | grep {this_device}",
"Total Capacity": "lsblk -d -o NAME,SIZE | grep {this_device} | awk '{{print $2}}'"
},
"metrics": {
"placeholder": ""
}
}
]

View File

@ -1,14 +1,11 @@
<?php
/* -------------------------------------------------------------
* Cosmostat Dashboard - updated to support hostspecific view
* -------------------------------------------------------------
*/
function h(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
/* --------------------- 1. Load API data --------------------- */
// Load API data
$raw_api_settings = file('/opt/api_settings/cosmostat_settings.yaml',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
@ -42,24 +39,26 @@ if ($clients === null || !is_array($clients)) {
die('<p style="color:red;">Malformed JSON returned from the API.</p>');
}
/* --------------------- 2. Resolve selected host ------------- */
$selectedHost = $_GET['host'] ?? '';
$selectedIdx = null;
// hostname get handler
$selectedId = $_GET['host'] ?? ''; // the value passed in ?host=
$selectedIdx = null;
foreach ($clients as $idx => $client) {
if (strtolower($client['hostname']) === strtolower($selectedHost)) {
if (isset($client['short_id']) && $client['short_id'] === $selectedId) {
$selectedIdx = $idx;
break;
}
}
if ($selectedIdx === null) {
// no match - default to the first host (or none)
// No match fall back to the first client (or none)
$selectedIdx = 0;
$selectedHost = $clients[$selectedIdx]['hostname'] ?? '';
$selectedId = $clients[$selectedIdx]['short_id'] ?? '';
}
$client = $clients[$selectedIdx] ?? null;
$properties = $client['client_properties'][0] ?? [];
$systemProperties = $properties['system_properties'] ?? [];
$systemComponents = $properties['system_components'] ?? [];
$selectedHost = $clients[$selectedIdx]['hostname'];
?>
@ -76,86 +75,82 @@ $systemComponents = $properties['system_components'] ?? [];
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar">
<h3>Endpoints</h3>
<!-- The list will be populated by the JavaScript below -->
<ol id="endpointList"></ol>
</div>
<h3>Endpoints</h3>
<!-- The list will be populated by JavaScript -->
<ol id="endpointList"></ol>
</nav>
<!-- Main content -->
<div class="main">
<!-- Header Card -->
<div class="card">
<h2>Matt-Cloud Cosmostat Dashboard</h2>
<p>This dashboard shows the local Matt-Cloud system stats.</p>
<div class="help-link" id="helpToggle">API</div>
</div>
</div> <!-- / Header Card -->
<!-- Hidden API Card -->
<div id="helpText" class="card">
<strong>Component Desriptor</strong>
<p>To view the component descriptor, you may <br>
<code>curl -s https://<?= h($_SERVER['SERVER_NAME']) ?>/descriptor</code></p>
<p>This will return the entire JSON descriptor variable</p>
</div>
</div> <!-- / Header Card -->
<!-- summary card -->
<div class="card">
<div id="host_components" class="column">
<?php if (!empty($systemProperties)): ?>
<h2>System Properties</h2>
<div class="system">
<table>
<tr>
<td>
<ul class="system-list">
<?php foreach ($systemProperties as $prop): ?>
<li><?= h($prop['Property']) ?></li>
<?php endforeach; ?>
</ul>
</td>
<td>
<h2>Live System Metrics</h2>
<div id="host_metrics" class="column">Connecting...</div>
</td>
</tr>
</table>
</div>
<?php endif; ?>
<?php if (!empty($systemComponents)): ?>
<h2>Components</h2>
<div class="components">
<?php foreach ($systemComponents as $comp): ?>
<div class="component">
<h3><?= h($comp['component_name']) ?></h3>
<ul class="info-list">
<?php foreach ($comp['info_strings'] as $info): ?>
<li><?= h($info) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($systemProperties)): ?><h2>System Properties</h2>
<table><tr>
<td>
<ul class="system-list">
<?php foreach ($systemProperties as $prop): ?><li><?= h($prop['Property']) ?></li>
<?php endforeach; ?></ul>
</td><td>
<h2>Live System Metrics</h2>
<!-- Live content, javascript rendered -->
<div id="host_metrics" class="column">
Connecting...
</div> <!--/live content -->
</td>
</tr></table>
<?php endif; ?><br>
<!-- api help toggle -->
<div class="componentDetail-link" id="componentDetailToggle">Toggle Component Details</div>
</div> <!--/summary card -->
<!-- hidden detail card -->
<div id="componentDetailText" class="card">
<?php if (!empty($systemComponents)): ?><h2>Components</h2>
<!-- component bucket -->
<div class="components">
<?php foreach ($systemComponents as $comp): ?>
<!-- individual component -->
<div class="component">
<h3><?= h($comp['component_name']) ?></h3>
<ul class="info-list">
<?php foreach ($comp['info_strings'] as $info): ?><li><?= h($info) ?></li>
<?php endforeach; ?></ul>
</div> <!--/individual component -->
<?php endforeach; ?></div> <!--/component bucket -->
<?php endif; ?></div> <!--/hidden detail card -->
</div> <!-- /main -->
</div> <!-- /wrapper -->
<!-- Socket.IO client library -->
<script src="socket.io/socket.io.js"></script>
<!-- system metrics script -->
<script src="src/system_metrics.js"></script>
<!-- sidebar script -->
<script src="src/sidebar.js"></script>
<!-- Panel Toggles -->
<script>
document.getElementById('helpToggle').addEventListener('click', function () {
const help = document.getElementById('helpText');
help.style.display = help.style.display === 'none' || help.style.display === '' ? 'block' : 'none';
});
document.getElementById('componentDetailToggle').addEventListener('click', function () {
const help = document.getElementById('componentDetailText');
help.style.display = help.style.display === 'none' || help.style.display === '' ? 'block' : 'none';
});
</script>
</body>
</html>

View File

@ -1,134 +0,0 @@
// Helper - return the value of the ?host= querystring
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;
}
// Sanitycheck
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) Rebuild 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 isnt 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 <ul id="endpointList"> with <li><a> items.
---------------------------------------------------------- */
function buildList(hosts) {
const ol = document.getElementById('endpointList');
const selected = getSelectedHost().toLowerCase();
ol.innerHTML = ''; // clear old items
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);
});
}
/* ----------------------------------------------------------
Listen for the “client_hostnames” event from the server.
The payload can be:
a JSON string → parse it
a plain array → use it directly
an object with a .data array (fallback)
---------------------------------------------------------- */
sock.on('client_hostnames', payload => {
let hosts;
if (typeof payload === 'string') {
try {
hosts = JSON.parse(payload);
} catch (e) {
console.warn('client_hostnames message is not JSON:', payload);
return;
}
} else if (Array.isArray(payload)) {
hosts = payload;
} else if (payload && Array.isArray(payload.data)) {
hosts = payload.data;
} else {
console.warn('client_hostnames payload format unrecognised:', payload);
return;
}
if (!Array.isArray(hosts)) {
console.warn('client_hostnames payload did not resolve to an array:', hosts);
return;
}
buildList(hosts);
});
/* ----------------------------------------------------------
When the user navigates via the back/forward buttons,
reapply the “active” class to the correct link.
---------------------------------------------------------- */
window.addEventListener('popstate', () => {
const selected = getSelectedHost().toLowerCase();
document.querySelectorAll('#endpointList a').forEach(a => {
a.classList.toggle('active', a.textContent.toLowerCase() === selected);
});
});
})();

View File

@ -1,179 +1,309 @@
/* ------------------------------------------------------------
1. Socket-IO connection & helper functions (unchanged)
------------------------------------------------------------ */
const socket = io();
/* ==============================================================
system_metrics.js
==============================================================
Updated to use the unique `short_id` (the systems key) rather
than the hostname. Hostnames are still displayed to the user
but every internal mapping and URL uses the short_id so duplicate
hostnames no longer collide.
============================================================== */
(() => {
/* ==========================================================
Socket.IO setup unchanged
========================================================== */
const socket = io({
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 3000,
reconnectionDelayMax: 60000,
timeout: 60000,
pingTimeout: 5000,
pingInterval: 25000,
});
socket.on('client_summary', renderStatsTable);
/* ==========================================================
Color constants unchanged
========================================================== */
const GREEN = [ 39, 174, 96]; // #27ae60
const YELLOW = [243, 156, 18]; // #f39c12
const RED = [192, 57, 43]; // #c0392b
socket.on('connect_error', err => {
safeSetText('client_summary', `Could not connect to server - ${err.message}`);
});
/* ==========================================================
Helpers
========================================================== */
const hostTimestamps = {}; // keyed by short_id
socket.on('reconnect', attempt => {
safeSetText('client_summary', `Re-connected (attempt ${attempt})`);
});
const toRgb = (r, g, b) => `rgb(${r},${g},${b})`;
function safeSetText(id, txt) {
const el = document.getElementById(id);
if (el) el.textContent = txt;
}
const T20 = 20 * 1000;
const T40 = 40 * 1000;
const T60 = 60 * 1000;
/* ------------------------------------------------------------
2. Render the table for the *selected* host
------------------------------------------------------------ */
function renderStatsTable(raw) {
// Raw may be a string (from Redis) or already parsed by socket.io
let payload;
if (typeof raw === 'string') {
try {
payload = JSON.parse(raw);
} catch (e) {
safeSetText('client_summary', 'Invalid data received');
function getFreshnessColor(ageMs) {
if (ageMs <= T20) {
return toRgb(...GREEN);
}
if (ageMs <= T40) {
const t = (ageMs - T20) / (T40 - T20);
const r = Math.round(GREEN[0] + t * (YELLOW[0] - GREEN[0]));
const g = Math.round(GREEN[1] + t * (YELLOW[1] - GREEN[1]));
const b = Math.round(GREEN[2] + t * (YELLOW[2] - GREEN[2]));
return toRgb(r, g, b);
}
if (ageMs <= T60) {
const t = (ageMs - T40) / (T60 - T40);
const r = Math.round(YELLOW[0] + t * (RED[0] - YELLOW[0]));
const g = Math.round(YELLOW[1] + t * (RED[1] - YELLOW[1]));
const b = Math.round(YELLOW[2] + t * (RED[2] - YELLOW[2]));
return toRgb(r, g, b);
}
return toRgb(...RED);
}
function safeSetText(id, txt) {
const el = document.getElementById(id);
if (el) el.textContent = txt;
}
/* ------------------------------------------------------------------
Get the *short_id* from the query string
------------------------------------------------------------------ */
function getSelectedId() {
return new URLSearchParams(window.location.search).get('host') || '';
}
/* ==========================================================
Sidebar building uses short_id for status key
========================================================== */
function buildList(systemList) {
const ul = document.getElementById('endpointList');
const current = Array.from(ul.children).map(li => li.dataset.id);
const newIds = systemList.map(s => s.short_id);
if (arraysEqual(current, newIds)) return; // nothing changed
const selected = getSelectedId().toLowerCase();
ul.innerHTML = ''; // reset list
systemList.forEach(item => {
const li = document.createElement('li');
// status dot keyed by short_id
const status = document.createElement('span');
status.className = 'host-status';
status.dataset.id = item.short_id;
// link display hostname, encode short_id in URL
const a = document.createElement('a');
a.href = '?host=' + encodeURIComponent(item.short_id);
a.textContent = item.hostname;
if (item.short_id.toLowerCase() === selected) a.classList.add('active');
li.appendChild(status);
li.appendChild(a);
ul.appendChild(li);
});
}
/* ==========================================================
Update status colours every second
========================================================== */
function updateStatusColors() {
const nowSec = Date.now() / 1000;
Object.entries(hostTimestamps).forEach(([id, ts]) => {
const ageMs = (nowSec - ts) * 1000;
const color = getFreshnessColor(ageMs);
const span = document.querySelector(
`.host-status[data-id="${id}"]`
);
if (span) span.style.backgroundColor = color;
});
}
setInterval(updateStatusColors, 1000);
/* ==========================================================
Utility helpers (unchanged)
========================================================== */
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg;
return;
}
} else {
payload = raw;
const merged = mergeRowsByName(data);
const ordered = orderRows(merged);
const table = buildTable(ordered);
table.id = 'host_metrics_table';
container.innerHTML = '';
container.appendChild(table);
}
if (!Array.isArray(payload) || !payload.length) {
safeSetText('client_summary', 'No data available');
return;
}
/* ---------------------------------------------
2a. Determine the hostname to display
--------------------------------------------- */
const urlParams = new URLSearchParams(window.location.search);
const selectedHost = urlParams.get('host');
/* ---------------------------------------------
2b. Find the host object in the payload
--------------------------------------------- */
const hostObj =
payload.find(item => item.hostname === selectedHost) || payload[0];
/* ---------------------------------------------
2c. Extract the Redis data for that host
--------------------------------------------- */
const hostData = hostObj && Array.isArray(hostObj.redis_data)
? hostObj.redis_data
: [];
/* ---------------------------------------------
2d. Pass the host-specific data to the generic renderer
--------------------------------------------- */
renderGenericTable('host_metrics', hostData, 'No Stats available');
}
/* ------------------------------------------------------------
3. Table rendering - unchanged from original
------------------------------------------------------------ */
function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg;
return;
}
// Merge rows by source name
const mergedData = mergeRowsByName(data);
// Order the merged rows - priority first
const orderedData = orderRows(mergedData);
// Build the table from the ordered data
const table = buildTable(orderedData);
table.id = 'host_metrics_table';
container.innerHTML = '';
container.appendChild(table);
}
/* ------------------------------------------------------------
4. Merge rows by source name
------------------------------------------------------------ */
function mergeRowsByName(data) {
const groups = {}; // { source: { Metric: [], Data: [] } }
data.forEach(row => {
const source = row.Source;
if (!source) return;
if (!groups[source]) {
groups[source] = { Metric: [], Data: [] };
}
if ('Metric' in row && 'Data' in row) {
groups[source].Metric.push(row.Metric);
groups[source].Data.push(row.Data);
}
});
const merged = [];
Object.entries(groups).forEach(([source, grp]) => {
merged.push({
Source: source,
Metric: grp.Metric,
Data: grp.Data,
});
});
return merged;
}
/* ------------------------------------------------------------
5. Order rows - put “System”, “CPU”, “RAM” first
------------------------------------------------------------ */
function orderRows(rows) {
const priority = ['System', 'CPU', 'RAM'];
const priorityMap = {};
priority.forEach((src, idx) => {
priorityMap[src] = idx;
});
return [...rows].sort((a, b) => {
const aIdx = priorityMap.hasOwnProperty(a.Source) ? priorityMap[a.Source] : Infinity;
const bIdx = priorityMap.hasOwnProperty(b.Source) ? priorityMap[b.Source] : Infinity;
return aIdx - bIdx;
});
}
/* ------------------------------------------------------------
6. Build an HTML table from an array of objects
------------------------------------------------------------ */
function buildTable(data) {
const cols = ['Source', 'Metric', 'Data'];
const table = document.createElement('table');
// Header
const thead = table.createTHead();
const headerRow = thead.insertRow();
cols.forEach(col => {
const th = document.createElement('th');
th.textContent = col;
headerRow.appendChild(th);
});
// Body
const tbody = table.createTBody();
data.forEach(item => {
const tr = tbody.insertRow();
cols.forEach(col => {
const td = tr.insertCell();
const val = item[col];
if (Array.isArray(val)) {
val.forEach((v, idx) => {
td.id = 'host_metrics_column';
const span = document.createElement('span');
span.textContent = v;
td.appendChild(span);
if (idx < val.length - 1) td.appendChild(document.createElement('br'));
});
} else {
td.textContent = val !== undefined ? val : '';
function mergeRowsByName(rows) {
const groups = {}; // { Source: { Metric: [], Data: [] } }
rows.forEach(r => {
const src = r.Source;
if (!src) return;
if (!groups[src]) groups[src] = { Metric: [], Data: [] };
if ('Metric' in r && 'Data' in r) {
groups[src].Metric.push(r.Metric);
groups[src].Data.push(r.Data);
}
});
return Object.entries(groups).map(([src, g]) => ({
Source: src,
Metric: g.Metric,
Data: g.Data,
}));
}
function orderRows(rows) {
const priority = ['System', 'CPU', 'RAM'];
const map = {};
priority.forEach((s, i) => map[s] = i);
return [...rows].sort((a, b) => {
const ai = map.hasOwnProperty(a.Source) ? map[a.Source] : Infinity;
const bi = map.hasOwnProperty(b.Source) ? map[b.Source] : Infinity;
return ai - bi;
});
}
function buildTable(rows) {
const cols = ['Source', 'Metric', 'Data'];
const table = document.createElement('table');
// Header
const thead = table.createTHead();
const headerRow = thead.insertRow();
cols.forEach(col => {
const th = document.createElement('th');
th.textContent = col;
headerRow.appendChild(th);
});
// Body
const tbody = table.createTBody();
rows.forEach(item => {
const tr = tbody.insertRow();
cols.forEach(col => {
const td = tr.insertCell();
const val = item[col];
if (Array.isArray(val)) {
val.forEach((v, i) => {
const span = document.createElement('span');
span.textContent = v;
td.appendChild(span);
if (i < val.length - 1) td.appendChild(document.createElement('br'));
});
} else {
td.textContent = val !== undefined ? val : '';
}
});
});
return table;
}
/* ==========================================================
Handle incoming data
========================================================== */
let lastUpdate = Date.now();
function handleSummary(raw) {
lastUpdate = Date.now(); // reset watchdog
let payload;
if (typeof raw === 'string') {
try { payload = JSON.parse(raw); } catch (e) {
safeSetText('client_summary', 'Invalid data received');
return;
}
} else payload = raw;
if (!Array.isArray(payload) || !payload.length) {
safeSetText('client_summary', 'No data available');
return;
}
// 1) Build the list first (so <span> elements exist)
buildList(payload);
// 2) Store the timestamp for every short_id
payload.forEach(hostObj => {
if (hostObj.short_id && hostObj.data_timestamp) {
hostTimestamps[hostObj.short_id] = hostObj.data_timestamp; // seconds
}
});
// 3) Immediately update colours for the current view
updateStatusColors();
// Metric table for selected host
const selectedId = getSelectedId();
const hostObj = payload.find(h => h.short_id === selectedId) || payload[0];
const hostData = hostObj && Array.isArray(hostObj.redis_data)
? hostObj.redis_data
: [];
renderGenericTable('host_metrics', hostData, 'No Stats available');
}
/* ==========================================================
Socket event wiring unchanged
========================================================== */
socket.on('client_summary', handleSummary);
socket.on('connect', () => {
safeSetText('client_summary', 'Connected');
requestSummary();
});
socket.on('disconnect', () => {
safeSetText('client_summary', 'Disconnected - retrying...');
});
socket.on('reconnect', attempt => {
safeSetText('client_summary', `Re-connected (attempt ${attempt})`);
requestSummary();
});
return table;
}
/* ==========================================================
Request logic unchanged
========================================================== */
function requestSummary() {
if (!socket.connected) return; // guard against stale emits
socket.emit('get_client_summary'); // server will reply via client_summary
}
/* ==========================================================
Recursive polling unchanged
========================================================== */
let pollTimer = null;
function pollLoop() {
if (!socket.connected) return;
requestSummary();
pollTimer = setTimeout(pollLoop, 5000);
}
socket.on('connect', () => {
if (!pollTimer) pollLoop();
});
/* ==========================================================
Watchdog force reconnect if no data for 15s
========================================================== */
function watchdog() {
if (Date.now() - lastUpdate > 15000 && socket.connected) {
safeSetText('client_summary', 'No updates - reconnecting...');
socket.disconnect(); // forces a reconnect cycle
}
setTimeout(watchdog, 5000);
}
watchdog();
/* ==========================================================
Keep the 'active' link in sync when the URL changes
========================================================== */
window.addEventListener('popstate', () => {
const selected = getSelectedId().toLowerCase();
document.querySelectorAll('#endpointList a').forEach(a =>
a.classList.toggle('active', a.href.includes('host=' + encodeURIComponent(selected)))
);
});
})();

View File

@ -2,7 +2,7 @@
1. Global settings & color palette
------------------------------------------------- */
:root {
/* Dark theme body & card backgrounds */
/* Dark theme - body & card backgrounds */
--bg-body: #2c3e50; /* main page background */
--bg-card: #34495e; /* card / panel background */
--bg-sidebar: #3d566e; /* sidebar background (slightly lighter) */
@ -30,14 +30,23 @@ a { color: var(--clr-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* -------------------------------------------------
2. Layout wrapper, sidebar, main
2. Layout - wrapper, sidebar, main
------------------------------------------------- */
.wrapper { display: flex; min-height: 100vh; }
.sidebar {
width: 200px;
background: var(--bg-sidebar);
position: fixed; /* keep sidebar visible during scroll */
top: 0; /* stick to the top of the viewport */
left: 0; /* align to the left edge */
height: 100vh; /* full viewport height */
/* ---- size & spacing ------------------------------------------- */
width: 200px; /* same as before */
padding: 1rem;
overflow-y: auto; /* allow sidebar content to scroll if needed */
/* ---- look ------------------------------------------------------- */
background: var(--bg-sidebar);
/* optional: keep it above other content */
z-index: 1000;
}
.sidebar h3 { margin: 0 0 .4rem 0; font-size: 1.1rem; }
.sidebar ul { list-style: none; padding: 0; margin: 0; }
@ -46,8 +55,13 @@ a:hover { text-decoration: underline; }
.sidebar a { color: var(--clr-accent); }
.sidebar a.active { font-weight: bold; }
.main { flex: 1; padding: 1rem; }
.main{
flex: 1;
padding: 1rem;
padding-left: 200px; /* space for the fixed sidebar */
/* optional: avoid accidental horizontal overflow */
overflow-x: hidden;
}
/* -------------------------------------------------
3. Card component
------------------------------------------------- */
@ -105,7 +119,7 @@ li { margin-bottom: 10px; color: var(--clr-text); }
.component h3 { margin: 0 0 5px; }
/* -------------------------------------------------
7. Help toggle / modal
7. Panel toggles / modal
------------------------------------------------- */
.help-link {
cursor: pointer;
@ -116,8 +130,117 @@ li { margin-bottom: 10px; color: var(--clr-text); }
.help-link:hover { text-decoration: underline; }
#helpText { display: none; }
.componentDetail-link {
cursor: pointer;
user-select: none;
color: var(--clr-accent);
text-align: left;
}
.componentDetail-link:hover { text-decoration: underline; }
#componentDetailText { display: none; }
/* -------------------------------------------------
8. Misc helpers
------------------------------------------------- */
/* Hide numeric markers in metric columns (if any) */
#host_metrics_column td { list-style: none; padding-left: 0; margin-left: 0; }
#host_metrics_column td { list-style: none; padding-left: 0; margin-left: 0; }
.host-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 6px;
margin-right: 8px;
vertical-align: middle;
background: #808080; /* default unknown / no timestamp */
transition: background-color 1s linear; /* smooth fade */
}
/* -------------------------------------------------
9. Mobile adjustments
------------------------------------------------- */
@media (max-width: 768px) {
/* 1. Make the whole page a column */
.wrapper {
flex-direction: column;
}
/* 2. Hide the sidebar initially */
.sidebar {
position: relative; /* take it out of the flow */
width: 100%;
max-height: 0; /* collapsed */
overflow: hidden;
transition: max-height 0.3s ease-out;
background: var(--bg-sidebar);
padding: 0; /* remove padding */
}
.sidebar.show {
max-height: 500px; /* enough for all items */
padding: 1rem;
}
/* 3. Move the toggle button into the header */
.mobile-toggle {
display: block;
font-size: 1.5rem;
background: transparent;
border: none;
color: var(--clr-accent);
padding: 0.5rem;
margin-bottom: 0.5rem;
}
/* 4. Main content no left padding */
.main {
padding-left: 0;
padding-right: 1rem;
}
/* 5. Table scroll on small screens */
.card table {
width: 100%;
table-layout: fixed;
}
.card table thead,
.card table tbody,
.card table tr,
.card table td,
.card table th {
display: block;
}
.card table tbody {
overflow-x: auto;
white-space: nowrap;
}
.card table td,
.card table th {
display: inline-block;
vertical-align: top;
width: 30%;
}
/* 6. Adjust the host status dot positioning */
.host-status {
margin-left: 2px;
margin-right: 12px;
}
}
/* Optional: style the drawer indicator */
.sidebar.show::before {
content: "✕ Close";
display: block;
padding: 0.5rem 1rem;
background: var(--bg-sidebar);
color: var(--clr-accent);
font-weight: bold;
}

View File

@ -61,65 +61,66 @@ io.on('connection', async socket => {
/* ---------- 3. Serve static files ----------------------------------- */
/* --------------------------------------------------------------------- */
app.use(express.static('public'));
/* --- 4. Redis subscriber (patched) --------------------------------- */
const redisClient = createClient({
url: 'redis://192.168.37.1:6379',
socket: { keepAlive: 60000, // 60s TCP keep-alive
reconnectStrategy: attempts => Math.min(attempts * 100, 3000) } // back-off
});
/* --------------------------------------------------------------------- */
/* ---------- 4. Redis subscriber ------------------------------------- */
/* --------------------------------------------------------------------- */
const redisClient = createClient({ url: 'redis://192.168.37.1:6379' });
redisClient.on('error', err => console.error('Redis error', err));
(async () => {
await redisClient.connect();
const sub = redisClient.duplicate(); // duplicate to keep separate pub/sub
const sub = redisClient.duplicate();
await sub.connect();
// Subscribe to the channel that sends host stats
await sub.subscribe(
['host_metrics'],
(message, channel) => {
let payload;
try {
payload = JSON.parse(message); // message is a JSON string
} catch (e) {
console.error(`Failed to parse ${channel}`, e);
return;
}
io.emit(channel, payload);
// --------------------------------------------------------------------
// Helper that re-subscribes to a channel (and re-sends the handler)
// --------------------------------------------------------------------
async function safeSubscribe(channel, handler) {
try {
await sub.subscribe(channel, handler);
console.log(`Subscribed to ${channel}`);
} catch (e) {
console.error(`Failed to subscribe to ${channel}`, e);
}
);
}
// Subscribe to the channel that sends host stats
await sub.subscribe(
['client_summary'],
(message, channel) => {
let payload;
try {
payload = JSON.parse(message); // message is a JSON string
} catch (e) {
console.error(`Failed to parse ${channel}`, e);
return;
}
// ---------------------------------------------------------------
// Subscribe to all required channels
// ---------------------------------------------------------------
await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg));
await safeSubscribe('client_summary', (msg) => forward('client_summary', msg));
// ---------------------------------------------------------------
// Forward messages to Socket.io
// ---------------------------------------------------------------
function forward(channel, message) {
try {
const payload = JSON.parse(message);
io.emit(channel, payload);
} catch (e) {
console.error(`Failed to parse message from ${channel}`, e);
}
);
}
// Subscribe to the channel that sends host stats
await sub.subscribe(
['client_hostnames'],
(message, channel) => {
let payload;
try {
payload = JSON.parse(message); // message is a JSON string
} catch (e) {
console.error(`Failed to parse ${channel}`, e);
return;
}
io.emit(channel, payload);
}
);
// ----------------------------------------------------------------
// Re-subscribe automatically when the Redis connection reconnects
// ----------------------------------------------------------------
sub.on('reconnecting', () => console.log('Redis reconnecting…'));
sub.on('ready', async () => {
console.log('Redis ready - re-subscribing to all channels');
await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg));
await safeSubscribe('client_summary', (msg) => forward('client_summary', msg));
});
sub.on('error', err => console.error('Subscriber error', err));
// Optional: if the connection ends for any reason, close the process
sub.on('end', () => {
console.error('Redis connection closed - exiting');
process.exit(1);
});
})();
/* --------------------------------------------------------------------- */

View File

@ -11,7 +11,7 @@ listen 80;
server_name localhost;
# ---------------------------------------
# The API only /descriptor
# API Endpoints
# ---------------------------------------
location = /descriptor {
proxy_pass http://192.168.37.1:5000/descriptor;
@ -20,6 +20,20 @@ server_name localhost;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /update_client {
proxy_pass http://192.168.37.1:5000/update_client;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /create_client {
proxy_pass http://192.168.37.1:5000/create_client;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ---------------------------------------
# WebSocket endpoint

View File

@ -132,5 +132,4 @@
state: present
force_source: true
...

View File

@ -11,8 +11,9 @@
- name: Build API
include_tasks: api.yaml
# set up web stack
# set up web stack
- name: Build Web Dashboard
when: not disable_local_api
include_tasks: web.yaml
#- name: Purge Old Containers

View File

@ -4,15 +4,7 @@
- name: Cosmostat - Server Dashboard - replace index.php
copy:
src: server/server.php
dest: "{{ service_control_web_folder }}/index.php"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Server Dashboard - copy sidebar.js
copy:
src: server/sidebar.js
dest: "{{ service_control_web_folder }}/src/sidebar.js"
dest: "{{ service_control_web_folder }}/html/index.php"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
@ -20,14 +12,14 @@
- name: Cosmostat - Server Dashboard - copy system_metrics.js
copy:
src: server/system_metrics.js
dest: "{{ service_control_web_folder }}/src/system_metrics.js"
dest: "{{ service_control_web_folder }}/html/src/system_metrics.js"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Server Dashboard - delete redis.js
ansible.builtin.file:
path: "{{ service_control_web_folder }}/src/redis.js"
path: "{{ service_control_web_folder }}/html/src/redis.js"
state: absent
...

View File

@ -24,6 +24,12 @@
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
#######################
# configure as server
- name: Cosmostat - Web - Configure Server Dashboard
when: cosmostat_server | bool
include_tasks: server.yaml
- name: Cosmostat - Web - template docker-compose.yaml
template:

View File

@ -20,6 +20,7 @@ ansible_hostname: "{{ ansible_hostname }}"
# docker subnet, will use to bind the IP in default secure mode
api_bind_ip: {{ api_bind_ip }}
docker_gateway: {{ docker_gateway }}
local_api_address: {{ local_api_address }}
# python system variables, no quotes for bool or int
secure_api: {{ secure_api }}
@ -33,4 +34,7 @@ custom_api_port: {{ custom_api_port }}
cosmostat_server: {{ cosmostat_server }}
cosmostat_server_api: "{{ cosmostat_server_api }}"
cosmostat_server_reporter: {{ cosmostat_server_reporter }}
disable_local_api: {{ disable_local_api }}
REAL_API_KEY: "{{ REAL_API_KEY }}"
cosmostat_server_ip: "{{ cosmostat_server_ip }}"
...

15
templates/vpn_client.conf Normal file
View File

@ -0,0 +1,15 @@
client
dev tun
proto {{ vpn_proto }}
remote {{ vpn_server_ip }} {{ vpn_port }}
resolv-retry infinite
nobind
persist-key
persist-tun
user nobody
group nogroup
tls-auth {{ psk_key_path }} 1
cipher AES-256-GCM
auth SHA256

19
templates/vpn_server.conf Normal file
View File

@ -0,0 +1,19 @@
ifconfig-pool-persist ipp.txt
port {{ vpn_port }}
proto {{ vpn_proto }}
dev tun
tls-crypt {{ psk_key_path }}
server {{ vpn_network }} {{ vpn_netmask }}
topology subnet
push "route {{ private_server_address }} 255.255.255.255"
keepalive 10 120
cipher AES-256-GCM
auth SHA256
user nobody
group nogroup
persist-key
persist-tun
status openvpn-status.log
verb 3
explicit-exit-notify 1