new classes based on json descriptor

This commit is contained in:
2026-03-14 20:55:30 -07:00
parent 298d7432a7
commit 0173c16731
15 changed files with 576 additions and 577 deletions

View File

@ -56,4 +56,5 @@ secure_api: false
push_redis: true push_redis: true
run_background : true run_background : true
log_output: true log_output: true
update_frequency: "1"
... ...

View File

@ -1,222 +1,254 @@
# this class file is for the cosmostat service # this class file is for the cosmostat service
import subprocess import subprocess
from LinkedList import * import json
from typing import Dict, Any, List
# Global Class Vars
global_max_length = 500 global_max_length = 500
debug_output = False
# import the component descriptor
# this outlines how the component class works
# each type of component has a "type"
try:
with open("component_descriptors.json", encoding="utf-8") as f:
component_class_tree: List[Dict] = json.load(f)
except FileNotFoundError as exc:
raise RuntimeError("Descriptor file not found") from exc
component_types = [{"name": entry["name"], "multi_check": entry["multi_check"] == "True"} for entry in component_class_tree]
class Component: class Component:
##########################################################################################
# Base class for all system components. All instantiated objects need a child class
# Class data:
### name - name of the type of component, declared in the parent class
### status
### model_string - string with device info, declared in parent class
### metric_name - name of the value being measured
### current_value
### historical_data - This will be a linked list used to generate a json when calling get_historical_data
### for this to work, the function using these classes needs to update the values periodically
#### historical_data = [
#### {
#### "timestamp": timestamp, # seconds since epoch
#### "value": value
#### },
#### {
#### "timestamp": timestamp,
#### "value": value
#### }
#### ]
def __init__(self, name: str, model_string: str = None): def __init__(self, name: str, comp_type: str ):
# fail instantiation if critical data is missing
if self.model_string is None:
raise TypeError("Error - missing component model_string")
if self.metric_name is None:
raise TypeError("Error - missing component metric_name")
if self.metric_value_command is None:
raise TypeError("Error - missing component metric_value_command")
if self.type is None:
raise TypeError("Error - missing component type")
if self.has_temp is None:
raise TypeError("Error - missing temp data check")
# set up history list
self.history_max_length = global_max_length
self.historical_data = ValueHistory(self.history_max_length)
self.history_start = self.historical_data.get_first_timestamp()
self.update_value()
if self.current_value is None:
raise TypeError("Error - failed to read value")
# if temp data exists, handle it
if self.has_temp:
self.temp_history_data = ValueHistory(self.history_max_length)
self.temp_history_start = self.temp_history_data.get_first_timestamp()
self.current_temp = self.temp_history_data.get_current_value()
else:
self.temp_history_data = None
# instantiate other shared class variables
self.name = name self.name = name
self.current_value = self.historical_data.get_current_value() self.type = comp_type
if self.has_temp: for component in component_class_tree:
self.current_temp = self.temp_history_data.get_current_value() if component["name"] == self.type:
else: COMPONENT_DESCRIPTORS = component
self.current_temp = None # Load component type descriptor from class tree
self.comment = f"This is a {self.type}, so we are measuring {self.metric_name}, currently at {self.current_value}" # COMPONENT_DESCRIPTORS = {d['type']: d for d in component_class_tree}
descriptor = COMPONENT_DESCRIPTORS
# if nothing failed, the object is ready self._descriptor = descriptor
self.status = "ready" if descriptor is None:
raise ValueError(
f"Component type '{comp_type}' is not defined in the "
f"component descriptor tree."
)
# store static properties
self.multi_check = self.is_multi()
self._properties: Dict[str, str] = {}
for key, command in descriptor.get('properties', {}).items():
self._properties[key] = run_command(command, True)
# build the description string
self._description_template: str | None = descriptor.get("description")
self.description = self._description_template.format(**self._properties)
# initialize metrics
self._metrics: Dict[str, str] = {}
self.update_metrics()
def __str__(self): def __str__(self):
return (f"{self.__class__.__name__}: {self.name} " self_string = (f"Component name: {self.name}, type: {self.type} - "
f"{self.model_string}") f"{self.description}")
return self_string
def __del__(self): def __repr__(self):
print(f"Deleting {self.type} component - {self.model_string}") self_string = (f"Component name: {self.name}, type {self.type} - "
f"{self.description}")
return self_string
def get_info_key(self): def update_metrics(self):
for key, command in self._descriptor.get('metrics', {}).items():
self._metrics[key] = run_command(command, True)
# complex data type return
def get_metrics(self, type = None):
these_metrics = []
if type == None:
for name, value in self._metrics:
these_metrics.append({"name": name, "value": value})
else:
for name, value in self._metrics:
if name == type:
these_metrics.append({"name": name, "value": value})
result = { result = {
"name": self.name, "name": self.name,
"type": self.type, "type": self.type,
"model_string": self.model_string, "metrics": these_metrics
"metric_name": self.metric_name
} }
return result return result
def get_summary_key(self): # complex data type return
def get_properties(self, type = None):
these_properties = []
if type == None:
for name, value in self._properties.items():
these_properties.append({"name": name, "value": value})
else:
for name, value in self._properties.items():
if name == type:
these_properties.append({"name": name, "value": value})
result = { result = {
"name": self.name,
"type": self.type, "type": self.type,
"current_value": self.current_value, "properties": these_properties
"metric_name": self.metric_name,
"model_string": self.model_string
} }
return result return result
def update_value(self): # this gets the value of a specified property, type required
#try: def get_property(self, type):
self.current_value = run_command(self.metric_value_command, True) return self._properties[type]
self.historical_data.add(self.current_value)
#except:
def update_temp_value(self): # returns array of dicts for redis
if has_temp: def get_metrics_keys(self):
#try: result = []
self.current_temp = run_command(self.temp_value_command, True) for name, value in self._metrics.items():
self.temp_history_data.add(self.current_value) this_metric = {
#except: "name": self.name,
else: "type": name,
return None "metric": value
def get_history(self, count: int = global_max_length):
if self.has_temp:
result = {
"value_metric": self.metric_name,
"history_count": count,
"history_data": self.historical_data.get_history(count), # reminder this is a LinkedList get_history
"history_temp_data": self.temp_history_data.get_history(count)
} }
else: result.append(this_metric)
return result
def get_properties_keys(self):
result = []
for name, value in self._properties.items():
this_property = {
"name": self.name,
"property": name,
"value": value
}
result.append(this_property)
return result
# full data return
def get_description(self):
these_properties = []
for name, value in self._metrics.items():
these_properties.append({"name": name, "value": value})
these_metrics = []
for name, value in self._metrics.items():
these_metrics.append({"name": name, "value": value})
result = { result = {
"value_metric": self.metric_name, "name": self.name,
"history_count": count, "type": self.type,
"history_data": self.historical_data.get_history(count) # same reminder here "properties": these_properties,
"metrics": these_metrics
} }
return result return result
############################################################ def is_multi(self):
# Component Class Types for component_type in component_types:
# There needs to be one of these for each monitored thing if self.type == component_type["name"]:
############################################################ return component_type["multi_check"]
# Need to add: return False
### temperatures
### network + VPN
### storage + ZFS
### video cards
### virtual machines
# CPU component class.
class CPU(Component):
def __init__(self, name: str, is_virtual: bool = False):
# Declare component type
self.type = "CPU"
# deal with temp later
self.has_temp = False
# no temp if VM
#self.has_temp = not is_virtual
#self.temp_value_command = "acpi -V | jc --acpi -p | jq '.[] | select(.type==\"Thermal\") | .temperature '"
self.model_string = self.get_model_string()
# Initialize value
self.metric_name = "1m_load"
self.metric_value_command = "cat /proc/loadavg | awk '{print $1}'"
self.current_value = run_command(self.metric_value_command, True)
# Complete instantiation
super().__init__(name, self.model_string)
def get_model_string(self):
# Get CPU Info
model_string_command = "lscpu --json | jq -r '.lscpu[] | select(.field==\"Model name:\") | .data'"
return run_command(model_string_command, True)
# RAM component class.
class RAM(Component):
def __init__(self, name: str):
# Declare component type
self.type = "RAM"
self.has_temp = False
self.model_string = self.get_model_string()
# Initialize Value
self.metric_name = "used_capacity_mb"
self.metric_value_command = "free -m | grep Mem | awk '{print $3}'"
self.current_value = run_command(self.metric_value_command, True)
# Complete instantiation
super().__init__(name, self.model_string)
def get_model_string(self):
# Check total system RAM
bytes_total_command = "sudo lshw -json -c memory | jq -r '.[] | select(.description==\"System Memory\").size' "
bytes_total = float(run_command(bytes_total_command, True))
gb_total = round(bytes_total / 1073741824, 2)
return f"Total Capacity: {gb_total}GB"
############################################################ ############################################################
# System Class # System Class
# A system is build from components
############################################################ ############################################################
class System: class System:
# system variable declarations
# keys to add: model and serial number
static_key_variables = [
{"name": "hostname", "command": "hostname"},
{"name": "virt_string", "command": "systemd-detect-virt"}
]
dynamic_key_variables = [
{"name": "uptime", "command": "uptime -p"},
{"name": "timestamp", "command": "date '+%D %r'"},
]
# add components based on the class tree
# component_types = [{"name": entry["name"], "multi_check": entry["multi_check"] == "True"} for entry in component_class_tree]
# instantiate new system # instantiate new system
def __init__(self, name: str): def __init__(self, name: str):
# the system needs a name # the system needs a name
self.name = name self.name = name
# system is built of other component objects if debug_output:
print(f"System initializing, name {self.name}")
# system contains an array of component objects
self.components = [] self.components = []
# other system properties # initialize system properties and metrics dicts
self.sysvars = {} self._properties: Dict[str, str] = {}
# either i do it here or i do it twice self._metrics: Dict[str, str] = {}
self.sysvars["is_virtual"] = self.check_for_virtual() # load static keys
# Let's build a system for static_key in self.static_key_variables:
self.add_component(CPU("CPU", self.sysvars["is_virtual"])) command = static_key["command"]
self.add_component(RAM("RAM")) result = run_command(command, True)
if debug_output:
print(f"Static key [{static_key["name"]}] - command [{command}] - output [{result}]")
self._properties[static_key["name"]] = result
# initialize live keys
self.update_live_keys()
# initialze components
self.load_components()
# let's build system values # update only system dynamic keys
self.check_values() def update_live_keys(self):
for live_key in self.dynamic_key_variables:
if live_key['command'] is not None:
command = live_key['command']
result = run_command(command, True)
self._metrics[live_key['name']] = result
if debug_output:
print(f"Command {live_key["name"]} - [{command}] Result - [{result}]")
# update all dynamic keys, including components
def update_system_state(self):
self.update_live_keys()
for component in self.components:
component.update_metrics()
# check for components
def load_components(self):
for component in component_types:
component_name = component["name"]
multi_check = component["multi_check"]
if multi_check:
print("placeholder...")
else:
if debug_output:
print(f"Creating component {component["name"]}")
self.add_components(Component(component_name, component_name))
# Add a component to the system # Add a component to the system
def add_component(self, component: Component): def add_components(self, component: Component):
if debug_output:
print(f"Component description: {component.description}")
self.components.append(component) self.components.append(component)
# Get all components, optionally filtered by type # Get all components, optionally filtered by type
def get_components(self, component_type: type = None): def get_components(self, component_type: type = None):
if component_type is None: if component_type is None:
return self.components return self.components
return [c for c in self.components if isinstance(c, component_type)] else:
result = []
for component in self.components:
if component.type == component_type:
result.append(component)
if component.is_multi():
return result
else:
return result[0]
def get_component_strings(self, component_type: type = None):
if component_type is None:
result = []
for component in self.components:
result.append(component.description)
return result
else:
result = []
for component in self.components:
if component.type == component_type:
result.append(component.description)
if component.is_multi():
return result
else:
return result[0]
# get component count # get component count
def get_component_count(self): def get_component_count(self):
@ -225,64 +257,93 @@ class System:
def __str__(self): def __str__(self):
components_str = "\n".join(f" - {c}" for c in self.components) components_str = "\n".join(f" - {c}" for c in self.components)
return f"System: {self.name}\n{components_str}" return f"System hostname: {self.name}\nComponent Count: {self.get_component_count()}\n{components_str}"
# update metrics for all components
def update_values(self):
self.check_values()
for component in self.components:
component.update_value()
def check_for_virtual(self):
check_if_vm_command = "systemd-detect-virt"
check_if_vm = run_command(check_if_vm_command, True)
if check_if_vm != "none":
return True
else:
return False
def check_uptime(self):
check_uptime_command = "uptime -p"
system_uptime = run_command(check_uptime_command, True)
return system_uptime
def check_timestamp(self):
check_timestamp_command = "date '+%D %r'"
system_timestamp = run_command(check_timestamp_command, True)
return system_timestamp
def check_values(self):
self.sysvars["uptime"] = self.check_uptime()
self.sysvars["name"] = self.name
self.sysvars["component_count"] = self.get_component_count()
self.sysvars["timestamp"] = self.check_timestamp()
def get_sysvars(self):
result = {}
for sysvar in self.sysvars:
result[f"{sysvar}"] = self.sysvars[f"{sysvar}"]
return result
# return both static and dynamic data
def get_sysvars_summary_keys(self): def get_sysvars_summary_keys(self):
result = [] result = []
for sysvar in self.sysvars: for name, value in self._properties.items():
system_type_string = f"sysvar['{sysvar}']"
thisvar = { thisvar = {
"type": "System Class Variable", "name": "System Class Property",
"current_value": sysvar, "type": name,
"metric_name": system_type_string, "value": value
"model_string": self.sysvars[sysvar] }
result.append(thisvar)
for name, value in self._metrics.items():
thisvar = {
"name": "System Class Metric",
"type": name,
"value": value
}
result.append(thisvar)
return result
# return list of all live metrics from system and properties
def get_live_metrics(self):
result = []
for component_metric in self.get_component_metrics():
result.append(component_metric)
for system_metric in self.get_system_metrics():
result.append(system_metric)
return result
# return array of all component metrics
def get_component_metrics(self):
result = []
for component in self.components:
for metric in component.get_metrics_keys():
result.append(metric)
return result
# return array of all component metrics
def get_component_properties(self):
result = []
for component in self.components:
for metric in component.get_properties_keys():
result.append(metric)
return result
# return array of all system metrics
def get_system_metrics(self):
result = []
for name, value in self._metrics.items():
thisvar = {
"name": "System",
"type": name,
"metric": value
}
result.append(thisvar)
# add component count
result.append({
"name": "System",
"type": "component_count",
"metric": self.get_component_count()
})
return result
def get_system_properties(self):
result = []
for name, value in self._properties.items():
if name == "virt_string":
thisvar = {
"name": "System",
"property": name,
"value": value == "none"
}
else:
thisvar = {
"name": "System",
"property": name,
"value": value
} }
result.append(thisvar) result.append(thisvar)
return result return result
############################################################ ############################################################
# Helper Functions # Helper Functions
############################################################ ############################################################
# subroutine to run a command, return stdout as array unless zero_only then return [0] # subroutine to run a command, return stdout as array unless zero_only then return [0]
def run_command(cmd, zero_only=False): def run_command(cmd, zero_only=False):
# Run the command and capture the output # Run the command and capture the output

