full server dashboard working

This commit is contained in:
2026-03-22 18:44:07 -07:00
parent 324eaff135
commit 97fdb3d5d8
19 changed files with 1441 additions and 169 deletions

View File

@ -2,7 +2,7 @@
# required system packages # required system packages
cosmostat_packages: cosmostat_packages:
- "{{ 'docker' if x64_arch else 'wmdocker' }}" - "{{ '' if x64_arch else 'wmdocker' }}"
- docker.io - docker.io
- docker-compose - docker-compose
- python3 - python3
@ -35,6 +35,9 @@ cosmostat_sudoers_content: |
# subnet for service # subnet for service
docker_subnet: "192.168.37.0/24" docker_subnet: "192.168.37.0/24"
docker_gateway: "192.168.37.1" docker_gateway: "192.168.37.1"
cosmostat_server_ip: "10.200.27.20"
api_bind_ip: "{{ docker_gateway }}"
# cosmostat service folder root # cosmostat service folder root
service_folder: "/opt/cosmostat" service_folder: "/opt/cosmostat"
@ -54,12 +57,13 @@ custom_api_port: "5000"
service_control_web_folder: "{{ service_folder }}/web" service_control_web_folder: "{{ service_folder }}/web"
public_dashboard: true public_dashboard: true
custom_port: "80" custom_port: "80"
web_src: "/web"
# other vars # other vars
quick_refresh: false quick_refresh: false
x64_arch: true x64_arch: true
# cosmostat_settings # cosmostat_settings, will be for special_server defaults
noisy_test: false noisy_test: false
debug_output: true debug_output: true
secure_api: true secure_api: true
@ -67,7 +71,7 @@ push_redis: true
run_background : true run_background : true
log_output: true log_output: true
update_frequency: "1" update_frequency: "1"
cosmostat_server: false cosmostat_server: true
cosmostat_server_api: "http://10.200.27.20/" cosmostat_server_api: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/"
cosmostat_server_reporter: false cosmostat_server_reporter: false
... ...

View File

