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 - jc
- smartmontools - smartmontools
- inxi - inxi
- easy-rsa
# python venv packages # python venv packages
cosmostat_venv_packages: | cosmostat_venv_packages: |
@ -38,7 +39,6 @@ docker_gateway: "192.168.37.1"
cosmostat_server_ip: "10.200.27.20" cosmostat_server_ip: "10.200.27.20"
api_bind_ip: "{{ docker_gateway }}" api_bind_ip: "{{ docker_gateway }}"
# cosmostat service folder root # cosmostat service folder root
service_folder: "/opt/cosmostat" service_folder: "/opt/cosmostat"
@ -52,6 +52,7 @@ api_service_folder: "{{ service_folder }}/api"
venv_folder: "{{ service_folder }}/venv" venv_folder: "{{ service_folder }}/venv"
api_service_exe: "{{ venv_folder }}/bin/python -u {{ api_service_folder }}/app.py" api_service_exe: "{{ venv_folder }}/bin/python -u {{ api_service_folder }}/app.py"
custom_api_port: "5000" custom_api_port: "5000"
REAL_API_KEY: "DEADBEEF"
# dashboard vars # dashboard vars
service_control_web_folder: "{{ service_folder }}/web" service_control_web_folder: "{{ service_folder }}/web"
@ -67,11 +68,14 @@ x64_arch: true
noisy_test: false noisy_test: false
debug_output: true debug_output: true
secure_api: true secure_api: true
push_redis: true push_redis: false
run_background : true run_background : true
log_output: true log_output: true
update_frequency: "1" update_frequency: "1"
cosmostat_server: true 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 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.virt_ignore = self._descriptor.get('virt_ignore', [])
self.multi_metrics = self._descriptor.get('multi_metrics', []) self.multi_metrics = self._descriptor.get('multi_metrics', [])
self.arch_check = self._descriptor.get('arch_check', []) self.arch_check = self._descriptor.get('arch_check', [])
self.php_extra_list = self._descriptor.get('php_extra', [])
if self.is_virtual: if self.is_virtual:
self.virt_ignore = [] self.virt_ignore = []
@ -173,6 +174,13 @@ class Component:
log_data(log_output = f"result - {result_command}", log_level = "debug_output") log_data(log_output = f"result - {result_command}", log_level = "debug_output")
return result_command 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 # keyed data functions
######################################################## ########################################################
@ -407,7 +415,6 @@ class System:
if multi_check: if multi_check:
log_data(log_output = f"Creating one component of type {component_name} for each one found", log_level = "log_output") 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_type_device_list = get_device_list(component_name)
component_id = 0
for this_device in component_type_device_list: for this_device in component_type_device_list:
this_component_ID = component_type_device_list.index(this_device) this_component_ID = component_type_device_list.index(this_device)
this_component_name = f"{component_name} {this_component_ID}" this_component_name = f"{component_name} {this_component_ID}"
@ -486,20 +493,36 @@ class System:
result.append(metric) result.append(metric)
return result return result
def get_system_properties(self, human_readable = False): def get_system_properties(self, human_readable = False, php_extra = False):
result = [] result = []
for name, value in self._properties.items(): for name, value in self._properties.items():
if human_readable: if human_readable:
result.append({ result.append({
"Source": "System", "Source": "System",
"Property": f"{name}: {value}" "Property": f"{name}: {value}"
}) })
else: else:
result.append({ result.append({
"Source": "System", "Source": "System",
"Property": name, "Property": name,
"Value": value "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 return result
######################################################## ########################################################
@ -584,7 +607,12 @@ def run_command(cmd, zero_only=False, use_shell=True, req_check = True):
except: except:
return output_lines 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): def get_device_list(device_type_name: str):
result = [] result = []
for component in component_class_tree: for component in component_class_tree:
precheck_value = 1 precheck_value = 1
@ -594,6 +622,7 @@ def get_device_list(device_type_name: str):
precheck_value = int(precheck_value_output) precheck_value = int(precheck_value_output)
log_data(log_output = f"Precheck found - {precheck_command} - {precheck_value}", log_level = "log_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: if component["name"] == device_type_name and precheck_value != 0:
device_list_command = component["device_list"] device_list_command = component["device_list"]
device_list_result = run_command(device_list_command) device_list_result = run_command(device_list_command)
result = device_list_result result = device_list_result

View File

@ -1,5 +1,6 @@
import yaml import yaml
from urllib.parse import urlparse from urllib.parse import urlparse
import secrets, string
####################################################################### #######################################################################
### Settings Handler Functions ### Settings Handler Functions
####################################################################### #######################################################################
@ -16,7 +17,11 @@ app_settings = {
"cosmostat_server_reporter": False, "cosmostat_server_reporter": False,
"update_frequency": 1, "update_frequency": 1,
"custom_api_port": "5000", "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: with open('cosmostat_settings.yaml', 'r') as f:
@ -61,9 +66,7 @@ def run_cosmostat_reporter():
def service_gateway_ip(): def service_gateway_ip():
result = "0.0.0.0" result = "0.0.0.0"
if cosmostat_settings["cosmostat_server"]: if cosmostat_settings["secure_api"] and not cosmostat_settings["cosmostat_server"]:
result = urlparse(cosmostat_settings["cosmostat_server_api"]).hostname
elif cosmostat_settings["secure_api"]:
result = cosmostat_bind_ip() result = cosmostat_bind_ip()
return result return result

View File

@ -93,15 +93,33 @@ class CosmostatServer:
def get_client_hostname(self, system_uuid: str): def get_client_hostname(self, system_uuid: str):
client = self.get_system(system_uuid) client = self.get_system(system_uuid)
return client.hostname 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): def get_uuid_from_hostname(self, system_hostname):
result = [] result = ""
for system in self.systems: for system in self.systems:
data_age = time.time() - system.data_timestamp if system.hostname == system_hostname:
if int(data_age) > 60: result = system.uuid
self.systems.remove(system) return result
else:
result.append(system.hostname) 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 return result

View File

@ -4,6 +4,7 @@ from typing import Dict, Union
import json, time, redis, yaml import json, time, redis, yaml
import base64, hashlib import base64, hashlib
import secrets, string
import requests import requests
from requests import RequestException, Response from requests import RequestException, Response
@ -35,7 +36,7 @@ def update_redis_server():
if run_cosmostat_server(): if run_cosmostat_server():
update_redis_channel("client_summary", get_server_redis_data()) 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 # History Redis Tree
# Update history_stats Redis Channel # Update history_stats Redis Channel
@ -54,6 +55,7 @@ def get_server_redis_data():
for client in cosmostat_server.systems: for client in cosmostat_server.systems:
this_client_key = { this_client_key = {
"hostname": client.hostname, "hostname": client.hostname,
"data_timestamp": client.data_timestamp,
"uuid": client.uuid, "uuid": client.uuid,
"short_id": client.name, "short_id": client.name,
"redis_data": client.redis_data "redis_data": client.redis_data
@ -64,6 +66,7 @@ def get_server_redis_data():
def get_server_hostnames(): def get_server_hostnames():
return cosmostat_server.get_client_hostnames() return cosmostat_server.get_client_hostnames()
####################################################################### #######################################################################
### Client Flask Routes ### Client Flask Routes
####################################################################### #######################################################################
@ -152,7 +155,7 @@ def get_static_data(human_readable = False):
return cosmostat_client.get_static_metrics(human_readable) return cosmostat_client.get_static_metrics(human_readable)
def get_php_summary(): 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 = [] system_components = []
for component in cosmostat_client.get_components(): for component in cosmostat_client.get_components():
this_component = { this_component = {
@ -161,6 +164,18 @@ def get_php_summary():
} }
system_components.append(this_component) 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 = [{ result = [{
"system_properties": system_properties, "system_properties": system_properties,
"system_components": system_components "system_components": system_components
@ -229,7 +244,17 @@ def client_details():
def client_hostnames(): def client_hostnames():
result = [] result = []
if run_cosmostat_server(): 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: else:
result = {"message": "server not running on this endpoint"} result = {"message": "server not running on this endpoint"}
return jsonify(result) return jsonify(result)
@ -241,30 +266,48 @@ def client_hostnames():
# update client on server # update client on server
def run_update_client(this_client): def run_update_client(this_client):
if not cosmostat_server.check_uuid(this_client["uuid"]): if public_api_check(this_client):
return { "message": "client not found" } if not cosmostat_server.check_uuid(this_client["uuid"]):
else: return { "message": "client not found" }
timestamp_update = cosmostat_server.update_system(system_dictionary = this_client["redis_data"], system_uuid = this_client["uuid"]) else:
update_status = f'updated client {this_client["short_id"]}' 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 { return {
"status": update_status, "status": update_status,
"uuid": this_client["uuid"], "uuid": this_client["uuid"],
"redis_data": this_client, "redis_data": this_client,
"timestamp_update": timestamp_update "timestamp_update": timestamp_update
} }
else:
return{
"status": "api failure"
}
# create client on server # create client on server
def run_create_client(this_client): def run_create_client(this_client):
timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) if public_api_check(this_client):
update_status = f'created client {this_client["short_id"]}' timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
return { update_status = f'created client {this_client["short_id"]}'
"status": update_status, return {
"uuid": this_client["uuid"], "status": update_status,
"client_properties": this_client, "uuid": this_client["uuid"],
"timestamp_update": timestamp_update "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 # flask submission check function
def client_submit_check(request, dict_name: str): def client_submit_check(request, dict_name: str):
payload = {} payload = {}
@ -324,6 +367,7 @@ def get_client_details():
# Cosmostat Client Reporter # Cosmostat Client Reporter
def client_update(): def client_update():
api_url = f"{cosmostat_server_api()}update_client" api_url = f"{cosmostat_server_api()}update_client"
print(api_url)
payload = get_client_payload(get_client_redis_data(human_readable = False), "redis_data") 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 = "client_update - redis data from local client:", log_level = "noisy_test")
log_data(log_output = payload, 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, "uuid": this_uuid,
"short_id": this_short_id, "short_id": this_short_id,
"hostname": this_hostname, "hostname": this_hostname,
dictionary_name: system_dictionary dictionary_name: system_dictionary,
"API_KEY": app_settings["REAL_API_KEY"]
} }
return payload return payload
@ -401,25 +446,26 @@ if __name__ == '__main__':
# Background Loop Function # Background Loop Function
def background_loop(): def background_loop():
# Update all data on the System object # Update all data on the System object unless this is the server
if cosmostat_client.check_system_timer(): if cosmostat_client.check_system_timer() and not run_cosmostat_server():
cosmostat_client.update_system_state() 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() update_redis_server()
if run_cosmostat_reporter(): if run_cosmostat_reporter():
if int(time.time()) % 5 == 0 and not cosmostat_client.check_system_timer(): if int(time.time()) % 5 == 0 and not cosmostat_client.check_system_timer():
cosmostat_client.update_system_state() cosmostat_client.update_system_state()
client_update() client_update()
if run_cosmostat_server(): 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") 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) run_update_client(this_client)
time.sleep(0.5) time.sleep(0.5)
###################################### ######################################
@ -443,14 +489,14 @@ if __name__ == '__main__':
# send initial stats update to redis # 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() update_redis_server()
###################################### ######################################
# Flask scheduler for scanner # 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") log_data(log_output = "Loading flask background subroutine...", log_level = "log_output")
scheduler.add_job(id='background_loop', scheduler.add_job(id='background_loop',
@ -467,11 +513,15 @@ if __name__ == '__main__':
###################################### ######################################
# Flask API # Flask API
###################################### ######################################
print(f"gateway: {service_gateway_ip()} - port: {service_api_port()}")
app.run(debug=False, host=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": [ "arch_variance": [
"current_mhz", "current_mhz",
"Clock Speed" "Clock Speed"
],
"php_extra" :[
"CPU Model"
] ]
}, },
{ {
@ -132,20 +135,23 @@
"RAM Type", "RAM Type",
"RAM Speed", "RAM Speed",
"RAM Voltage" "RAM Voltage"
],
"php_extra" :[
"Total GB"
] ]
}, },
{ {
"name": "LAN", "name": "LAN",
"description": "{Device Name} - {Device ID} - {MAC Address}", "description": "{Device Name} - {Device ID}",
"multi_check": "True", "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}}' ", "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": { "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 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" "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": { "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 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 ')'", "Data Received": "ifconfig {this_device} | grep TX | grep bytes | cut -d '(' -f2 | tr -d ')'",
"Link State": "cat /sys/class/net/{this_device}/operstate", "Link State": "cat /sys/class/net/{this_device}/operstate",
@ -155,13 +161,28 @@
"IP Address" "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", "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", "multi_check": "True",
"device_list": "nvidia-smi --query-gpu=index --format=csv,noheader,nounits", "device_list": "nvidia-smi --query-gpu=index --format=csv,noheader,nounits",
"properties": { "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}", "Device ID": "echo NVGPU{this_device}",
"Driver Version": "nvidia-smi --id={this_device} --query-gpu=driver_version --format=csv,noheader,nounits", "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", "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" "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", "name": "STOR",
"description": "{Device Path} is of type {Drive Type} with capacity of {Total Capacity}.", "description": "{Device Path} is of type {Drive Type} with capacity of {Total Capacity}.",
"multi_check": "True", "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": { "properties": {
"Device Name": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{{print $1}}' | grep {this_device}", "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}", "Device Path": "echo /dev/{this_device}",
"Drive Type": "lsblk -d -o NAME,TRAN | grep {this_device} | awk '{{print ($2 != \"\" ? $2 : \"missing\")}}'", "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}}'", "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" "SMART Check": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r .smart_status.passed"
@ -192,5 +216,34 @@
"metrics": { "metrics": {
"placeholder": "" "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 <?php
/* -------------------------------------------------------------
* Cosmostat Dashboard - updated to support hostspecific view
* -------------------------------------------------------------
*/
function h(string $s): string function h(string $s): string
{ {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
} }
/* --------------------- 1. Load API data --------------------- */ // Load API data
$raw_api_settings = file('/opt/api_settings/cosmostat_settings.yaml', $raw_api_settings = file('/opt/api_settings/cosmostat_settings.yaml',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 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>'); die('<p style="color:red;">Malformed JSON returned from the API.</p>');
} }
/* --------------------- 2. Resolve selected host ------------- */ // hostname get handler
$selectedHost = $_GET['host'] ?? ''; $selectedId = $_GET['host'] ?? ''; // the value passed in ?host=
$selectedIdx = null; $selectedIdx = null;
foreach ($clients as $idx => $client) { foreach ($clients as $idx => $client) {
if (strtolower($client['hostname']) === strtolower($selectedHost)) { if (isset($client['short_id']) && $client['short_id'] === $selectedId) {
$selectedIdx = $idx; $selectedIdx = $idx;
break; break;
} }
} }
if ($selectedIdx === null) { if ($selectedIdx === null) {
// no match - default to the first host (or none) // No match fall back to the first client (or none)
$selectedIdx = 0; $selectedIdx = 0;
$selectedHost = $clients[$selectedIdx]['hostname'] ?? ''; $selectedId = $clients[$selectedIdx]['short_id'] ?? '';
} }
$client = $clients[$selectedIdx] ?? null; $client = $clients[$selectedIdx] ?? null;
$properties = $client['client_properties'][0] ?? []; $properties = $client['client_properties'][0] ?? [];
$systemProperties = $properties['system_properties'] ?? []; $systemProperties = $properties['system_properties'] ?? [];
$systemComponents = $properties['system_components'] ?? []; $systemComponents = $properties['system_components'] ?? [];
$selectedHost = $clients[$selectedIdx]['hostname'];
?> ?>
@ -76,86 +75,82 @@ $systemComponents = $properties['system_components'] ?? [];
<!-- Sidebar --> <!-- Sidebar -->
<nav class="sidebar"> <nav class="sidebar">
<div class="sidebar"> <h3>Endpoints</h3>
<h3>Endpoints</h3> <!-- The list will be populated by JavaScript -->
<!-- The list will be populated by the JavaScript below --> <ol id="endpointList"></ol>
<ol id="endpointList"></ol>
</div>
</nav> </nav>
<!-- Main content --> <!-- Main content -->
<div class="main"> <div class="main">
<!-- Header Card -->
<div class="card"> <div class="card">
<h2>Matt-Cloud Cosmostat Dashboard</h2> <h2>Matt-Cloud Cosmostat Dashboard</h2>
<p>This dashboard shows the local Matt-Cloud system stats.</p> <p>This dashboard shows the local Matt-Cloud system stats.</p>
<div class="help-link" id="helpToggle">API</div> <div class="help-link" id="helpToggle">API</div>
</div> </div> <!-- / Header Card -->
<!-- Hidden API Card -->
<div id="helpText" class="card"> <div id="helpText" class="card">
<strong>Component Desriptor</strong> <strong>Component Desriptor</strong>
<p>To view the component descriptor, you may <br> <p>To view the component descriptor, you may <br>
<code>curl -s https://<?= h($_SERVER['SERVER_NAME']) ?>/descriptor</code></p> <code>curl -s https://<?= h($_SERVER['SERVER_NAME']) ?>/descriptor</code></p>
<p>This will return the entire JSON descriptor variable</p> <p>This will return the entire JSON descriptor variable</p>
</div> </div> <!-- / Header Card -->
<!-- summary card -->
<div class="card"> <div class="card">
<div id="host_components" class="column"> <?php if (!empty($systemProperties)): ?><h2>System Properties</h2>
<table><tr>
<?php if (!empty($systemProperties)): ?> <td>
<h2>System Properties</h2> <ul class="system-list">
<div class="system"> <?php foreach ($systemProperties as $prop): ?><li><?= h($prop['Property']) ?></li>
<table> <?php endforeach; ?></ul>
<tr> </td><td>
<td> <h2>Live System Metrics</h2>
<ul class="system-list"> <!-- Live content, javascript rendered -->
<?php foreach ($systemProperties as $prop): ?> <div id="host_metrics" class="column">
<li><?= h($prop['Property']) ?></li> Connecting...
<?php endforeach; ?> </div> <!--/live content -->
</ul> </td>
</td> </tr></table>
<td> <?php endif; ?><br>
<h2>Live System Metrics</h2> <!-- api help toggle -->
<div id="host_metrics" class="column">Connecting...</div> <div class="componentDetail-link" id="componentDetailToggle">Toggle Component Details</div>
</td> </div> <!--/summary card -->
</tr> <!-- hidden detail card -->
</table> <div id="componentDetailText" class="card">
</div> <?php if (!empty($systemComponents)): ?><h2>Components</h2>
<?php endif; ?> <!-- component bucket -->
<div class="components">
<?php if (!empty($systemComponents)): ?> <?php foreach ($systemComponents as $comp): ?>
<h2>Components</h2>
<div class="components"> <!-- individual component -->
<?php foreach ($systemComponents as $comp): ?> <div class="component">
<div class="component"> <h3><?= h($comp['component_name']) ?></h3>
<h3><?= h($comp['component_name']) ?></h3> <ul class="info-list">
<ul class="info-list"> <?php foreach ($comp['info_strings'] as $info): ?><li><?= h($info) ?></li>
<?php foreach ($comp['info_strings'] as $info): ?> <?php endforeach; ?></ul>
<li><?= h($info) ?></li> </div> <!--/individual component -->
<?php endforeach; ?> <?php endforeach; ?></div> <!--/component bucket -->
</ul> <?php endif; ?></div> <!--/hidden detail card -->
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div> <!-- /main --> </div> <!-- /main -->
</div> <!-- /wrapper --> </div> <!-- /wrapper -->
<!-- Socket.IO client library --> <!-- Socket.IO client library -->
<script src="socket.io/socket.io.js"></script> <script src="socket.io/socket.io.js"></script>
<!-- system metrics script --> <!-- system metrics script -->
<script src="src/system_metrics.js"></script> <script src="src/system_metrics.js"></script>
<!-- sidebar script --> <!-- Panel Toggles -->
<script src="src/sidebar.js"></script>
<script> <script>
document.getElementById('helpToggle').addEventListener('click', function () { document.getElementById('helpToggle').addEventListener('click', function () {
const help = document.getElementById('helpText'); const help = document.getElementById('helpText');
help.style.display = help.style.display === 'none' || help.style.display === '' ? 'block' : 'none'; 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> </script>
</body> </body>
</html> </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) system_metrics.js
------------------------------------------------------------ */ ==============================================================
const socket = io(); 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 => { const toRgb = (r, g, b) => `rgb(${r},${g},${b})`;
safeSetText('client_summary', `Re-connected (attempt ${attempt})`);
});
function safeSetText(id, txt) { const T20 = 20 * 1000;
const el = document.getElementById(id); const T40 = 40 * 1000;
if (el) el.textContent = txt; const T60 = 60 * 1000;
}
/* ------------------------------------------------------------ function getFreshnessColor(ageMs) {
2. Render the table for the *selected* host if (ageMs <= T20) {
------------------------------------------------------------ */ return toRgb(...GREEN);
function renderStatsTable(raw) { }
// Raw may be a string (from Redis) or already parsed by socket.io if (ageMs <= T40) {
let payload; const t = (ageMs - T20) / (T40 - T20);
if (typeof raw === 'string') { const r = Math.round(GREEN[0] + t * (YELLOW[0] - GREEN[0]));
try { const g = Math.round(GREEN[1] + t * (YELLOW[1] - GREEN[1]));
payload = JSON.parse(raw); const b = Math.round(GREEN[2] + t * (YELLOW[2] - GREEN[2]));
} catch (e) { return toRgb(r, g, b);
safeSetText('client_summary', 'Invalid data received'); }
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; return;
} }
} else { const merged = mergeRowsByName(data);
payload = raw; const ordered = orderRows(merged);
const table = buildTable(ordered);
table.id = 'host_metrics_table';
container.innerHTML = '';
container.appendChild(table);
} }
if (!Array.isArray(payload) || !payload.length) { function mergeRowsByName(rows) {
safeSetText('client_summary', 'No data available'); const groups = {}; // { Source: { Metric: [], Data: [] } }
return; rows.forEach(r => {
} const src = r.Source;
if (!src) return;
/* --------------------------------------------- if (!groups[src]) groups[src] = { Metric: [], Data: [] };
2a. Determine the hostname to display if ('Metric' in r && 'Data' in r) {
--------------------------------------------- */ groups[src].Metric.push(r.Metric);
const urlParams = new URLSearchParams(window.location.search); groups[src].Data.push(r.Data);
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 : '';
} }
}); });
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 1. Global settings & color palette
------------------------------------------------- */ ------------------------------------------------- */
:root { :root {
/* Dark theme body & card backgrounds */ /* Dark theme - body & card backgrounds */
--bg-body: #2c3e50; /* main page background */ --bg-body: #2c3e50; /* main page background */
--bg-card: #34495e; /* card / panel background */ --bg-card: #34495e; /* card / panel background */
--bg-sidebar: #3d566e; /* sidebar background (slightly lighter) */ --bg-sidebar: #3d566e; /* sidebar background (slightly lighter) */
@ -30,14 +30,23 @@ a { color: var(--clr-accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
/* ------------------------------------------------- /* -------------------------------------------------
2. Layout wrapper, sidebar, main 2. Layout - wrapper, sidebar, main
------------------------------------------------- */ ------------------------------------------------- */
.wrapper { display: flex; min-height: 100vh; } .wrapper { display: flex; min-height: 100vh; }
.sidebar { .sidebar {
width: 200px; position: fixed; /* keep sidebar visible during scroll */
background: var(--bg-sidebar); 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; 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 h3 { margin: 0 0 .4rem 0; font-size: 1.1rem; }
.sidebar ul { list-style: none; padding: 0; margin: 0; } .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 { color: var(--clr-accent); }
.sidebar a.active { font-weight: bold; } .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 3. Card component
------------------------------------------------- */ ------------------------------------------------- */
@ -105,7 +119,7 @@ li { margin-bottom: 10px; color: var(--clr-text); }
.component h3 { margin: 0 0 5px; } .component h3 { margin: 0 0 5px; }
/* ------------------------------------------------- /* -------------------------------------------------
7. Help toggle / modal 7. Panel toggles / modal
------------------------------------------------- */ ------------------------------------------------- */
.help-link { .help-link {
cursor: pointer; cursor: pointer;
@ -116,8 +130,117 @@ li { margin-bottom: 10px; color: var(--clr-text); }
.help-link:hover { text-decoration: underline; } .help-link:hover { text-decoration: underline; }
#helpText { display: none; } #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 8. Misc helpers
------------------------------------------------- */ ------------------------------------------------- */
/* Hide numeric markers in metric columns (if any) */ /* 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 ----------------------------------- */ /* ---------- 3. Serve static files ----------------------------------- */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
app.use(express.static('public')); 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)); redisClient.on('error', err => console.error('Redis error', err));
(async () => { (async () => {
await redisClient.connect(); await redisClient.connect();
const sub = redisClient.duplicate(); // duplicate to keep separate pub/sub
const sub = redisClient.duplicate();
await sub.connect(); await sub.connect();
// Subscribe to the channel that sends host stats // --------------------------------------------------------------------
await sub.subscribe( // Helper that re-subscribes to a channel (and re-sends the handler)
['host_metrics'], // --------------------------------------------------------------------
(message, channel) => { async function safeSubscribe(channel, handler) {
let payload; try {
try { await sub.subscribe(channel, handler);
payload = JSON.parse(message); // message is a JSON string console.log(`Subscribed to ${channel}`);
} catch (e) { } catch (e) {
console.error(`Failed to parse ${channel}`, e); console.error(`Failed to subscribe to ${channel}`, e);
return;
}
io.emit(channel, payload);
} }
); }
// Subscribe to the channel that sends host stats // ---------------------------------------------------------------
await sub.subscribe( // Subscribe to all required channels
['client_summary'], // ---------------------------------------------------------------
(message, channel) => { await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg));
let payload; await safeSubscribe('client_summary', (msg) => forward('client_summary', msg));
try {
payload = JSON.parse(message); // message is a JSON string // ---------------------------------------------------------------
} catch (e) { // Forward messages to Socket.io
console.error(`Failed to parse ${channel}`, e); // ---------------------------------------------------------------
return; function forward(channel, message) {
} try {
const payload = JSON.parse(message);
io.emit(channel, payload); 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( // Re-subscribe automatically when the Redis connection reconnects
['client_hostnames'], // ----------------------------------------------------------------
(message, channel) => { sub.on('reconnecting', () => console.log('Redis reconnecting…'));
let payload; sub.on('ready', async () => {
try { console.log('Redis ready - re-subscribing to all channels');
payload = JSON.parse(message); // message is a JSON string await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg));
} catch (e) { await safeSubscribe('client_summary', (msg) => forward('client_summary', msg));
console.error(`Failed to parse ${channel}`, e); });
return;
}
io.emit(channel, payload);
}
);
// Optional: if the connection ends for any reason, close the process
sub.on('error', err => console.error('Subscriber error', err)); sub.on('end', () => {
console.error('Redis connection closed - exiting');
process.exit(1);
});
})(); })();
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View File

@ -11,7 +11,7 @@ listen 80;
server_name localhost; server_name localhost;
# --------------------------------------- # ---------------------------------------
# The API only /descriptor # API Endpoints
# --------------------------------------- # ---------------------------------------
location = /descriptor { location = /descriptor {
proxy_pass http://192.168.37.1:5000/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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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 # WebSocket endpoint

View File

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

View File

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

View File

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

View File

@ -24,6 +24,12 @@
mode: 0755 mode: 0755
owner: "{{ service_user }}" owner: "{{ service_user }}"
group: "{{ 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 - name: Cosmostat - Web - template docker-compose.yaml
template: template:

View File

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