View File

@ -1,98 +0,0 @@
##############################
# linked list classes
# written by the intern
##############################
import time
# single node in a singly linked list
class Node:
__slots__ = ("value", "next", "timestamp")
def __init__(self, value):
self.value = value
self.timestamp = time.time()
self.next = None
# small, bounded history implemented with a singly linked list
class ValueHistory:
def __init__(self, maxlen: int):
if maxlen <= 0:
raise ValueError("maxlen must be a positive integer")
self.maxlen = maxlen
self.head: Node | None = None # oldest entry
self.tail: Node | None = None # newest entry
self.size = 0
# Append a new value to the history, dropping the oldest if needed
def add(self, value):
new_node = Node(value)
# link it after the current tail
if self.tail is None: # empty list
self.head = self.tail = new_node
else:
self.tail.next = new_node
self.tail = new_node
self.size += 1
# 2. enforce the size bound
if self.size > self.maxlen:
# drop the head (oldest item)
assert self.head is not None # for the type checker
self.head = self.head.next
self.size -= 1
# If the list became empty, also reset tail
if self.head is None:
self.tail = None
# Return the history as a Python dict list (oldest → newest)
def get_history(self, count: int | None = None):
if count is None:
count = self.maxlen
out = []
cur = self.head
counter = 0
while cur is not None and counter < count:
counter += 1
out.append(
{
"timestamp": cur.timestamp,
"value": cur.value
}
)
cur = cur.next
return out
# Return oldest timestamp
def get_first_timestamp(self):
if self.head is not None:
return self.head.timestamp
else:
return time.time()
# Return current data
def get_current_value(self):
if self.tail is not None:
return self.tail.value
else:
return 0
# ------------------------------------------------------------------
# Convenience methods
# ------------------------------------------------------------------
def __len__(self):
return self.size
def __iter__(self):
"""Iterate over values from oldest to newest."""
cur = self.head
while cur is not None:
yield cur.value
cur = cur.next
def __repr__(self):
return f"BoundedHistory(maxlen={self.maxlen}, data={self.get()!r})"