@ -1,4 +1,5 @@
import yaml import yaml
from urllib.parse import urlparse
####################################################################### #######################################################################
### Settings Handler Functions ### Settings Handler Functions
####################################################################### #######################################################################
@ -32,8 +33,9 @@ with open('cosmostat_settings.yaml', 'r') as f:
app_settings[setting] = cosmos_setting app_settings[setting] = cosmos_setting
print("...Done") print("...Done")
# this returns the docker gateway from the settings # this returns the docker gateway from the settings
def docker_gateway_settings() -> str: def cosmostat_bind_ip() -> str:
return cosmostat_settings["docker_gateway"] return cosmostat_settings["docker_gateway"]
# this returns the jenkins user that ran the pipeline # this returns the jenkins user that ran the pipeline
@ -51,9 +53,23 @@ def jenkins_inventory_generation_timestamp_settings() -> str:
def run_cosmostat_server(): def run_cosmostat_server():
return cosmostat_settings["cosmostat_server"] return cosmostat_settings["cosmostat_server"]
def run_cosmostat_reporter():
result = False
if not cosmostat_settings["cosmostat_server"] and cosmostat_settings["cosmostat_server_reporter"]:
result = True
return result
def service_gateway_ip(): def service_gateway_ip():
result = "0.0.0.0"
if cosmostat_settings["cosmostat_server"]:
result = urlparse(cosmostat_settings["cosmostat_server_api"]).hostname
elif cosmostat_settings["secure_api"]:
result = cosmostat_bind_ip()
return result
def redis_gateway_ip():
if cosmostat_settings["secure_api"]: if cosmostat_settings["secure_api"]:
return docker_gateway_settings() return cosmostat_bind_ip()
else: else:
return "0.0.0.0" return "0.0.0.0"
@ -63,7 +79,7 @@ def cosmostat_server_api():
def service_api_port(): def service_api_port():
return cosmostat_settings["custom_api_port"] return cosmostat_settings["custom_api_port"]
def log_data(log_output:str, log_level = cosmostat_settings["noisy_test"]): def log_data(log_output:str, log_level = "noisy_test"):
log_levels = [ log_levels = [
"noisy_test", "noisy_test",
"debug_output", "debug_output",
@ -76,6 +92,9 @@ def log_data(log_output:str, log_level = cosmostat_settings["noisy_test"]):
else: else:
print(f"Warning - {log_level} not valid log level") print(f"Warning - {log_level} not valid log level")
if app_settings["cosmostat_server"]:
app_settings["cosmostat_server_reporter"] = False
log_data(log_output = "Warning - server and reporter cannot run concurrently, server is prioritized.", log_level = cosmostat_settings["log_output"])

View File

@ -21,7 +21,7 @@ from Cosmos_Settings import *
################################################################# #################################################################
################################################################# #################################################################
class Cosmostat: class CosmostatServer:
############################################################ ############################################################
# instantiate new Cosmostat server # instantiate new Cosmostat server
@ -32,7 +32,7 @@ class Cosmostat:
self.name = name self.name = name
self.short_id = self.short_uuid(self.name) self.short_id = self.short_uuid(self.name)
log_data(log_output = f"Cosmostat Server {self.short_id} initializing", log_level = "log_output") log_data(log_output = f"Cosmostat Server {self.short_id} initializing", log_level = "log_output")
# system contains an array of keys with component objects # system contains an array of CosmostatClient Objects
self.systems = [] self.systems = []
def __str__(self): def __str__(self):
@ -43,29 +43,38 @@ class Cosmostat:
self_string = f"Cosmostat Server {self.short_id}" self_string = f"Cosmostat Server {self.short_id}"
def add_system(self, system_dictionary: dict): def add_system(self, system_dictionary: dict):
new_system_key = { if not self.check_uuid(system_dictionary["uuid"]):
"data_timestamp": time.time(), new_cosmostat_clilent = CosmostatClient(
"uuid": system_dictionary["uuid"], name = system_dictionary["short_id"],
"short_id": system_dictionary["short_id"], uuid = system_dictionary["uuid"],
"client_properties": system_dictionary["client_properties"], hostname = system_dictionary["hostname"],
"redis_data": {} data_timestamp = time.time(),
} client_properties = system_dictionary["client_properties"],
log_data(log_output = f"Client system {system_dictionary["short_id"]} added", log_level = "log_output") redis_data = {}
self.systems.append(new_system_key) )
self.systems.append(new_cosmostat_clilent)
log_data(log_output = f'Client system {system_dictionary["short_id"]} added', log_level = "log_output")
return new_cosmostat_clilent.data_timestamp
def update_system(self, system_state: {}, system_uuid: str):
def update_system(self, system_dictionary: {}, system_uuid: str):
this_system = self.get_system(system_uuid) this_system = self.get_system(system_uuid)
this_system["redis_data"] = system_state this_system.redis_data = system_dictionary
this_system["data_timestamp"] = time.time() this_system.data_timestamp = time.time()
log_data(log_output = f"Client system {this_system["short_id"]} addupdateded", log_level = "log_output") log_data(log_output = f"Client system {this_system.name} update requested, {this_system.uuid}", log_level = "log_output")
return this_system["data_timestamp"] data_age = time.time() - this_system.data_timestamp
if int(data_age) > 60:
self.systems.remove(this_system)
return this_system.data_timestamp
def get_system(self, system_uuid: str) -> dict: def get_system(self, system_uuid: str):
result = {} log_data(log_output = f'Cosmostat - get_system - {system_uuid}', log_level = "debug_output")
result = None
for system in self.systems: for system in self.systems:
if system["uuid"] == system_uuid: if system.uuid == system_uuid:
return system result = system
break
return result return result
def short_uuid(self, value: str, length=8): def short_uuid(self, value: str, length=8):
@ -73,3 +82,53 @@ class Cosmostat:
hasher.update(value.encode('utf-8')) hasher.update(value.encode('utf-8'))
full_hex = hasher.hexdigest() full_hex = hasher.hexdigest()
return full_hex[:length] return full_hex[:length]
def check_uuid(self, uuid: str):
uuid_exists = False
for system in self.systems:
if system.uuid == uuid:
uuid_exists = True
return uuid_exists
def get_client_hostname(self, system_uuid: str):
client = self.get_system(system_uuid)
return client.hostname
def get_client_hostnames(self, send_age = False):
result = []
for system in self.systems:
data_age = time.time() - system.data_timestamp
if int(data_age) > 60:
self.systems.remove(system)
else:
result.append(system.hostname)
return result
class CosmostatClient:
############################################################
# instantiate new Cosmostat server
############################################################
def __init__(self, name: str, uuid: str, hostname: str, data_timestamp: float, client_properties: dict, redis_data: dict):
self.name = name
self.uuid = uuid
self.hostname = hostname
self.data_timestamp = data_timestamp
self.client_properties = client_properties
self.redis_data = redis_data
def __str__(self):
self_string = f'Cosmostat Client {self.name} - Hostname {self.hostname}'
return self_string
def __repr__(self):
self_string = f'Cosmostat Client {self.name} - Hostname {self.hostname}'
return self_string
def get_properties(self):
return self.client.properties
def get_redis(self):
return self.redis_data

View File

@ -1,12 +1,17 @@
from flask import Flask, jsonify, request from flask import Flask, jsonify, request, Response
from flask_apscheduler import APScheduler from flask_apscheduler import APScheduler
from typing import Dict, Union from typing import Dict, Union
import json, time, redis, yaml import json, time, redis, yaml
import base64, hashlib import base64, hashlib
import requests
from requests import RequestException, Response
from Components import * from Components import *
from Cosmos_Settings import * from Cosmos_Settings import *
from Cosmostat import *
# declare flask apps # declare flask apps
app = Flask(__name__) app = Flask(__name__)
scheduler = APScheduler() scheduler = APScheduler()
@ -16,7 +21,7 @@ scheduler = APScheduler()
####################################################################### #######################################################################
# Redis client - will publish updates # Redis client - will publish updates
r = redis.Redis(host=service_gateway_ip(), port=6379) r = redis.Redis(host=redis_gateway_ip(), port=6379)
def update_redis_channel(redis_channel, data): def update_redis_channel(redis_channel, data):
# Publish to the specified Redis channel # Publish to the specified Redis channel
@ -25,14 +30,14 @@ def update_redis_channel(redis_channel, data):
def update_redis_server(): def update_redis_server():
# Client Redis Tree # Client Redis Tree
if not run_cosmostat_server(): if cosmostat_client.check_system_timer():
if cosmostat_client.check_system_timer():
update_redis_channel("host_metrics", get_client_redis_data(human_readable = False)) update_redis_channel("host_metrics", get_client_redis_data(human_readable = False))
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())
# Server Redis Tree # History Redis Tree
# Update history_stats Redis Channel # Update history_stats Redis Channel
# update_redis_channel("history_stats", get_component_list()) # update_redis_channel("history_stats", get_component_list())
@ -48,13 +53,17 @@ def get_server_redis_data():
result = [] result = []
for client in cosmostat_server.systems: for client in cosmostat_server.systems:
this_client_key = { this_client_key = {
"uuid": client["uuid"], "hostname": client.hostname,
"short_id": client["short_id"], "uuid": client.uuid,
"redis_data": client["redis_data"] "short_id": client.name,
"redis_data": client.redis_data
} }
result.append(this_client_key) result.append(this_client_key)
return result return result
def get_server_hostnames():
return cosmostat_server.get_client_hostnames()
####################################################################### #######################################################################
### Client Flask Routes ### Client Flask Routes
####################################################################### #######################################################################
@ -175,30 +184,56 @@ def generate_state_definition():
####################################################################### #######################################################################
# update client on server # update client on server
@app.route('/update_client', methods=['GET']) @app.route('/update_client', methods=['POST'])
def update_client(): def update_client():
result = {} result = {}
# check the request and return payload if all good # check the request and return payload dict {} if all good
payload = client_submit_check(request = request, dict_name = "redis_data") payload = client_submit_check(request = request, dict_name = "redis_data")
this_client = cosmostat_server.get_system(uuid = payload["uuid"]) result = run_update_client(payload)
result = run_update_client(this_client)
return jsonify(result), 200 return jsonify(result), 200
# create client on server # create client on server
@app.route('/create_client', methods=['GET']) @app.route('/create_client', methods=['POST'])
def create_client(): def create_client():
result = {} result = {}
# check the request and return payload if all good # check the request and return payload dict {} if all good
payload = client_submit_check(request = request, dict_name = "client_properties") payload = client_submit_check(request = request, dict_name = "client_properties")
this_client = cosmostat_server.get_system(uuid = payload["uuid"]) if not cosmostat_server.check_uuid(payload["uuid"]):
result = run_create_client(this_client) result = run_create_client(payload)
else:
result = {"message": "object already exists, skipping creation"}
return jsonify(result), 200 return jsonify(result), 200
# api to validate Cosmostat Class # api to validate Cosmostat Class
@app.route('/client_summary', methods=['GET']) @app.route('/client_summary', methods=['GET'])
def client_summary(): def client_summary():
client_summary = get_client_summary() result = []
return jsonify() if run_cosmostat_server():
result = get_client_summary()
else:
result = {"message": "server not running on this endpoint"}
return jsonify(result)
# api to pull all data
@app.route('/client_details', methods=['GET'])
def client_details():
result = []
if run_cosmostat_server():
result = get_client_details()
else:
result = {"message": "server not running on this endpoint"}
return jsonify(result)
# api to get all hostnames
@app.route('/client_hostnames', methods=['GET'])
def client_hostnames():
result = []
if run_cosmostat_server():
result = cosmostat_server.get_client_hostnames()
else:
result = {"message": "server not running on this endpoint"}
return jsonify(result)
####################################################################### #######################################################################
### Server Flask Helpers ### Server Flask Helpers
@ -206,29 +241,34 @@ def client_summary():
# update client on server # update client on server
def run_update_client(this_client): def run_update_client(this_client):
if this_client == {}: if not cosmostat_server.check_uuid(this_client["uuid"]):
return { "message": "client not found" } return { "message": "client not found" }
update_status = f"updated client {this_client.short_id}" else:
timestamp_update = cosmostat_server.update_system(system_state = payload, system_uuid = payload["uuid"]) timestamp_update = cosmostat_server.update_system(system_dictionary = this_client["redis_data"], system_uuid = this_client["uuid"])
return { update_status = f'updated client {this_client["short_id"]}'
"status": update_status,
"uuid": payload["uuid"], return {
"timestamp": timestamp_update "status": update_status,
} "uuid": this_client["uuid"],
"redis_data": this_client,
"timestamp_update": timestamp_update
}
# create client on server # create client on server
def run_create_client(this_client): def run_create_client(this_client):
update_status = f"created client {this_client.short_id}" timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
timestamp_update = cosmostat_server.create_system(system_state = payload, system_uuid = payload["uuid"]) update_status = f'created client {this_client["short_id"]}'
return { return {
"status": update_status, "status": update_status,
"uuid": payload["uuid"], "uuid": this_client["uuid"],
"timestamp": timestamp_update "client_properties": this_client,
"timestamp_update": timestamp_update
} }
# flask submission check fucntion # flask submission check function
def client_submit_check(request, dict_name: str): def client_submit_check(request, dict_name: str):
required_keys = {"uuid", "short_id", "data_timestamp", dict_name} payload = {}
required_keys = {"uuid", "short_id", "hostname", dict_name}
if not request.is_json: if not request.is_json:
logging.warning("Received non-JSON request") logging.warning("Received non-JSON request")
return jsonify({"error": "Content-type must be application/json"}), 400 return jsonify({"error": "Content-type must be application/json"}), 400
@ -239,97 +279,98 @@ def client_submit_check(request, dict_name: str):
missing = required_keys - payload.keys() missing = required_keys - payload.keys()
if missing: if missing:
raise ValueError(f"Missing required keys: {', '.join(sorted(missing))}") raise ValueError(f"Missing required keys: {', '.join(sorted(missing))}")
return payload return payload
# generate cosmostat server summary # generate cosmostat server summary
def get_client_summary(): def get_client_summary():
result = [] result = []
for client in cosmostat_server.systems: for client in cosmostat_server.systems:
this_client_properties = client.get_system_properties(human_readable = True) data_age = time.time() - client.data_timestamp
this_client_components = []
for component in client.get_components():
this_component = {
"component_name": component.name,
"info_strings": component.get_properties_strings(return_simple = True)
}
this_client_components.append(this_component)
this_client = { this_client = {
"client_properties": this_client_properties, "uuid": client.uuid,
"client_components": this_client_components "short_id": client.name,
"data_age": data_age,
"hostname": client.hostname
} }
result.append(this_client) result.append(this_client)
if result == []:
result = {"message": "no clients reporting"}
return result
# no redis data needed here
def get_client_details():
result = []
for client in cosmostat_server.systems:
data_age = time.time() - client.data_timestamp
this_client = {
"uuid": client.uuid,
"short_id": client.name,
"client_properties": client.client_properties,
# "redis_data": client.redis_data,
"hostname": client.hostname
}
result.append(this_client)
if result == []:
result = {"message": "no clients reporting"}
return result return result
####################################################################### #######################################################################
### Cosmostat Client Subroutines ### Cosmostat Client Subroutines
####################################################################### #######################################################################
# since the API isn't running
# def local_client_update():
# Cosmostat Client Reporter # Cosmostat Client Reporter
def client_update(this_client: dict, api_endpoint = "update_client"): def client_update():
# set variables for API call api_url = f"{cosmostat_server_api()}update_client"
this_uuid = cosmostat_client.uuid payload = get_client_payload(get_client_redis_data(human_readable = False), "redis_data")
this_short_id = cosmostat_client.short_id log_data(log_output = "client_update - redis data from local client:", log_level = "noisy_test")
this_timestamp = time.time() log_data(log_output = payload, log_level = "noisy_test")
api_url = f"{cosmostat_server_api()}{api_endpoint}"
# generate payload
payload = {
"uuid": this_uuid,
"short_id": this_short_id,
"data_timestamp": this_timestamp, # Unix epoch float
"redis_data": get_client_redis_data(human_readable = False),
}
# execute API call # execute API call
result = client_submission_handler() result = client_submission_handler(api_url, payload)
if ( client_initialize()
isinstance(result, dict)
and result.get("message", "").lower() == "client not found"
):
# if client not found, create client
if api_endpoint == "update_client":
client_initialize()
raise RuntimeError("Client not found - initializing")
return result return result
# Cosmostat Client Initializer # Cosmostat Client Initializer
def client_initialize(): def client_initialize():
# set variables for API call
this_uuid = cosmostat_client.uuid
this_short_id = cosmostat_client.short_id
this_timestamp = time.time()
api_url = f"{cosmostat_server_api()}create_client" api_url = f"{cosmostat_server_api()}create_client"
# generate payload # generate payload
payload = { payload = get_client_payload(get_php_summary(), "client_properties")
"uuid": this_uuid,
"short_id": this_short_id,
"data_timestamp": this_timestamp, # Unix epoch float
"client_properties": get_php_summary(),
}
# execute API call # execute API call
result = client_submission_handler() result = client_submission_handler(api_url, payload)
return result return result
# Cosmostat Client API Reporting Handler # Cosmostat Client API Reporting Handler
def client_submission_handler(): def client_submission_handler(api_url: str, payload: dict):
result = None result = None
try: try:
# `json=` automatically sets Content-Type to application/json # `json=` automatically sets Content-Type to application/json
response: Response = requests.post(api_url, json=payload, timeout=timeout) response: Response = requests.post(api_url, json=payload, timeout=4)
response.raise_for_status() # raise HTTPError for 4xx/5xx response.raise_for_status() # raise HTTPError for 4xx/5xx
except RequestException as exc: except RequestException as exc:
# Wrap the low-level exception in a more descriptive one # Wrap the low-level exception in a more descriptive one
raise RuntimeError( log_data(log_output = f"Failed to POST to {api_url!r}: {exc}", log_level = "log_output")
f"Failed to POST to {url!r}: {exc}"
) from exc
# process reply from API # process reply from API
try: try:
result = response.json() result = response.json()
except ValueError as exc: except ValueError as exc:
raise RuntimeError( log_data(log_output = "Server responded with non-JSON payload: {response.text!r}", log_level = "log_output")
f"Server responded with non-JSON payload: {response.text!r}"
) from exc
return result return result
def get_client_payload(system_dictionary: dict, dictionary_name: str):
this_uuid = cosmostat_client.uuid
this_short_id = cosmostat_client.short_id
this_hostname = cosmostat_client.name
payload = {
"uuid": this_uuid,
"short_id": this_short_id,
"hostname": this_hostname,
dictionary_name: system_dictionary
}
return payload
####################################################################### #######################################################################
####################################################################### #######################################################################
@ -353,9 +394,9 @@ if __name__ == '__main__':
return new_client return new_client
# instantiate and return the Cosmoserver System object # instantiate and return the Cosmoserver System object
def new_cosmos_server(): def new_cosmostat_server():
new_server = Cosmoserver(cosmostat_client.uuid) new_server = CosmostatServer(cosmostat_client.uuid)
log_data(log_output = f"New Cosmostat object name: {new_server.name}", log_level = "log_output") log_data(log_output = f"New Cosmostat serverobject name: {new_server.name}", log_level = "log_output")
return new_server return new_server
# Background Loop Function # Background Loop Function
@ -367,14 +408,25 @@ if __name__ == '__main__':
if app_settings["push_redis"]: if app_settings["push_redis"]:
update_redis_server() update_redis_server()
if app_settings["cosmostat_server_reporter"]: if run_cosmostat_reporter():
if int(time.time()) % 5 == 0 and not cosmostat_client.check_system_timer():
cosmostat_client.update_system_state()
client_update() client_update()
if run_cosmostat_server():
this_client = get_client_payload(get_client_redis_data(human_readable = False), "redis_data")
run_update_client(this_client)
time.sleep(0.5)
###################################### ######################################
# instantiate client # instantiate client
###################################### ######################################
cosmostat_client = new_cosmos_client() cosmostat_client = new_cosmos_client()
if app_settings["cosmostat_server_reporter"]: if app_settings["cosmostat_server_reporter"] and not app_settings["cosmostat_server"]:
client_initialize() client_initialize()
###################################### ######################################
@ -383,7 +435,9 @@ if __name__ == '__main__':
cosmostat_server = None cosmostat_server = None
if run_cosmostat_server(): if run_cosmostat_server():
cosmostat_server = new_cosmos_server() cosmostat_server = new_cosmostat_server()
this_client = get_client_payload(get_php_summary(), "client_properties")
timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
###################################### ######################################
# send initial stats update to redis # send initial stats update to redis

View File

@ -118,7 +118,7 @@
}, },
"metrics": { "metrics": {
"MB Used": "free -m | grep Mem | awk '{print $3}'", "MB Used": "free -m | grep Mem | awk '{print $3}'",
"MB Free": "free -m | grep Mem | awk '{print $4}'" "MB Available": "free -m | grep Mem | awk '{print $7}'"
}, },
"virt_ignore": [ "virt_ignore": [
"RAM Type", "RAM Type",
@ -136,13 +136,13 @@
}, },
{ {
"name": "LAN", "name": "LAN",
"description": "{Device ID} - {Device Name} - {MAC Address}", "description": "{Device Name} - {Device ID} - {MAC Address}",
"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}}'",
"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 | 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- | grep {this_device} | awk '{{print $4}}'",
@ -185,7 +185,7 @@
"properties": { "properties": {
"Device Name": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{{print $1}}' | grep {this_device}", "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 Path": "lsblk -d -o NAME,SIZE | grep -v -e 0B -e NAME | awk '{{print \"/dev/\"$1}}' | grep {this_device}",
"Drive Type": "lsblk -d -o NAME,TRAN | grep {this_device} | awk '{{print $2}}'", "Drive Type": "lsblk -d -o NAME,TRAN | grep {this_device} | awk '{{print ($2 != \"\" ? $2 : \"missing\")}}'",
"Total Capacity": "lsblk -d -o NAME,SIZE | grep {this_device} | awk '{{print $2}}'", "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"
}, },

161
files/server/server.php Normal file
View File

@ -0,0 +1,161 @@
<?php
/* -------------------------------------------------------------
* Cosmostat Dashboard - updated to support hostspecific view
* -------------------------------------------------------------
*/
function h(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
/* --------------------- 1. Load API data --------------------- */
$raw_api_settings = file('/opt/api_settings/cosmostat_settings.yaml',
FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$api_settings = [];
foreach ($raw_api_settings as $line) {
if (isset($line[0]) && $line[0] === '#') { continue; }
$pos = strpos($line, ':');
if ($pos === false) { continue; }
$key = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
if ($value === '') { $value = null; }
$api_settings[$key] = $value;
}
$api_bind_ip = trim($api_settings['api_bind_ip'] ?? '', "\"'");
$customApiPort = trim($api_settings['custom_api_port'] ?? '', "\"'");
$apiUrl = "http://$api_bind_ip:$customApiPort/client_details";
echo "<!-- ".$apiUrl . " -->";
$context = stream_context_create([
'http' => [
'timeout' => 5,
'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n"
]
]);
$json = @file_get_contents($apiUrl, false, $context);
if ($json === false) {
die('<p style="color:red;">Could not fetch data from the API.</p>');
}
$clients = json_decode($json, true);
if ($clients === null || !is_array($clients)) {
die('<p style="color:red;">Malformed JSON returned from the API.</p>');
}
/* --------------------- 2. Resolve selected host ------------- */
$selectedHost = $_GET['host'] ?? '';
$selectedIdx = null;
foreach ($clients as $idx => $client) {
if (strtolower($client['hostname']) === strtolower($selectedHost)) {
$selectedIdx = $idx;
break;
}
}
if ($selectedIdx === null) {
// no match - default to the first host (or none)
$selectedIdx = 0;
$selectedHost = $clients[$selectedIdx]['hostname'] ?? '';
}
$client = $clients[$selectedIdx] ?? null;
$properties = $client['client_properties'][0] ?? [];
$systemProperties = $properties['system_properties'] ?? [];
$systemComponents = $properties['system_components'] ?? [];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cosmostat - <?= h($selectedHost) ?></title>
<link rel="stylesheet" href="src/styles.css">
</head>
<body>
<div class="wrapper">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar">
<h3>Endpoints</h3>
<!-- The list will be populated by the JavaScript below -->
<ol id="endpointList"></ol>
</div>
</nav>
<!-- Main content -->
<div class="main">
<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 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 class="card">
<div id="host_components" class="column">
<?php if (!empty($systemProperties)): ?>
<h2>System Properties</h2>
<div class="system">
<table>
<tr>
<td>
<ul class="system-list">
<?php foreach ($systemProperties as $prop): ?>
<li><?= h($prop['Property']) ?></li>
<?php endforeach; ?>
</ul>
</td>
<td>
<h2>Live System Metrics</h2>
<div id="host_metrics" class="column">Connecting...</div>
</td>
</tr>
</table>
</div>
<?php endif; ?>
<?php if (!empty($systemComponents)): ?>
<h2>Components</h2>
<div class="components">
<?php foreach ($systemComponents as $comp): ?>
<div class="component">
<h3><?= h($comp['component_name']) ?></h3>
<ul class="info-list">
<?php foreach ($comp['info_strings'] as $info): ?>
<li><?= h($info) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</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>
<script>
document.getElementById('helpToggle').addEventListener('click', function () {
const help = document.getElementById('helpText');
help.style.display = help.style.display === 'none' || help.style.display === '' ? 'block' : 'none';
});
</script>
</body>
</html>

134
files/server/sidebar.js Normal file
View File

@ -0,0 +1,134 @@
// 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

@ -0,0 +1,179 @@
/* ------------------------------------------------------------
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}`);
});
socket.on('reconnect', attempt => {
safeSetText('client_summary', `Re-connected (attempt ${attempt})`);
});
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;
}
if (!Array.isArray(payload) || !payload.length) {
safeSetText('client_summary', 'No data available');
return;
}
/* ---------------------------------------------
2a. Determine the hostname to display
--------------------------------------------- */
const urlParams = new URLSearchParams(window.location.search);
const selectedHost = urlParams.get('host');
/* ---------------------------------------------
2b. Find the host object in the payload
--------------------------------------------- */
const hostObj =
payload.find(item => item.hostname === selectedHost) || payload[0];
/* ---------------------------------------------
2c. Extract the Redis data for that host
--------------------------------------------- */
const hostData = hostObj && Array.isArray(hostObj.redis_data)
? hostObj.redis_data
: [];
/* ---------------------------------------------
2d. Pass the host-specific data to the generic renderer
--------------------------------------------- */
renderGenericTable('host_metrics', hostData, 'No Stats available');
}
/* ------------------------------------------------------------
3. Table rendering - unchanged from original
------------------------------------------------------------ */
function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg;
return;
}
// Merge rows by source name
const mergedData = mergeRowsByName(data);
// Order the merged rows - priority first
const orderedData = orderRows(mergedData);
// Build the table from the ordered data
const table = buildTable(orderedData);
table.id = 'host_metrics_table';
container.innerHTML = '';
container.appendChild(table);
}
/* ------------------------------------------------------------
4. Merge rows by source name
------------------------------------------------------------ */
function mergeRowsByName(data) {
const groups = {}; // { source: { Metric: [], Data: [] } }
data.forEach(row => {
const source = row.Source;
if (!source) return;
if (!groups[source]) {
groups[source] = { Metric: [], Data: [] };
}
if ('Metric' in row && 'Data' in row) {
groups[source].Metric.push(row.Metric);
groups[source].Data.push(row.Data);
}
});
const merged = [];
Object.entries(groups).forEach(([source, grp]) => {
merged.push({
Source: source,
Metric: grp.Metric,
Data: grp.Data,
});
});
return merged;
}
/* ------------------------------------------------------------
5. Order rows - put “System”, “CPU”, “RAM” first
------------------------------------------------------------ */
function orderRows(rows) {
const priority = ['System', 'CPU', 'RAM'];
const priorityMap = {};
priority.forEach((src, idx) => {
priorityMap[src] = idx;
});
return [...rows].sort((a, b) => {
const aIdx = priorityMap.hasOwnProperty(a.Source) ? priorityMap[a.Source] : Infinity;
const bIdx = priorityMap.hasOwnProperty(b.Source) ? priorityMap[b.Source] : Infinity;
return aIdx - bIdx;
});
}
/* ------------------------------------------------------------
6. Build an HTML table from an array of objects
------------------------------------------------------------ */
function buildTable(data) {
const cols = ['Source', 'Metric', 'Data'];
const table = document.createElement('table');
// Header
const thead = table.createTHead();
const headerRow = thead.insertRow();
cols.forEach(col => {
const th = document.createElement('th');
th.textContent = col;
headerRow.appendChild(th);
});
// Body
const tbody = table.createTBody();
data.forEach(item => {
const tr = tbody.insertRow();
cols.forEach(col => {
const td = tr.insertCell();
const val = item[col];
if (Array.isArray(val)) {
val.forEach((v, idx) => {
td.id = 'host_metrics_column';
const span = document.createElement('span');
span.textContent = v;
td.appendChild(span);
if (idx < val.length - 1) td.appendChild(document.createElement('br'));
});
} else {
td.textContent = val !== undefined ? val : '';
}
});
});
return table;
}

View File

@ -0,0 +1,218 @@
/* ------------------------------------------------------------------ */
/* 1. SocketIO connection & helpers unchanged */
/* ------------------------------------------------------------------ */
const socket = io();
socket.on('connect_error', err => {
safeSetText('status', `Could not connect to server - ${err.message}`);
});
socket.on('reconnect', attempt => {
safeSetText('status', `Re-connected (attempt ${attempt})`);
});
function safeSetText(id, txt) {
const el = document.getElementById(id);
if (el) el.textContent = txt;
}
/* ------------------------------------------------------------------ */
/* 2. Global state */
/* ------------------------------------------------------------------ */
let selectedHost = null; // hostname that is currently displayed
const hostDataMap = {}; // hostname → client object (from CLIENT_LIST)
/* ------------------------------------------------------------------ */
/* 3. Build the host list once the page is ready */
/* ------------------------------------------------------------------ */
function initHostList() {
const listEl = document.getElementById('host-list');
listEl.innerHTML = ''; // clear any stray markup
CLIENT_LIST.forEach(host => {
hostDataMap[host.hostname] = host; // cache for quick lookup
const item = document.createElement('div');
item.textContent = host.hostname;
item.className = 'host-item';
item.dataset.hostname = host.hostname;
item.addEventListener('click', () => selectHost(host.hostname));
listEl.appendChild(item);
});
// autoselect the first host (you could also stay on "Loading…" until the user clicks)
if (CLIENT_LIST.length) selectHost(CLIENT_LIST[0].hostname);
}
/* ------------------------------------------------------------------ */
/* 4. Handle host click update UI and request live metrics */
/* ------------------------------------------------------------------ */
function selectHost(hostname) {
if (selectedHost === hostname) return; // already selected
selectedHost = hostname;
// Update active styling in the list
document.querySelectorAll('.host-item').forEach(el => {
el.classList.toggle('active', el.dataset.hostname === hostname);
});
// Render the static part of the page for this host
renderHostContent(hostDataMap[hostname]);
// Now request the live metrics for this host
// The server sends an array of all hosts well filter below
// (If you have a dedicated endpoint you could request only the chosen host here)
}
/* ------------------------------------------------------------------ */
/* 5. Render the static content (system properties + components) */
/* ------------------------------------------------------------------ */
function renderHostContent(host) {
const main = document.getElementById('main-content');
main.innerHTML = ''; // clear
// 5a. System Properties
if (host.client_properties?.[0]?.system_properties?.length) {
const propSection = document.createElement('div');
propSection.innerHTML = '<h2>System Properties</h2>';
const ul = document.createElement('ul');
ul.className = 'system-list';
host.client_properties[0].system_properties.forEach(p => {
const li = document.createElement('li');
li.textContent = p.Property;
ul.appendChild(li);
});
propSection.appendChild(ul);
main.appendChild(propSection);
}
// 5b. Components
if (host.client_properties?.[0]?.system_components?.length) {
const compSection = document.createElement('div');
compSection.innerHTML = '<h2>Components</h2>';
const compGrid = document.createElement('div');
compGrid.className = 'components';
host.client_properties[0].system_components.forEach(c => {
const compDiv = document.createElement('div');
compDiv.className = 'component';
compDiv.innerHTML = `<h3>${c.component_name}</h3>`;
const ul = document.createElement('ul');
ul.className = 'info-list';
c.info_strings.forEach(str => {
const li = document.createElement('li');
li.textContent = str;
ul.appendChild(li);
});
compDiv.appendChild(ul);
compGrid.appendChild(compDiv);
});
compSection.appendChild(compGrid);
main.appendChild(compSection);
}
// 5c. Placeholder for live metrics will be filled by Socket.IO
const metricsDiv = document.createElement('div');
metricsDiv.id = 'client_summary';
metricsDiv.textContent = 'Connecting…';
main.appendChild(metricsDiv);
}
/* ------------------------------------------------------------------ */
/* 6. Render metrics called when a client_summary event arrives */
/* ------------------------------------------------------------------ */
socket.on('client_summary', data => {
// `data` is an array of host objects (the same structure as CLIENT_LIST)
// Find the one that matches the currently selected host
const host = data.find(h => h.hostname === selectedHost);
if (!host) return; // no data for this host yet
const metrics = host.redis_data;
renderStatsTable('client_summary', metrics, 'No Stats available');
});
/* 7. Table rendering unchanged except we now target a specific
container (e.g. id = 'client_summary') */
function renderStatsTable(containerId, data, emptyMsg) {
socket.emit('tableRendered');
renderGenericTable(containerId, data, emptyMsg);
}
function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg;
return;
}
const mergedData = mergeRowsByName(data);
const orderedData = orderRows(mergedData);
const table = buildTable(orderedData);
table.id = `${containerId}_table`;
container.innerHTML = '';
container.appendChild(table);
}
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;
}
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[a.Source] ?? Infinity;
const bIdx = priorityMap[b.Source] ?? Infinity;
return aIdx - bIdx;
});
}
function buildTable(data) {
const cols = ['Source', 'Metric', 'Data'];
const table = document.createElement('table');
const thead = table.createTHead();
const headerRow = thead.insertRow();
cols.forEach(col => {
const th = document.createElement('th');
th.textContent = col;
headerRow.appendChild(th);
});
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) => {
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 ?? '';
}
});
});
return table;
}
/* ------------------------------------------------------------------ */
/* 8. Kick things off when the DOM is ready */
/* ------------------------------------------------------------------ */
document.addEventListener('DOMContentLoaded', initHostList);

