cosmostat working
This commit is contained in:
@ -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
|
||||
...
|
||||
@ -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,7 +493,7 @@ 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:
|
||||
@ -500,6 +507,22 @@ class 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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -94,14 +94,32 @@ class CosmostatServer:
|
||||
client = self.get_system(system_uuid)
|
||||
return client.hostname
|
||||
|
||||
def get_client_hostnames(self, send_age = False):
|
||||
result = []
|
||||
def get_client_timestamp(self, system_hostname: str):
|
||||
client = self.get_system(get_uuid_from_hostname(system_hostname))
|
||||
return client.data_timestamp
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -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,6 +266,7 @@ def client_hostnames():
|
||||
|
||||
# update client on server
|
||||
def run_update_client(this_client):
|
||||
if public_api_check(this_client):
|
||||
if not cosmostat_server.check_uuid(this_client["uuid"]):
|
||||
return { "message": "client not found" }
|
||||
else:
|
||||
@ -253,9 +279,14 @@ def run_update_client(this_client):
|
||||
"redis_data": this_client,
|
||||
"timestamp_update": timestamp_update
|
||||
}
|
||||
else:
|
||||
return{
|
||||
"status": "api failure"
|
||||
}
|
||||
|
||||
# create client on server
|
||||
def run_create_client(this_client):
|
||||
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 {
|
||||
@ -264,6 +295,18 @@ def run_create_client(this_client):
|
||||
"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):
|
||||
@ -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,11 +446,11 @@ 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():
|
||||
@ -414,12 +459,13 @@ if __name__ == '__main__':
|
||||
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
|
||||
######################################
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -1,14 +1,11 @@
|
||||
<?php
|
||||
/* -------------------------------------------------------------
|
||||
* Cosmostat Dashboard - updated to support host‑specific 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'] ?? '';
|
||||
// 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 -->
|
||||
<!-- The list will be populated by JavaScript -->
|
||||
<ol id="endpointList"></ol>
|
||||
</div>
|
||||
</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>
|
||||
<?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>
|
||||
<?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>
|
||||
<!-- Live content, javascript rendered -->
|
||||
<div id="host_metrics" class="column">
|
||||
Connecting...
|
||||
</div> <!--/live content -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($systemComponents)): ?>
|
||||
<h2>Components</h2>
|
||||
</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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?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>
|
||||
@ -1,134 +0,0 @@
|
||||
|
||||
// Helper - return the value of the ?host= query‑string
|
||||
function getSelectedHost() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('host') || '';
|
||||
}
|
||||
|
||||
// Build the endpoints list when we receive data
|
||||
socket.on('client_hostnames', rawMsg => {
|
||||
// rawMsg is the JSON string that redis-cli prints
|
||||
let hosts;
|
||||
try {
|
||||
hosts = JSON.parse(rawMsg);
|
||||
} catch (e) {
|
||||
console.warn('Could not parse client_hostnames message', rawMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanity‑check
|
||||
if (!Array.isArray(hosts)) { return; }
|
||||
|
||||
const ol = document.getElementById('endpointList');
|
||||
const selected = getSelectedHost().toLowerCase();
|
||||
|
||||
// Clear old list
|
||||
ol.innerHTML = '';
|
||||
|
||||
hosts.forEach(host => {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.href = '?host=' + encodeURIComponent(host);
|
||||
a.textContent = host;
|
||||
if (host.toLowerCase() === selected) {
|
||||
a.classList.add('active');
|
||||
}
|
||||
li.appendChild(a);
|
||||
ol.appendChild(li);
|
||||
});
|
||||
});
|
||||
|
||||
/* -----------------------------------------------
|
||||
2. (Optional) Re‑build the list if the URL changes
|
||||
----------------------------------------------- */
|
||||
window.addEventListener('popstate', () => {
|
||||
// When the user navigates via back/forward the page
|
||||
// still holds the old list, so we rebuild it.
|
||||
const currentSelected = getSelectedHost().toLowerCase();
|
||||
const anchors = document.querySelectorAll('#endpointList a');
|
||||
anchors.forEach(a => {
|
||||
a.classList.toggle('active', a.textContent.toLowerCase() === currentSelected);
|
||||
});
|
||||
});
|
||||
|
||||
(function () {
|
||||
/* ----------------------------------------------------------
|
||||
Use the socket that system_metrics.js already created.
|
||||
If for some reason it isn’t defined, create a new one.
|
||||
---------------------------------------------------------- */
|
||||
const sock = typeof socket !== 'undefined' ? socket : io();
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Return the hostname that is currently selected in the URL
|
||||
(the value of the “?host=…” query string).
|
||||
---------------------------------------------------------- */
|
||||
function getSelectedHost() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('host') || '';
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Populate <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,
|
||||
re‑apply 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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@ -1,148 +1,181 @@
|
||||
/* ------------------------------------------------------------
|
||||
1. Socket-IO connection & helper functions (unchanged)
|
||||
------------------------------------------------------------ */
|
||||
const socket = io();
|
||||
|
||||
socket.on('client_summary', renderStatsTable);
|
||||
|
||||
socket.on('connect_error', err => {
|
||||
safeSetText('client_summary', `Could not connect to server - ${err.message}`);
|
||||
/* ==============================================================
|
||||
system_metrics.js
|
||||
==============================================================
|
||||
Updated to use the unique `short_id` (the system’s 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('reconnect', attempt => {
|
||||
safeSetText('client_summary', `Re-connected (attempt ${attempt})`);
|
||||
});
|
||||
/* ==========================================================
|
||||
Color constants – unchanged
|
||||
========================================================== */
|
||||
const GREEN = [ 39, 174, 96]; // #27ae60
|
||||
const YELLOW = [243, 156, 18]; // #f39c12
|
||||
const RED = [192, 57, 43]; // #c0392b
|
||||
|
||||
/* ==========================================================
|
||||
Helpers
|
||||
========================================================== */
|
||||
const hostTimestamps = {}; // keyed by short_id
|
||||
|
||||
const toRgb = (r, g, b) => `rgb(${r},${g},${b})`;
|
||||
|
||||
const T20 = 20 * 1000;
|
||||
const T40 = 40 * 1000;
|
||||
const T60 = 60 * 1000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
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');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
payload = raw;
|
||||
/* ------------------------------------------------------------------
|
||||
Get the *short_id* from the query string
|
||||
------------------------------------------------------------------ */
|
||||
function getSelectedId() {
|
||||
return new URLSearchParams(window.location.search).get('host') || '';
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload) || !payload.length) {
|
||||
safeSetText('client_summary', 'No data available');
|
||||
return;
|
||||
/* ==========================================================
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
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');
|
||||
/* ==========================================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
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);
|
||||
const merged = mergeRowsByName(data);
|
||||
const ordered = orderRows(merged);
|
||||
const table = buildTable(ordered);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
const merged = [];
|
||||
Object.entries(groups).forEach(([source, grp]) => {
|
||||
merged.push({
|
||||
Source: source,
|
||||
Metric: grp.Metric,
|
||||
Data: grp.Data,
|
||||
});
|
||||
});
|
||||
|
||||
return merged;
|
||||
return Object.entries(groups).map(([src, g]) => ({
|
||||
Source: src,
|
||||
Metric: g.Metric,
|
||||
Data: g.Data,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
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;
|
||||
});
|
||||
|
||||
const map = {};
|
||||
priority.forEach((s, i) => map[s] = i);
|
||||
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;
|
||||
const ai = map.hasOwnProperty(a.Source) ? map[a.Source] : Infinity;
|
||||
const bi = map.hasOwnProperty(b.Source) ? map[b.Source] : Infinity;
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
6. Build an HTML table from an array of objects
|
||||
------------------------------------------------------------ */
|
||||
function buildTable(data) {
|
||||
function buildTable(rows) {
|
||||
const cols = ['Source', 'Metric', 'Data'];
|
||||
const table = document.createElement('table');
|
||||
|
||||
// Header
|
||||
const thead = table.createTHead();
|
||||
const headerRow = thead.insertRow();
|
||||
@ -151,29 +184,126 @@ function buildTable(data) {
|
||||
th.textContent = col;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
|
||||
// Body
|
||||
const tbody = table.createTBody();
|
||||
data.forEach(item => {
|
||||
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, idx) => {
|
||||
td.id = 'host_metrics_column';
|
||||
val.forEach((v, i) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = v;
|
||||
td.appendChild(span);
|
||||
if (idx < val.length - 1) td.appendChild(document.createElement('br'));
|
||||
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();
|
||||
});
|
||||
|
||||
/* ==========================================================
|
||||
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 15 s
|
||||
========================================================== */
|
||||
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)))
|
||||
);
|
||||
});
|
||||
})();
|
||||
@ -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-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;
|
||||
}
|
||||
|
||||
|
||||
@ -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, // 60 s 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;
|
||||
// --------------------------------------------------------------------
|
||||
// Helper that re-subscribes to a channel (and re-sends the handler)
|
||||
// --------------------------------------------------------------------
|
||||
async function safeSubscribe(channel, handler) {
|
||||
try {
|
||||
payload = JSON.parse(message); // message is a JSON string
|
||||
await sub.subscribe(channel, handler);
|
||||
console.log(`Subscribed to ${channel}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${channel}`, e);
|
||||
return;
|
||||
console.error(`Failed to subscribe to ${channel}`, e);
|
||||
}
|
||||
io.emit(channel, payload);
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to the channel that sends host stats
|
||||
await sub.subscribe(
|
||||
['client_summary'],
|
||||
(message, channel) => {
|
||||
let payload;
|
||||
// ---------------------------------------------------------------
|
||||
// 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 {
|
||||
payload = JSON.parse(message); // message is a JSON string
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${channel}`, e);
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(message);
|
||||
io.emit(channel, payload);
|
||||
}
|
||||
);
|
||||
|
||||
// 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;
|
||||
console.error(`Failed to parse message from ${channel}`, e);
|
||||
}
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -132,5 +132,4 @@
|
||||
state: present
|
||||
force_source: true
|
||||
|
||||
|
||||
...
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
# set up web stack
|
||||
- name: Build Web Dashboard
|
||||
when: not disable_local_api
|
||||
include_tasks: web.yaml
|
||||
|
||||
#- name: Purge Old Containers
|
||||
|
||||
@ -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
|
||||
|
||||
...
|
||||
@ -25,6 +25,12 @@
|
||||
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:
|
||||
src: docker-compose-php.yaml
|
||||
|
||||
@ -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
15
templates/vpn_client.conf
Normal 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
19
templates/vpn_server.conf
Normal 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
|
||||
Reference in New Issue
Block a user