View File

@ -16,11 +16,12 @@ scheduler = APScheduler()
# default application setting variables # default application setting variables
app_settings = { app_settings = {
"noisy_test" : False, "noisy_test" : False,
"debug_output" : False, "debug_output" : True,
"log_output" : False, "log_output" : True,
"secure_api" : True, "secure_api" : True,
"push_redis" : False, "push_redis" : False,
"run_background" : True "run_background" : True,
"update_frequency": 1
} }
with open('cosmostat_settings.yaml', 'r') as f: with open('cosmostat_settings.yaml', 'r') as f:
@ -75,93 +76,29 @@ def update_redis_channel(redis_channel, data):
def update_redis_server(): def update_redis_server():
# Update Stats Redis Channel # Update Stats Redis Channel
update_redis_channel("host_stats", get_full_summary()) update_redis_channel("host_metrics", get_redis_data())
# 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())
#######################################################################
### Other Functions
#######################################################################
def get_component_summary():
result = []
for component in cosmostat_system.components:
result.append(component.get_summary_key())
return result
def get_full_summary():
result = []
for component in cosmostat_system.components:
result.append(component.get_summary_key())
for sysvar in cosmostat_system.get_sysvars_summary_keys():
result.append(sysvar)
return result
# This will instantiate a System object
def new_cosmos_system():
new_system = System(f"{jenkins_hostname_settings()}")
if app_settings["log_output"]:
print(f"New system object name: {new_system.name}")
for component in new_system.components:
print(component)
return new_system
def get_component_list(history_count = None):
result = []
for component in cosmostat_system.components:
if history_count is not None:
history = component.get_history(history_count)
else:
history = component.get_history()
result.append(
{
"info": component.get_info_key(),
"history": history
}
)
return result
def get_info():
device_summary = []
for component in cosmostat_system.components:
device_summary.append(
{
"info": component.get_info_key(),
}
)
result = {
"system_info":
{
"user": jenkins_user_settings(),
"hostname": jenkins_hostname_settings(),
"timestamp": jenkins_inventory_generation_timestamp_settings(),
"component_count:": len(cosmostat_system.components),
"object_name": cosmostat_system.name,
"docker_gateway": docker_gateway_settings()
},
"device_summary": device_summary
}
return result
#def get_history_summary():
####################################################################### #######################################################################
### Flask Routes ### Flask Routes
####################################################################### #######################################################################
# full component list # dynamic data
@app.route('/component_list', methods=['GET']) # this will go to the redis server
def component_list(): @app.route('/dynamic_data', methods=['GET'])
count = request.args.get('count', type=int) def dynamic_data():
return jsonify(get_component_list(count)) return jsonify(get_dynamic_data())
# component summary # static data
@app.route('/component_summary', methods=['GET']) @app.route('/static_data', methods=['GET'])
def component_summary(): def static_data():
return jsonify(get_component_summary()) return jsonify(get_static_data())
# redis data
@app.route('/redis_data', methods=['GET'])
def redis_data():
return jsonify(get_redis_data())
# full summary # full summary
@app.route('/full_summary', methods=['GET']) @app.route('/full_summary', methods=['GET'])
@ -180,20 +117,89 @@ def test():
{ {
"component_count:": len(cosmostat_system.components), "component_count:": len(cosmostat_system.components),
"user": jenkins_user_settings(), "user": jenkins_user_settings(),
"hostname": jenkins_hostname_settings() "hostname": jenkins_hostname_settings(),
"cpu_model": cosmostat_system.get_components(component_type="CPU").description
} }
) )
####################################################################### #######################################################################
### Main Subroutine ### Flask Helpers
####################################################################### #######################################################################
if __name__ == '__main__': # needs to return array of {name: name, type: type, metrics: metrics}
# for redis table generation, includes system and component metrics
def get_dynamic_data():
return cosmostat_system.get_live_metrics()
# Background Loop Function def get_static_data():
def background_loop(): result = []
for metric in cosmostat_system.get_system_properties():
result.append(metric)
for metric in cosmostat_system.get_component_properties():
result.append(metric)
return result
def get_redis_data():
result = []
for metric in get_dynamic_data():
result.append(metric)
for metric in get_static_data():
result.append(metric)
return result
def get_full_summary():
live_metrics = cosmostat_system.get_live_metrics()
system_components = cosmostat_system.get_component_strings()
system_info = get_info()
result = {
"system_settings":
{
"user": jenkins_user_settings(),
"hostname": jenkins_hostname_settings(),
"timestamp": jenkins_inventory_generation_timestamp_settings(),
"component_count:": len(cosmostat_system.components),
"object_name": cosmostat_system.name,
"docker_gateway": docker_gateway_settings()
},
"live_metrics": live_metrics,
"system_components": system_components,
"system_info": system_info
}
return result
def get_info():
component_strings = []
for component in cosmostat_system.get_components():
component_strings.append({"name": component.name, "description": component.description})
system_strings = []
result = {
"hostname": jenkins_hostname_settings(),
"component_strings": component_strings
}
#for component_string in component_strings:
# for name, description in component_string.items():
# result[name] = description
return result
#######################################################################
### Other Functions
#######################################################################
# instantiate and return the System object
def new_cosmos_system():
new_system = System(f"{jenkins_hostname_settings()}")
if app_settings["log_output"]:
print(f"New system object name: {new_system.name} - {new_system.get_component_count()} components:")
for component in new_system.components:
print(component.description)
return new_system
# Background Loop Function
def background_loop():
# Update all data on the System object # Update all data on the System object
cosmostat_system.update_values() cosmostat_system.update_system_state()
if app_settings["push_redis"]: if app_settings["push_redis"]:
update_redis_server() update_redis_server()
@ -202,6 +208,13 @@ if __name__ == '__main__':
print("Sorry about the mess...") print("Sorry about the mess...")
print(f"Blame {jenkins_user_settings()}") print(f"Blame {jenkins_user_settings()}")
#######################################################################
### Main Subroutine
#######################################################################
if __name__ == '__main__':
# instantiate system # instantiate system
cosmostat_system = new_cosmos_system() cosmostat_system = new_cosmos_system()
@ -217,7 +230,7 @@ if __name__ == '__main__':
scheduler.add_job(id='background_loop', scheduler.add_job(id='background_loop',
func=background_loop, func=background_loop,
trigger='interval', trigger='interval',
seconds=1) seconds=app_settings["update_frequency"])
scheduler.init_app(app) scheduler.init_app(app)
scheduler.start() scheduler.start()