View File

@ -0,0 +1,107 @@
<?php
/* ------------------------------------------------------------------ */
/* Load the API that returns the list of all clients */
/* ------------------------------------------------------------------ */
# load API settings, this requires a simple yaml file
$raw_api_settings = file('/opt/api_settings/cosmostat_settings.yaml', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$api_settings = [];
foreach ($raw_api_settings as $line) {
if ($line[0] === '#') {
continue;
}
$pos = strpos($line, ':');
if ($pos === false) {
continue;
}
$key = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
if ($value === '') {
$value = null;
}
$api_settings[$key] = $value;
}
$api_bind_ip = trim($api_settings['api_bind_ip'], "\"'") ?? null;
$customApiPort = trim($api_settings['custom_api_port'], "\"'") ?? null;
$apiUrl = 'http://'.$api_bind_ip.':'.$customApiPort.'/client_details';
$apiCtx = stream_context_create([
'http' => [
'timeout' => 5,
'header' => "User-Agent: PHP/".PHP_VERSION."\r\n"
]
]);
$apiJson = @file_get_contents($apiUrl, false, $apiCtx);
$clients = json_decode($apiJson, true) ?: [];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cosmostat Server Dashboard</title>
<link rel="stylesheet" href="src/styles.css">
<style>
/* ------------------------------------------------------------------ */
/* Layout tweaks 2 column grid, left column 200px wide */
/* ------------------------------------------------------------------ */
.layout{display:grid;grid-template-columns:200px 1fr;gap:1rem;}
.sidebar{background:#34495e;padding:10px;border-radius:6px;}
.main{background:#34495e;padding:20px;border-radius:6px;}
.host-item{cursor:pointer;color:#bdc3c7;padding:4px 8px;border-radius:4px;}
.host-item:hover{background:#2c3e50;}
.host-item.active{background:#1abc9c;color:#fff;}
/* Preserve existing table styling --------------------------------- */
</style>
</head>
<body>
<div class="card">
<h2>Matt-Cloud Cosmostat Dashboard</h2>
<p>This dashboard shows MattCloud system stats.</p>
<div class="help-link" id="helpToggle">API</div>
</div>
<div id="helpText" class="card">
<strong>Component Desriptor</strong>
<p>To view the component descriptor, you may <br>
<code>curl -s https://<?php echo $_SERVER['SERVER_NAME']; ?>/descriptor</code>
<p>This will return the entire JSON descriptor variable
</div>
<!-- --------------------------------------------------- -->
<!-- Page layout sidebar + main content -->
<!-- --------------------------------------------------- -->
<div class="layout">
<!-- Left side host list -->
<div class="sidebar">
<h3>Hosts</h3>
<div id="host-list"></div>
</div>
<!-- Right side content -->
<div class="main" id="main-content">
<h2>Loading…</h2>
</div>
</div>
<!-- --------------------------------------------------- -->
<!-- The client list is embedded so JS can build UI -->
<!-- --------------------------------------------------- -->
<script>
/* Expose the whole client list to JS this is the data that
the old PHP template used to render one host. */
const CLIENT_LIST = <?php echo json_encode($clients, JSON_UNESCAPED_SLASHES); ?>;
</script>
<!-- Socket.IO client -->
<script src="socket.io/socket.io.js"></script>
<!-- Custom Redis logic (see below) -->
<script src="src/redis-server.js"></script>
<script>
/* Toggle help panel unchanged from the original */
document.getElementById('helpToggle').addEventListener('click', function () {
const help = document.getElementById('helpText');
help.style.display = (help.style.display === 'none' || help.style.display === '') ? 'block' : 'none';
});
</script>
</body>
</html>

View File

@ -0,0 +1,36 @@
/* --------------------------------------------------
1. Expose the API URL (identical to PHPs $apiUrl)
-------------------------------------------------- */
const API_URL = 'http://<?= h($api_bind_ip) ?>:<?= h($customApiPort) ?>/php_summary';
/* --------------------------------------------------
2. Build the endpoint list
-------------------------------------------------- */
document.addEventListener('DOMContentLoaded', () => {
const listEl = document.getElementById('endpointList');
const urlParam = new URLSearchParams(window.location.search);
const current = urlParam.get('host'); // e.g. "MC-CM3588"
fetch(API_URL)
.then(r => r.json())
.then(hosts => {
hosts.forEach(hostObj => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '?host=' + encodeURIComponent(hostObj.hostname);
a.textContent = hostObj.hostname;
if (hostObj.hostname === current) a.classList.add('active');
li.appendChild(a);
listEl.appendChild(li);
});
})
.catch(err => {
console.error('Failed to load endpoint list:', err);
const li = document.createElement('li');
li.textContent = 'Unable to load endpoints';
listEl.appendChild(li);
});
});

View File

@ -0,0 +1,177 @@
/* ------------------------------------------------------------
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}`);
});
socket.on('reconnect', attempt => {
safeSetText('client_summary', `Re-connected (attempt ${attempt})`);
});
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;
}
if (!Array.isArray(payload) || !payload.length) {
safeSetText('client_summary', 'No data available');
return;
}
/* ---------------------------------------------
2a. Determine the hostname to display
--------------------------------------------- */
const urlParams = new URLSearchParams(window.location.search);
const selectedHost = urlParams.get('host');
/* ---------------------------------------------
2b. Find the host object in the payload
--------------------------------------------- */
const hostObj =
payload.find(item => item.hostname === selectedHost) || payload[0];
/* ---------------------------------------------
2c. Extract the Redis data for that host
--------------------------------------------- */
const hostData = hostObj && Array.isArray(hostObj.redis_data)
? hostObj.redis_data
: [];
/* ---------------------------------------------
2d. Pass the host-specific data to the generic renderer
--------------------------------------------- */
renderGenericTable('host_metrics', hostData, 'No Stats available');
}
/* ------------------------------------------------------------
3. Table rendering - unchanged from original
------------------------------------------------------------ */
function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg;
return;
}
// Merge rows by source name
const mergedData = mergeRowsByName(data);
// Order the merged rows - priority first
const orderedData = orderRows(mergedData);
// Build the table from the ordered data
const table = buildTable(orderedData);
table.id = 'host_metrics_table';
container.innerHTML = '';
container.appendChild(table);
}
/* ------------------------------------------------------------
4. Merge rows by source name
------------------------------------------------------------ */
function mergeRowsByName(data) {
const groups = {}; // { source: { Metric: [], Data: [] } }
data.forEach(row => {
const source = row.Source;
if (!source) return;
if (!groups[source]) {
groups[source] = { Metric: [], Data: [] };
}
if ('Metric' in row && 'Data' in row) {
groups[source].Metric.push(row.Metric);
groups[source].Data.push(row.Data);
}
});
const merged = [];
Object.entries(groups).forEach(([source, grp]) => {
merged.push({
Source: source,
Metric: grp.Metric,
Data: grp.Data,
});
});
return merged;
}
/* ------------------------------------------------------------
5. Order rows - put “System”, “CPU”, “RAM” first
------------------------------------------------------------ */
function orderRows(rows) {
const priority = ['System', 'CPU', 'RAM'];
const priorityMap = {};
priority.forEach((src, idx) => {
priorityMap[src] = idx;
});
return [...rows].sort((a, b) => {
const aIdx = priorityMap.hasOwnProperty(a.Source) ? priorityMap[a.Source] : Infinity;
const bIdx = priorityMap.hasOwnProperty(b.Source) ? priorityMap[b.Source] : Infinity;
return aIdx - bIdx;
});
}
/* ------------------------------------------------------------
6. Build an HTML table from an array of objects
------------------------------------------------------------ */
function buildTable(data) {
const cols = ['Source', 'Metric', 'Data'];
const table = document.createElement('table');
// Header
const thead = table.createTHead();
const headerRow = thead.insertRow();
cols.forEach(col => {
const th = document.createElement('th');
th.textContent = col;
headerRow.appendChild(th);
});
// Body
const tbody = table.createTBody();
data.forEach(item => {
const tr = tbody.insertRow();
cols.forEach(col => {
const td = tr.insertCell();
const val = item[col];
if (Array.isArray(val)) {
val.forEach((v, idx) => {
td.id = 'host_metrics_column';
const span = document.createElement('span');
span.textContent = v;
td.appendChild(span);
if (idx < val.length - 1) td.appendChild(document.createElement('br'));
});
} else {
td.textContent = val !== undefined ? val : '';
}
});
});
return table;
}

View File

@ -56,10 +56,11 @@
} }
$api_settings[$key] = $value; $api_settings[$key] = $value;
} }
$dockerGateway = trim($api_settings['docker_gateway'], "\"'") ?? null; $api_bind_ip = trim($api_settings['api_bind_ip'], "\"'") ?? null;
$customApiPort = trim($api_settings['custom_api_port'], "\"'") ?? null; $customApiPort = trim($api_settings['custom_api_port'], "\"'") ?? null;
# load API data # load API data
$apiUrl = 'http://'.$dockerGateway.':'.$customApiPort.'/php_summary'; $apiUrl = 'http://'.$api_bind_ip.':'.$customApiPort.'/php_summary';
echo "<!-- apiUrl - ".$apiUrl." -->";
$context = stream_context_create([ $context = stream_context_create([
'http' => [ 'http' => [
'timeout' => 5, // seconds 'timeout' => 5, // seconds

View File

@ -1,63 +1,123 @@
/* styles.css */ /* -------------------------------------------------
1. Global settings & color palette
------------------------------------------------- */
:root {
/* Dark theme body & card backgrounds */
--bg-body: #2c3e50; /* main page background */
--bg-card: #34495e; /* card / panel background */
--bg-sidebar: #3d566e; /* sidebar background (slightly lighter) */
/* Accent / link colour */
--clr-accent: #3498db; /* blue accent for links */
/* Text colour */
--clr-text: #ecf0f1; /* light whiteish text */
/* Borders / accents */
--clr-border: #1f2b38; /* dark border colour */
}
* { box-sizing: border-box; }
/* Body */
body { body {
font-family: Arial, sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #2c3e50; /* Dark background color */ background: var(--bg-body);
color: #bdc3c7; /* Dimmer text color */ color: var(--clr-text);
font-family: Arial, Helvetica, sans-serif;
} }
table, th, td { /* Links */
border: 2px solid #182939; a { color: var(--clr-accent); text-decoration: none; }
border-collapse: collapse; a:hover { text-decoration: underline; }
}
th, td {
padding: 10px;
}
/* -------------------------------------------------
2. Layout wrapper, sidebar, main
------------------------------------------------- */
.wrapper { display: flex; min-height: 100vh; }
.sidebar {
width: 200px;
background: var(--bg-sidebar);
padding: 1rem;
}
.sidebar h3 { margin: 0 0 .4rem 0; font-size: 1.1rem; }
.sidebar ul { list-style: none; padding: 0; margin: 0; }
.sidebar ol { list-style: none; padding: 0; margin: 0; }
.sidebar li { margin-bottom: .4rem; }
.sidebar a { color: var(--clr-accent); }
.sidebar a.active { font-weight: bold; }
.main { flex: 1; padding: 1rem; }
/* -------------------------------------------------
3. Card component
------------------------------------------------- */
.card { .card {
max-width: 950px; max-width: 950px;
margin: 0 auto; margin: 20px auto 1rem auto;
padding: 20px; padding: 20px;
background-color: #34495e; /* Darker background for container */ background: var(--bg-card);
border: 1px solid var(--clr-border);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); /* Slightly darker shadow */ box-shadow: 0 2px 4px rgba(0,0,0,.3);
margin-top: 20px;
} }
h1, h2, h3, h4 { /* -------------------------------------------------
color: #bdc3c7; /* Dimmer text color */ 4. Tables
------------------------------------------------- */
table, th, td {
border: 2px solid var(--clr-border);
border-collapse: collapse;
}
th, td { padding: 10px; }
/* Alternate row colour for metrics table */
#host_metrics_table tbody tr td:nth-of-type(even) {
background: #3e5c78; /* slight contrast */
} }
ul { /* -------------------------------------------------
list-style-type: none; 5. Lists & headings
padding: 0; ------------------------------------------------- */
} h1, h2, h3, h4 { color: var(--clr-text); margin: 0 0 .4rem 0; }
ul { list-style: none; padding: 0; }
ol { list-style: none; padding: 0; }
li { margin-bottom: 10px; color: var(--clr-text); }
li { /* System & component lists */
margin-bottom: 10px; .system-list, .info-list {
color: #bdc3c7; /* Dimmer text color */ list-style: none; padding: 0; margin: 0;
} }
.system-list li, .info-list li { margin-bottom: 5px; color: var(--clr-text); }
#host_metrics_column td { /* -------------------------------------------------
list-style: none; /* removes the numeric markers */ 6. Components grid
padding-left: 0; /* remove the default left indent */ ------------------------------------------------- */
margin-left: 0; /* remove the default left margin */ .components {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
} }
.component {
#host_metrics_table tbody tr td :nth-of-type(even) { padding: 10px;
background-color: #3e5c78; border: 1px solid var(--clr-border);
border-radius: 4px;
} }
.component h3 { margin: 0 0 5px; }
.help-link{ /* -------------------------------------------------
cursor:pointer; 7. Help toggle / modal
user-select:none; ------------------------------------------------- */
color: #2c3e50; .help-link {
cursor: pointer;
user-select: none;
color: var(--clr-accent);
text-align: right; text-align: right;
} }
.help-link:hover{ text-decoration:underline; } .help-link:hover { text-decoration: underline; }
#helpText { display: none; }
#helpText{ /* -------------------------------------------------
display:none; /* hidden by default */ 8. Misc helpers
} ------------------------------------------------- */
/* Hide numeric markers in metric columns (if any) */
#host_metrics_column td { list-style: none; padding-left: 0; margin-left: 0; }