View File

@ -0,0 +1,29 @@
[
{
"name": "CPU",
"description": "{model_name} with {core_count} cores.",
"multi_check": "False",
"properties": {
"core_count": "lscpu --json | jq -r '.lscpu[] | select(.field==\"CPU(s):\") | .data'",
"model_name": "lscpu --json | jq -r '.lscpu[] | select(.field==\"Model name:\") | .data'"
},
"metrics": {
"1m_load": "cat /proc/loadavg | awk '{print $1}'",
"5m_load": "cat /proc/loadavg | awk '{print $2}'",
"15m_load": "cat /proc/loadavg | awk '{print $3}'"
}
},
{
"name": "RAM",
"description": "Total {bytes_total}GB in {module_count} modules.",
"multi_check": "False",
"properties": {
"bytes_total": "sudo lshw -json -c memory | jq -r '.[] | select(.description==\"System Memory\").size' | awk '{printf \"%.2f\\n\", $1/1073741824}'",
"module_count": "sudo lshw -json -c memory | jq -r '.[] | select(.id | contains(\"bank\")) | .id ' | wc -l"
},
"metrics": {
"used_capacity_mb": "free -m | grep Mem | awk '{print $3}'",
"free_capacity_mb": "free -m | grep Mem | awk '{print $4}'"
}
}
]

View File

@ -13,8 +13,8 @@
This dashboard shows the local Matt-Cloud system stats.<p> This dashboard shows the local Matt-Cloud system stats.<p>
</div> </div>
<div class="container"> <div class="container">
<h2>System Stats</h2> <h2>Live System Metrics</h2>
<div id="host_stats" class="column">Connecting…</div> <div id="host_metrics" class="column">Connecting…</div>
</div> </div>
<!-- <!--

View File

@ -1,96 +1,94 @@
/* ------------------------------------------------------------- /* ------------------------------------------------------------
1. SocketIO connection & helper functions (unchanged) 1. Socket-IO connection & helper functions (unchanged)
------------------------------------------------------------- */ ------------------------------------------------------------ */
const socket = io(); const socket = io();
socket.on('host_stats', renderStatsTable); socket.on('host_metrics', renderStatsTable);
socket.on('connect_error', err => { socket.on('connect_error', err => {
safeSetText('host_stats', `Could not connect to server - ${err.message}`); safeSetText('host_metrics', `Could not connect to server - ${err.message}`);
}); });
socket.on('reconnect', attempt => { socket.on('reconnect', attempt => {
safeSetText('host_stats', `Reconnected (attempt ${attempt})`); safeSetText('host_metrics', `Re-connected (attempt ${attempt})`);
}); });
function safeSetText(id, txt) { function safeSetText(id, txt) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = txt; if (el) el.textContent = txt;
} }
/* ------------------------------------------------------------- /* ------------------------------------------------------------
2. Table rendering the table remains a <table> 2. Table rendering - the table remains a <table>
------------------------------------------------------------- */ ------------------------------------------------------------ */
function renderStatsTable(data) { function renderStatsTable(data) {
renderGenericTable('host_stats', data, 'No Stats available'); renderGenericTable('host_metrics', data, 'No Stats available');
} }
function renderGenericTable(containerId, data, emptyMsg) { function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) { if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg; container.textContent = emptyMsg;
return; return;
} }
/* 2 Merge “System Class Variable” rows first */ /* Merge rows by name (new logic) */
const mergedData = mergeSystemClassVariableRows(data); const mergedData = mergeRowsByName(data);
/* 3 Build the table from the merged data */ /* Build the table from the merged data */
const table = buildTable(mergedData); const table = buildTable(mergedData);
table.id = 'host_metrics_table';
container.innerHTML = ''; container.innerHTML = '';
container.appendChild(table); container.appendChild(table);
}
/* ------------------------------------------------------------
3. Merge rows by name (new logic)
------------------------------------------------------------ */
function mergeRowsByName(data) {
const groups = {}; // { name: { types: [], metrics: [], props: [], values: [] } }
data.forEach(row => {
const name = row.name;
if (!name) return; // ignore rows without a name
if (!groups[name]) {
groups[name] = { types: [], metrics: [], props: [], values: [] };
} }
/* ------------------------------------------------------------- // Metric rows - contain type + metric
3. Merge consecutive rows whose type === "System Class Variable" if ('type' in row && 'metric' in row) {
------------------------------------------------------------- */ groups[name].types.push(row.type);
function mergeSystemClassVariableRows(data) { groups[name].metrics.push(row.metric);
const result = []; }
let i = 0; // Property rows - contain property + value
else if ('property' in row && 'value' in row) {
while (i < data.length) { groups[name].props.push(row.property);
const cur = data[i]; groups[name].values.push(row.value);
if (cur.type && cur.type.trim() === 'System Class Variable') {
const group = [];
while (
i < data.length &&
data[i].type &&
data[i].type.trim() === 'System Class Variable'
) {
group.push(data[i]);
i++;
} }
/* Build one merged object keep each column as an array */
const merged = { type: 'System Class Variable' };
const cols = Object.keys(group[0]).filter(k => k !== 'type');
cols.forEach(col => {
const vals = group
.map(row => row[col])
.filter(v => v !== undefined && v !== null);
merged[col] = vals; // ← array, not joined string
}); });
result.push(merged); // Convert each group into a single row object
} else { const merged = [];
/* Normal row just copy it */ Object.entries(groups).forEach(([name, grp]) => {
result.push(cur); merged.push({
i++; name,
} type: grp.types, // array of types
} metric: grp.metrics, // array of metrics
property: grp.props, // array of property names
value: grp.values, // array of property values
});
});
return result; return merged;
} }
/* ------------------------------------------------------------- /* ------------------------------------------------------------
4. Build an HTML table from an array of objects 4. Build an HTML table from an array of objects
------------------------------------------------------------- */ ------------------------------------------------------------ */
function buildTable(data) { function buildTable(data) {
const cols = Object.keys(data[0]); // column order const cols = ['name', 'type', 'metric', 'property', 'value']; // explicit order
const table = document.createElement('table'); const table = document.createElement('table');
/* Header */ // Header
const thead = table.createTHead(); const thead = table.createTHead();
const headerRow = thead.insertRow(); const headerRow = thead.insertRow();
cols.forEach(col => { cols.forEach(col => {
@ -99,28 +97,30 @@
headerRow.appendChild(th); headerRow.appendChild(th);
}); });
/* Body */ // Body
const tbody = table.createTBody(); const tbody = table.createTBody();
data.forEach(item => { data.forEach(item => {
const tr = tbody.insertRow(); const tr = tbody.insertRow();
cols.forEach(col => { cols.forEach(col => {
const td = tr.insertCell(); const td = tr.insertCell();
const val = item[col];
/* If the value is an array → render as <ol> */ const val = item[col];
if (Array.isArray(val)) { if (Array.isArray(val)) {
const ol = document.createElement('ol'); // Create a <span> for each item
val.forEach(v => { val.forEach((v, idx) => {
const li = document.createElement('li'); td.id = 'host_metrics_column';
li.textContent = v; const span = document.createElement('span');
ol.appendChild(li); span.textContent = v;
td.appendChild(span);
// Insert a line break after every item except the last
if (idx < val.length - 1) td.appendChild(document.createElement('br'));
}); });
td.appendChild(ol);
} else { } else {
td.textContent = val; // normal text td.textContent = val !== undefined ? val : '';
} }
}); });
}); });
return table; return table;
} }