View File

@ -25,8 +25,9 @@ try {
} }
const API_PORT = config.custom_api_port || 5000; // fallback to 5000 const API_PORT = config.custom_api_port || 5000; // fallback to 5000
const API_HOST = config.docker_gateway || '192.168.37.1'; // fallback IP const API_HOST = config.api_bind_ip || '192.168.37.1'; // fallback IP
const API_BASE = `http://${API_HOST}:${API_PORT}`; const API_BASE = `http://${API_HOST}:${API_PORT}`;
console.log('API URL:', API_BASE);
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// ---------- 2. Socket.io ------------------------------------------------ // ---------- 2. Socket.io ------------------------------------------------
@ -87,6 +88,37 @@ redisClient.on('error', err => console.error('Redis error', err));
} }
); );
// Subscribe to the channel that sends host stats
await sub.subscribe(
['client_summary'],
(message, channel) => {
let payload;
try {
payload = JSON.parse(message); // message is a JSON string
} catch (e) {
console.error(`Failed to parse ${channel}`, e);
return;
}
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;
}
io.emit(channel, payload);
}
);
sub.on('error', err => console.error('Subscriber error', err)); sub.on('error', err => console.error('Subscriber error', err));
})(); })();

View File

@ -1,4 +1,8 @@
--- ---
- name: Cosmostat - API - Set api_bind_ip
when: cosmostat_server | bool
set_fact:
api_bind_ip: "{{ cosmostat_server_ip }}"
- name: Cosmostat - API - Stop Service - name: Cosmostat - API - Stop Service
become: true become: true
@ -32,7 +36,7 @@
service_exe: "{{ api_service_exe }}" service_exe: "{{ api_service_exe }}"
service_group: "{{ service_user }}" service_group: "{{ service_user }}"
extra_options: "" extra_options: ""
extra_service_options: "" extra_service_options: "RestartSec=5"
template: template:
src: "service_template.service" src: "service_template.service"
dest: "{{ user_service_folder }}/{{ api_service_name }}.service" dest: "{{ user_service_folder }}/{{ api_service_name }}.service"

View File

@ -15,7 +15,9 @@
register: dpkg_output register: dpkg_output
- name: Cosmostat - Init - Install Prereq Packages - name: Cosmostat - Init - Install Prereq Packages
when: cosmostat_packages_item not in dpkg_output.stdout_lines when:
- cosmostat_packages_item not in dpkg_output.stdout_lines
- cosmostat_packages_item | length > 0
apt: apt:
name: name:
- "{{ cosmostat_packages_item }}" - "{{ cosmostat_packages_item }}"

View File

@ -1,8 +1,33 @@
--- ---
# this will be ran to install the full cosmostat server dashboard # this will be ran to install the server dashboard at root
- 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"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Server Dashboard - copy system_metrics.js
copy:
src: server/system_metrics.js
dest: "{{ service_control_web_folder }}/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"
state: absent
... ...

View File

@ -18,8 +18,8 @@ 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
docker_subnet: "{{ docker_subnet }}" api_bind_ip: {{ api_bind_ip }}
docker_gateway: "{{ docker_gateway }}" docker_gateway: {{ docker_gateway }}
# 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 }}