View File

@ -109,12 +109,13 @@ li {
background-color: #2c3e50; /* Dark background for meter */ background-color: #2c3e50; /* Dark background for meter */
} }
#host_stats td ol { #host_metrics_column td {
list-style: none; /* removes the numeric markers */ list-style: none; /* removes the numeric markers */
padding-left: 0; /* remove the default left indent */ padding-left: 0; /* remove the default left indent */
margin-left: 0; /* remove the default left margin */ margin-left: 0; /* remove the default left margin */
} }
#host_stats td ol li:nth-child(odd) { background: #34495e; } #host_metrics_table tbody tr td :nth-of-type(even) {
#host_stats td ol li:nth-child(even) { background: #3e5c78; } background-color: #23384e;
}

View File

@ -13,7 +13,7 @@ app.use(express.static('public'));
// ---------- Redis subscriber ---------- // ---------- Redis subscriber ----------
const redisClient = createClient({ const redisClient = createClient({
url: 'redis://172.17.0.1:6379' url: 'redis://192.168.37.1:6379'
}); });
redisClient.on('error', err => console.error('Redis error', err)); redisClient.on('error', err => console.error('Redis error', err));
@ -25,7 +25,7 @@ redisClient.on('error', err => console.error('Redis error', err));
await sub.connect(); await sub.connect();
// Subscribe to the channel that sends host stats // Subscribe to the channel that sends host stats
await sub.subscribe( await sub.subscribe(
['host_stats'], ['host_metrics'],
(message, channel) => { // <-- single handler (message, channel) => { // <-- single handler
let payload; let payload;
try { try {
@ -38,21 +38,6 @@ redisClient.on('error', err => console.error('Redis error', err));
io.emit(channel, payload); io.emit(channel, payload);
} }
); );
// Subscribe to the channel that sends history stats
await sub.subscribe(
['history_stats'],
(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,8 +1,13 @@
--- ---
- name: Cosmostat - API - Stop Service - name: Cosmostat - API - Stop Service
become: true
become_user: "{{ service_user }}"
ignore_errors: yes ignore_errors: yes
shell: "systemctl --user -M {{ service_user }}@ stop {{ api_service_name }}" systemd:
name: "{{ api_service_name }}.service"
state: stopped
scope: user
- name: Cosmostat - API - copy api files - name: Cosmostat - API - copy api files
copy: copy:
@ -35,12 +40,14 @@
group: "{{ service_user }}" group: "{{ service_user }}"
mode: 0644 mode: 0644
- name: Cosmostat - API - Daemon Reload - name: Cosmostat - API - Daemon Reload, Start, Enable
shell: "systemctl --user -M {{ service_user }}@ daemon-reload" become: true
become_user: "{{ service_user }}"
- name: Cosmostat - API - Start Service systemd:
shell: "systemctl --user -M {{ service_user }}@ start {{ api_service_name }}" daemon_reload: yes
name: "{{ api_service_name }}.service"
state: started
enabled: yes
scope: user
... ...

View File

@ -22,7 +22,6 @@
driver: bridge driver: bridge
ipam_config: ipam_config:
- subnet: "{{ docker_subnet }}" - subnet: "{{ docker_subnet }}"
# - gateway: "{{ docker_gateway }}"
# allow service_user to sudo lshw without a password # allow service_user to sudo lshw without a password
- name: Cosmostat - Init - cosmos user sudoers file creation - name: Cosmostat - Init - cosmos user sudoers file creation
@ -31,7 +30,11 @@
content: "{{ cosmostat_sudoers_content }}" content: "{{ cosmostat_sudoers_content }}"
owner: root owner: root
group: root group: root
mode: 0600 mode: "0600"
# allow user services to "linger"
- name: Cosmostat - Init - cosmos user enable linger
shell: "loginctl enable-linger {{ service_user }}"
# create service working folder # create service working folder
- name: Cosmostat - Init - create cosmostat service folder - name: Cosmostat - Init - create cosmostat service folder
@ -102,11 +105,4 @@
labels: labels:
ws_node: "true" ws_node: "true"
- name: Cosmostat - Init - node.js - Prune old containers
community.docker.docker_prune:
containers: true
containers_filters:
label:
ws_node: "true"
... ...

View File

@ -40,17 +40,25 @@
- name: docker container handler - name: docker container handler
block: block:
- name: service_control_website - template docker-compose.yaml - name: Cosmostat - Web - template docker-compose.yaml
template: template:
src: docker-compose.yaml src: docker-compose.yaml
dest: "{{ service_control_web_folder }}/docker-compose.yaml" dest: "{{ service_control_web_folder }}/docker-compose.yaml"
mode: 0644 mode: 0644
- name: "service_control_website - Start containers" - name: Cosmostat - Web - Start containers
shell: "docker-compose -f {{ service_control_web_folder }}/docker-compose.yaml up -d" shell: "docker-compose -f {{ service_control_web_folder }}/docker-compose.yaml up -d"
register: docker_output register: docker_output
- debug: | - debug: |
msg="{{ docker_output.stdout_lines }}" msg="{{ docker_output.stdout_lines }}"
msg="{{ docker_output.stderr_lines }}" msg="{{ docker_output.stderr_lines }}"
- name: Cosmostat - Web - Prune old containers
community.docker.docker_prune:
containers: true
containers_filters:
label:
ws_node: "true"
... ...

View File

@ -28,4 +28,5 @@ debug_output: {{ debug_output }}
push_redis: {{ push_redis }} push_redis: {{ push_redis }}
run_background : {{ run_background }} run_background : {{ run_background }}
log_output: {{ log_output }} log_output: {{ log_output }}
update_frequency: {{ update_frequency }}
... ...

View File

@ -8,7 +8,7 @@ services:
container_name: redis container_name: redis
image: redis:7-alpine image: redis:7-alpine
ports: ports:
- {{ (docker_gateway + ':') if not secure_api else '' }}6379:6379 - {{ docker_gateway }}:6379:6379
networks: networks:
- cosmostat_net - cosmostat_net
restart: always restart: always
@ -23,7 +23,7 @@ services:
- {{ service_control_web_folder }}/html:/usr/src/app/public - {{ service_control_web_folder }}/html:/usr/src/app/public
ports: ports:
# put back to 3000 if the stack is needed # put back to 3000 if the stack is needed
- {{ (docker_gateway + ':') if not secure_api else '' }}80:3000 - {{ (docker_gateway + ':') if secure_api else '' }}80:3000
networks: networks:
- cosmostat_net - cosmostat_net
restart: always restart: always
@ -35,7 +35,7 @@ services:
# container_name: web_dash # container_name: web_dash
# image: php:8.0-apache # image: php:8.0-apache
# ports: # ports:
# - {{ (docker_gateway + ':') if not secure_api else '' }}8080:80 # - {{ (docker_gateway + ':') if secure_api else '' }}8080:80
# volumes: # volumes:
# - ./html:/var/www/html/ # - ./html:/var/www/html/
# networks: # networks:
@ -46,7 +46,7 @@ services:
# container_name: nginx_proxy # container_name: nginx_proxy
# image: nginx:latest # image: nginx:latest
# ports: # ports:
# - "{{ (docker_gateway + ':') if not secure_api else '' }}80:80" # - "{{ (docker_gateway + ':') if secure_api else '' }}80:80"
# volumes: # volumes:
# - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf # - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf
# networks: # networks:
@ -60,10 +60,5 @@ services:
networks: networks:
cosmostat_net: cosmostat_net:
external: true external: true
# driver: bridge
# ipam:
# driver: default
# config:
# -
# subnet: {{ docker_subnet }}