cosmostat active host inventory file api

This commit is contained in:
2026-04-04 17:47:32 -07:00
parent be95ab7593
commit a89703c420
26 changed files with 1243 additions and 261 deletions

View File

@ -80,5 +80,5 @@ cosmostat_server_api: "https://cosmostat.matt-cloud.com/"
local_api_address: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/" local_api_address: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/"
cosmostat_server_reporter: true cosmostat_server_reporter: true
# setting this to true for default install # setting this to true for default install
disable_local_api: true disable_local_dashboard: true
... ...

View File

@ -9,6 +9,7 @@ import json
import time import time
import weakref import weakref
import base64, hashlib import base64, hashlib
import ipaddress
from typing import Dict, Any, List from typing import Dict, Any, List
# Import Cosmos Settings # Import Cosmos Settings
from Cosmos_Settings import * from Cosmos_Settings import *
@ -255,6 +256,19 @@ class Component:
if value not in empty_value and name not in self.virt_ignore: if value not in empty_value and name not in self.virt_ignore:
result.append(this_metric) result.append(this_metric)
return result return result
# simple data value return
def get_metrics_value(self, type = None):
result = []
print(f"Metric type: {type}")
for name, value in self._metrics.items():
print(f"Metric Property: {name}")
if type in name:
result.append(value)
if len(result) == 1:
return result[0]
else:
return result
######################################################## ########################################################
# various data functions # various data functions
@ -285,7 +299,7 @@ class Component:
these_properties.append({"Property": name, "Value": value}) these_properties.append({"Property": name, "Value": value})
else: else:
for name, value in self._properties.items(): for name, value in self._properties.items():
if name == type: if type in name:
these_properties.append({"Property": name, "Value": value}) these_properties.append({"Property": name, "Value": value})
result = { result = {
"Source": self.name, "Source": self.name,
@ -294,6 +308,19 @@ class Component:
} }
return result return result
# simple data value return
def get_property_value(self, type = None):
result = []
print(f"Component type: {type}")
for name, value in self._properties.items():
print(f"Component Property: {name}")
if type in name:
result.append(value)
if len(result) == 1:
return result[0]
else:
return result
# full data return # full data return
def get_description(self): def get_description(self):
these_properties = [] these_properties = []
@ -360,7 +387,7 @@ class System:
self._properties: Dict[str, str] = {} self._properties: Dict[str, str] = {}
self._metrics: Dict[str, str] = {} self._metrics: Dict[str, str] = {}
self._virt_string = run_command('systemd-detect-virt', zero_only = True, req_check = False) self._virt_string = run_command('systemd-detect-virt', zero_only = True, req_check = False)
self.default_gateway = run_command("ip route show | grep def | awk '{print $3}'", zero_only = True, req_check = False)
self._virt_ignore = self.virt_ignore self._virt_ignore = self.virt_ignore
if self._virt_string == "none": if self._virt_string == "none":
self._virt_ignore = [] self._virt_ignore = []
@ -374,7 +401,9 @@ class System:
self.update_live_keys() self.update_live_keys()
# initialze components # initialze components
for component in component_types: for component in component_types:
self.create_component(component) self.create_component(component)
self.primary_ip = self.get_primary_ip()
print(f"Cosmostat System IP: {self.primary_ip}")
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)
@ -444,6 +473,22 @@ class System:
log_data(log_output = f'Creating component {component["name"]}', log_level = "debug_output") log_data(log_output = f'Creating component {component["name"]}', log_level = "debug_output")
new_component = Component(name = component_name, comp_type = component_name, parent_system = self) new_component = Component(name = component_name, comp_type = component_name, parent_system = self)
self.components.append(new_component) self.components.append(new_component)
# return the IP of the interface with the gateway
# remember the IP is stored like this 192.168.1.0/24
def get_primary_ip(self):
primary_ip = None
interfaces = self.get_components(component_type = "LAN")
for interface in interfaces:
interface_ip = interface.get_metrics_value(type = "IP Address")
interface_subnet = None
if "/" in interface_ip:
interface_subnet = ipaddress.ip_network(interface_ip, strict=False)
print(f"interface IP: {interface_ip} - Default Gateway: {self.default_gateway}")
if is_ip_in_subnets(self.default_gateway ,interface_subnet):
primary_ip = str(interface_ip).split("/")[0]
print(f"Primary IP: {primary_ip}")
return primary_ip
######################################################## ########################################################
# helper class functions # helper class functions
@ -456,7 +501,7 @@ class System:
else: else:
result = [] result = []
for component in self.components: for component in self.components:
if component.type == component_type: if component_type in component.type:
result.append(component) result.append(component)
if component.is_multi(): if component.is_multi():
return result return result
@ -648,4 +693,17 @@ def get_device_list(device_type_name: str):
return result return result
# subnet helper app
def is_ip_in_subnets(ip, subnet):
try:
ip_obj = ipaddress.IPv4Address(ip)
subnet_obj = ipaddress.IPv4Network(subnet)
if ip_obj in subnet_obj:
return True
return False
except ValueError as e:
# If the IP address is not valid, raise an error
return False

View File

@ -19,7 +19,7 @@ app_settings = {
"custom_api_port": "5000", "custom_api_port": "5000",
"cosmostat_server_api": "http://10.200.27.20:5000/", "cosmostat_server_api": "http://10.200.27.20:5000/",
"REAL_API_KEY": ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(256)), "REAL_API_KEY": ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(256)),
"disable_local_api": False, "disable_local_dashboard": False,
"local_api_address": "http://10.200.27.20:5000/", "local_api_address": "http://10.200.27.20:5000/",
"cosmostat_server_ip": "10.200.27.20", "cosmostat_server_ip": "10.200.27.20",
"api_bind_ip": "192.168.37.1" "api_bind_ip": "192.168.37.1"

View File

@ -16,6 +16,7 @@ import subprocess
import json import json
import time import time
import weakref import weakref
import ipaddress
import base64, hashlib import base64, hashlib
from typing import Dict, Any, List from typing import Dict, Any, List
# Import Cosmos Settings # Import Cosmos Settings
@ -35,10 +36,11 @@ class CosmostatServer:
# instantiate new Cosmostat server # instantiate new Cosmostat server
############################################################ ############################################################
def __init__(self, name: str): def __init__(self, name: str, hostname: str):
# the system needs a name, should be equal to the uuid of the client # the system needs a name, should be equal to the uuid of the client
self.name = name self.name = name
self.short_id = self.short_uuid(self.name) self.short_id = self.short_uuid(self.name)
self.hostname = hostname
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 CosmostatClient Objects # system contains an array of CosmostatClient Objects
self.systems = [] self.systems = []
@ -52,14 +54,18 @@ class CosmostatServer:
def add_system(self, system_dictionary: dict): def add_system(self, system_dictionary: dict):
if not self.check_uuid(system_dictionary["uuid"]): if not self.check_uuid(system_dictionary["uuid"]):
print(f"Adding Cosmostat Host: {system_dictionary['hostname']}")
new_cosmostat_clilent = CosmostatClient( new_cosmostat_clilent = CosmostatClient(
name = system_dictionary["short_id"], name = system_dictionary["short_id"],
uuid = system_dictionary["uuid"], uuid = system_dictionary["uuid"],
hostname = system_dictionary["hostname"], hostname = system_dictionary["hostname"],
active_ip = system_dictionary["active_interface"],
is_server = system_dictionary["is_server"],
data_timestamp = time.time(), data_timestamp = time.time(),
client_properties = system_dictionary["client_properties"], client_properties = system_dictionary["client_properties"],
redis_data = {} redis_data = {}
) )
print(f"New Cosmostat Server Object - IP {system_dictionary['active_interface']}")
self.systems.append(new_cosmostat_clilent) self.systems.append(new_cosmostat_clilent)
log_data(log_output = f'Client system {system_dictionary["short_id"]} added', log_level = "log_output") log_data(log_output = f'Client system {system_dictionary["short_id"]} added', log_level = "log_output")
return new_cosmostat_clilent.data_timestamp return new_cosmostat_clilent.data_timestamp
@ -123,6 +129,13 @@ class CosmostatServer:
result.append(system.hostname) result.append(system.hostname)
return result return result
def get_metrics_from_ip(self, ip):
this_metrics = ""
for system in self.systems:
if system.active_ip == ip:
this_metrics = system.redis_data
return this_metrics
def purge_stale_hostnames(self): def purge_stale_hostnames(self):
now = time.time() now = time.time()
fresh_systems = [] fresh_systems = []
@ -131,6 +144,22 @@ class CosmostatServer:
if age <= 60: # keep only fresh servers if age <= 60: # keep only fresh servers
fresh_systems.append(system) fresh_systems.append(system)
self.systems = fresh_systems # replace the old list self.systems = fresh_systems # replace the old list
# return the VPN IP if present, if just_check then it returns true/false if th
def get_vpn_ip(self, remote_ip, just_check = False):
cosmos_vpn_subnet = "10.200.26.0/24"
vpn_ip = None
this_client_metrics = self.get_metrics_from_ip(remote_ip)
for metric in this_client_metrics:
# if the metric is from VPN, is an IP address, and it belongs to the Jenkins VPN subnet
if metric["Metric"] == "IP Address" and "VPN" in metric["Source"] and is_ip_in_subnets(metric["Data"].split("/")[0], cosmos_vpn_subnet):
vpn_ip = metric["Data"].split("/")[0]
if just_check and vpn_ip is not None:
vpn_ip = False
elif just_check and vpn_ip is None:
vpn_ip = True
return vpn_ip
################################################################# #################################################################
### Cosmostat Client Class ### Cosmostat Client Class
@ -145,10 +174,12 @@ class CosmostatClient:
# instantiate new Cosmostat server # instantiate new Cosmostat server
############################################################ ############################################################
def __init__(self, name: str, uuid: str, hostname: str, data_timestamp: float, client_properties: dict, redis_data: dict): def __init__(self, name: str, uuid: str, hostname: str, active_ip: str, is_server: str, data_timestamp: float, client_properties: dict, redis_data: dict):
self.name = name self.name = name
self.uuid = uuid self.uuid = uuid
self.hostname = hostname self.hostname = hostname
self.active_ip = active_ip
self.is_server = is_server
self.data_timestamp = data_timestamp self.data_timestamp = data_timestamp
self.client_properties = client_properties self.client_properties = client_properties
self.redis_data = redis_data self.redis_data = redis_data
@ -165,4 +196,17 @@ class CosmostatClient:
return self.client.properties return self.client.properties
def get_redis(self): def get_redis(self):
return self.redis_data return self.redis_data
# subnet helper app
def is_ip_in_subnets(ip, subnet):
try:
ip_obj = ipaddress.IPv4Address(ip)
subnet_obj = ipaddress.IPv4Network(subnet)
if ip_obj in subnet_obj:
return True
return False
except ValueError as e:
# If the IP address is not valid, raise an error
return False

View File

@ -3,10 +3,10 @@
### cosmostat service handler ### cosmostat service handler
####################################################################### #######################################################################
from flask import Flask, jsonify, request, Response from flask import Flask, jsonify, request, Response, abort
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, datetime
import secrets, string import secrets, string
import requests import requests
from requests import RequestException, Response from requests import RequestException, Response
@ -63,6 +63,8 @@ def get_server_redis_data():
"data_timestamp": client.data_timestamp, "data_timestamp": client.data_timestamp,
"uuid": client.uuid, "uuid": client.uuid,
"short_id": client.name, "short_id": client.name,
"active_ip": client.active_ip,
"is_server": client.is_server,
"redis_data": client.redis_data "redis_data": client.redis_data
} }
result.append(this_client_key) result.append(this_client_key)
@ -168,7 +170,7 @@ def get_php_summary():
"info_strings": component.get_properties_strings(return_simple = True) "info_strings": component.get_properties_strings(return_simple = True)
} }
system_components.append(this_component) system_components.append(this_component)
this_primary_ip = cosmostat_client.primary_ip
if run_cosmostat_server(): if run_cosmostat_server():
client_uuid = cosmostat_server.get_uuid_from_hostname(cosmostat_client.name) client_uuid = cosmostat_server.get_uuid_from_hostname(cosmostat_client.name)
data_timestamp = cosmostat_server.get_system(client_uuid) data_timestamp = cosmostat_server.get_system(client_uuid)
@ -178,11 +180,13 @@ def get_php_summary():
"component_name": "Data Timestamp", "component_name": "Data Timestamp",
"info_strings": f"Data is {data_timestamp} seconds old" "info_strings": f"Data is {data_timestamp} seconds old"
} }
this_primary_ip = cosmostat_settings["cosmostat_server_ip"]
system_components.append(component_age) system_components.append(component_age)
result = [{ result = [{
"system_properties": system_properties, "system_properties": system_properties,
"system_components": system_components "system_components": system_components,
"active_interface": this_primary_ip
}] }]
return result return result
@ -217,6 +221,7 @@ def create_client():
result = {} result = {}
# check the request and return payload dict {} 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")
# if the client does not exist, create it
if not cosmostat_server.check_uuid(payload["uuid"]): if not cosmostat_server.check_uuid(payload["uuid"]):
result = run_create_client(payload) result = run_create_client(payload)
else: else:
@ -266,6 +271,20 @@ def get_server_redis():
result = {"message": "server not running on this endpoint"} result = {"message": "server not running on this endpoint"}
return jsonify(result) return jsonify(result)
# api to get server redis data
@app.route('/client_ip_summary', methods=['GET'])
def client_ip_summary():
result = []
if run_cosmostat_server():
result = get_client_ip_summary()
else:
result = {"message": "server not running on this endpoint"}
return jsonify(result)
# return inventory file of all clients for update
@app.route("/client_inventory", methods=["GET"])
def client_inventory():
return build_inventory()
####################################################################### #######################################################################
### Server Flask Helpers ### Server Flask Helpers
@ -295,6 +314,7 @@ def run_update_client(this_client):
# create client on server # create client on server
def run_create_client(this_client): def run_create_client(this_client):
if public_api_check(this_client): if public_api_check(this_client):
#this_client["active_interface"] = cosmostat_client.primary_ip
timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
update_status = f'created client {this_client["short_id"]}' update_status = f'created client {this_client["short_id"]}'
return { return {
@ -331,6 +351,7 @@ 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
@ -366,14 +387,92 @@ def get_client_details():
result = {"message": "no clients reporting"} result = {"message": "no clients reporting"}
return result return result
# get client IP summary
def get_client_ip_summary(return_list = False):
result = []
for client in cosmostat_server.systems:
device_info = {
"small_id": client.name,
"uuid": client.uuid,
"hostname": client.hostname,
"lan_ip": client.active_ip,
}
if client.hostname != cosmostat_server.hostname:
if return_list:
result.append(client.active_ip)
else:
result.append(device_info)
return result
# build client ansible inventory file
def build_inventory():
all_ips = get_client_ip_summary(return_list = True)
cosmos_subnets = [
"172.25.1.0/24",
"172.20.0.0/16",
"172.19.10.0/24",
"10.200.26.0/24",
"10.200.27.0/24",
"192.168.60.0/24",
]
ips = []
bad_ips = []
# build list of reachable IPs
for ip in all_ips:
for subnet in cosmos_subnets:
if is_ip_in_subnets(ip, subnet) and cosmostat_server.get_vpn_ip(ip, just_check = True):
ips.append(ip)
# list of unreachable IPs
for ip in all_ips:
if ip not in ips:
bad_ips.append(ip)
# add any VPN IPs for bad IPs to the main IP list
for ip in bad_ips:
ips.append(cosmostat_server.get_vpn_ip(ip))
hosts = {ip: {"ansible_host": ip} for ip in ips}
inventory = {
"all": {
"hosts": hosts,
"vars": {
"refresh_only": "true",
"ansible_connection": "ssh",
"ansible_ssh_private_key_file": "/var/jenkins_home/jenkins_key",
"ansible_python_interpreter": "/usr/bin/python3",
"jenkins_user": 'automate',
"jenkins_group": 'Jenkins-Admin',
"subnet_group_check": 'Jenkins-AllSubnets',
"SERVER_SUBNET_GROUP": 'Jenkins-AllSubnets',
"inventory_generation_timestamp": f"{datetime.datetime.now().isoformat()}",
"playbook_file": '/var/jenkins_home/ansible/playbooks/cosmostat.yaml',
"quick_refresh": "true",
"noisy_test": "false",
"debug_output": "false",
"push_redis": "false",
"run_background": "true",
"log_output": "true",
"public_dashboard": "false",
"custom_port": "80",
"custom_api_port": "5000",
"cosmostat_server_reporter": "true",
"cosmostat_server": "false",
"secure_api": "false",
"disable_local_dashboard": "true",
"REAL_API_KEY": f"{cosmostat_settings['REAL_API_KEY']}"
},
}
}
return yaml.safe_dump(
inventory,
default_flow_style=False,
sort_keys=False,
)
####################################################################### #######################################################################
### Cosmostat Client Subroutines ### Cosmostat Client Subroutines
####################################################################### #######################################################################
# since the API isn't running # Cosmostat Client Reporter Handler
# def local_client_update():
# Cosmostat Client Reporter
def client_update(): def client_update():
api_url = f"{cosmostat_server_api()}update_client" api_url = f"{cosmostat_server_api()}update_client"
payload = get_client_payload(get_client_redis_data(human_readable = False), "redis_data") payload = get_client_payload(get_client_redis_data(human_readable = False), "redis_data")
@ -385,6 +484,7 @@ def client_update():
if not result or not result.get('client_updated'): if not result or not result.get('client_updated'):
log_data(log_output = f"Client not updated, initializing", log_level = "log_output") log_data(log_output = f"Client not updated, initializing", log_level = "log_output")
result = client_api_initialize() result = client_api_initialize()
# this result does not matter and is not used anywhere
return result return result
# Cosmostat Client Initializer # Cosmostat Client Initializer
@ -393,6 +493,9 @@ def client_api_initialize():
# generate payload # generate payload
payload = get_client_payload(get_php_summary(), "client_properties") payload = get_client_payload(get_php_summary(), "client_properties")
# execute API call # execute API call
payload["active_interface"] = cosmostat_client.primary_ip
payload["is_server"] = False
result = client_submission_handler(api_url, payload) result = client_submission_handler(api_url, payload)
return result return result
@ -410,7 +513,7 @@ def client_submission_handler(api_url: str, payload: dict):
try: try:
result = response.json() result = response.json()
except ValueError as exc: except ValueError as exc:
log_data(log_output = "Server responded with non-JSON payload: {response.text!r}", log_level = "log_output") log_data(log_output = f"Server responded with non-JSON payload: {response.text!r}", log_level = "log_output")
return result return result
def get_client_payload(system_dictionary: dict, dictionary_name: str): def get_client_payload(system_dictionary: dict, dictionary_name: str):
@ -427,7 +530,6 @@ def get_client_payload(system_dictionary: dict, dictionary_name: str):
} }
return payload return payload
####################################################################### #######################################################################
####################################################################### #######################################################################
### Main Subroutine ### Main Subroutine
@ -452,7 +554,7 @@ if __name__ == '__main__':
# instantiate and return the Cosmoserver System object # instantiate and return the Cosmoserver System object
def new_cosmostat_server(): def new_cosmostat_server():
new_server = CosmostatServer(cosmostat_client.uuid) new_server = CosmostatServer(name = cosmostat_client.uuid, hostname = jenkins_hostname_settings())
log_data(log_output = f"New Cosmostat serverobject 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
@ -473,7 +575,7 @@ if __name__ == '__main__':
cosmostat_client.update_system_state() cosmostat_client.update_system_state()
# publish to redis if the web dashboard is active locally # publish to redis if the web dashboard is active locally
if app_settings["push_redis"] and not app_settings["disable_local_api"]: if app_settings["push_redis"] and not app_settings["disable_local_dashboard"]:
update_redis_server() update_redis_server()
# report data to the server if configured # report data to the server if configured
@ -489,7 +591,7 @@ if __name__ == '__main__':
run_update_client(get_client_payload(get_client_redis_data(human_readable = False), "redis_data")) run_update_client(get_client_payload(get_client_redis_data(human_readable = False), "redis_data"))
log_data(log_output = f"{this_client}", log_level = "noisy_test") log_data(log_output = f"{this_client}", log_level = "noisy_test")
time.sleep(0.5) time.sleep(0.2)
###################################### ######################################
# instantiate client # instantiate client
@ -514,15 +616,18 @@ if __name__ == '__main__':
log_data(log_output = f"Cosmostat Server Start", log_level = "log_output") log_data(log_output = f"Cosmostat Server Start", log_level = "log_output")
cosmostat_server = new_cosmostat_server() cosmostat_server = new_cosmostat_server()
this_client = get_client_payload(get_php_summary(), "client_properties") this_client = get_client_payload(get_php_summary(), "client_properties")
this_client["active_interface"] = cosmostat_settings["cosmostat_server_ip"]
this_client["is_server"] = True
timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) timestamp_update = cosmostat_server.add_system(system_dictionary = this_client)
# moving this here so all the bits exist # if not a server, update client on the server
client_update() else:
client_update()
###################################### ######################################
# send initial stats update to redis # send initial stats update to redis
###################################### ######################################
if app_settings["push_redis"] and not app_settings["disable_local_api"]: if app_settings["push_redis"] and not app_settings["disable_local_dashboard"]:
log_data(log_output = f"Initial Redis Push", log_level = "log_output") log_data(log_output = f"Initial Redis Push", log_level = "log_output")
update_redis_server() update_redis_server()
@ -530,7 +635,7 @@ if __name__ == '__main__':
# Flask scheduler for scanner # Flask scheduler for scanner
###################################### ######################################
if app_settings["run_background"] and not app_settings["disable_local_api"]: if app_settings["run_background"] and not app_settings["disable_local_dashboard"]:
log_data(log_output = f"Background Function Initializing", log_level = "log_output") log_data(log_output = f"Background Function Initializing", log_level = "log_output")
log_data(log_output = "Loading flask background subroutine...", log_level = "log_output") log_data(log_output = "Loading flask background subroutine...", log_level = "log_output")
@ -549,7 +654,7 @@ if __name__ == '__main__':
# Flask API # Flask API
###################################### ######################################
log_data(log_output = f"gateway: {service_gateway_ip()} - port: {service_api_port()}", log_level = "log_output") log_data(log_output = f"gateway: {service_gateway_ip()} - port: {service_api_port()}", log_level = "log_output")
if not app_settings["disable_local_api"]: if not app_settings["disable_local_dashboard"]:
log_data(log_output = f"Main API Start", log_level = "log_output") log_data(log_output = f"Main API Start", log_level = "log_output")
app.run(debug=False, host=service_gateway_ip(), port=service_api_port()) app.run(debug=False, host=service_gateway_ip(), port=service_api_port())
else: else:

View File

@ -247,5 +247,20 @@
"metrics": { "metrics": {
"placeholder": "" "placeholder": ""
} }
},
{
"name": "BAT",
"description": "Battery - {Device Name} - capacity {Capacity}",
"multi_check": "True",
"device_list": "acpi | grep Battery | cut -d: -f1",
"properties": {
"Device Name": "echo {this_device}",
"Capacity": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .design_capacity_mah' "
},
"metrics": {
"Percent Full": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .charge_percent'",
"State": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .state'"
},
"precheck": "acpi | grep Battery | wc -l"
} }
] ]

View File

@ -41,6 +41,22 @@
] ]
}, },
{
"name": "BAT",
"description": "Battery - {Device Name} - capacity {Capacity}",
"multi_check": "True",
"device_list": "acpi | grep Battery | cut -d: -f1",
"properties": {
"Device Name": "echo {this_device}",
"Capacity": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .design_capacity_mah' "
},
"metrics": {
"Percent Full": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .charge_percent'",
"State": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .state'"
},
"precheck": "acpi | grep Battery | wc -l"
},
{ {
"SATA GBW": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r '.physical_block_size as $block |.ata_device_statistics.pages[] | select(.name == \"General Statistics\") | .table[] | select(.name == \"Logical Sectors Written\") | .value as $sectors | ($sectors * $block) / 1073741824 ' | awk '{{printf \"%.2f GiB Written\\n\", $0}}' || true", "SATA GBW": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r '.physical_block_size as $block |.ata_device_statistics.pages[] | select(.name == \"General Statistics\") | .table[] | select(.name == \"Logical Sectors Written\") | .value as $sectors | ($sectors * $block) / 1073741824 ' | awk '{{printf \"%.2f GiB Written\\n\", $0}}' || true",
"NVMe GBW": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r ' .nvme_smart_health_information_log.data_units_written as $dw | .logical_block_size as $ls | ($dw * $ls) / 1073741824 ' | awk '{{printf \"%.2f GiB Written\\n\", $0}}' || true" "NVMe GBW": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r ' .nvme_smart_health_information_log.data_units_written as $dw | .logical_block_size as $ls | ($dw * $ls) / 1073741824 ' | awk '{{printf \"%.2f GiB Written\\n\", $0}}' || true"

View File

@ -1,91 +1,47 @@
# ------------------------------------------------------------------
# 1. Base image
# ------------------------------------------------------------------
# We use a slim Debian base so we can use aptget to pull every
# component in one go. Debian Bookworm contains all the
# packages we need (nodejs 18, redis, nginx, php8fpm, etc.).
# ------------------------------------------------------------------
FROM php:8.0-apache
# ------------------------------------------------------------------ # Base image
# 2. Build arguments handy if you want to change the port numbers FROM php:8.1-apache
# without touching the Dockerfile
# ------------------------------------------------------------------
ARG REDIS_PORT=6379
ARG NODE_PORT=3000
ARG PHP_PORT=8080
ARG NGX_PORT=80
ENV REDIS_PORT=${REDIS_PORT} # Install system packages
ENV NODE_PORT=${NODE_PORT} RUN apt-get update && apt-get install -y --no-install-recommends \
ENV PHP_PORT=${PHP_PORT} # Services
ENV NGX_PORT=${NGX_PORT} redis-server nginx \
# Process supervisor
supervisor \
# Clean up
&& rm -rf /var/lib/apt/lists/*
# ------------------------------------------------------------------ # Install Node.js LTS 18.x
# 3. Install all the system packages we need RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
# ------------------------------------------------------------------ && apt-get update && apt-get install -y nodejs \
RUN apt-get update && \ && rm -rf /var/lib/apt/lists/*
DEBIAN_FRONTEND=noninteractive apt-get install -y \
curl gnupg ca-certificates \
nodejs npm \
redis-server \
nginx \
supervisor \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# ------------------------------------------------------------------ # copy the config file
# 4. Prepare the working directories
# ------------------------------------------------------------------
# Node app
WORKDIR /app
COPY web/node_server/package.json ./
RUN npm install
COPY web/node_server/ ./
# Webdashboard static files
COPY web/html /var/www/html/
# API settings file
COPY cosmostat_settings.yaml /app/cosmostat_settings.yaml COPY cosmostat_settings.yaml /app/cosmostat_settings.yaml
# Nginx config you can keep the same file you used for the # Node on 3000
# proxy service in the compose file. It will proxy 3000 (WS) WORKDIR /usr/src/app
# and 8080 (PHP) to the local container. COPY web/node_server/ .
COPY web/proxy/nginx.conf /etc/nginx/nginx.conf RUN npm install --only=production
# ------------------------------------------------------------------ # Apache on 8080
# 5. Supervisord configuration RUN sed -i 's/^Listen .*/Listen 8080/' /etc/apache2/ports.conf && \
# ------------------------------------------------------------------ sed -i 's/<VirtualHost \*:80>/<VirtualHost \*:8080>/' /etc/apache2/sites-enabled/000-default.conf
# Create a minimal supervisord.conf that will launch the four COPY web/html/ /var/www/html/
# services from the same container.
RUN mkdir -p /etc/supervisor/conf.d && \
cat > /etc/supervisor/conf.d/supervisord.conf <<EOF
[supervisord]
nodaemon=true
[program:redis] # nginx on 80
command=/usr/bin/redis-server --port ${REDIS_PORT} RUN rm -rf /etc/nginx/sites-enabled/default
autostart=true COPY web/proxy/nginx.conf /etc/nginx/conf.d/default.conf
autorestart=true
user=root
[program:node] # Add supervisord configuration
command=npm run start COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
autostart=true
autorestart=true
user=root
[program:nginx] # Expose ports
command=/usr/sbin/nginx -g 'daemon off;' EXPOSE 80
autostart=true EXPOSE 6379
autorestart=true
user=root
EOF
# ------------------------------------------------------------------ # healthcheck looks for apache
# 6. Expose the ports HEALTHCHECK CMD netstat -ltn | grep -c ":8080" > /dev/null; if [ 0 != $? ]; then exit 1; fi;
# ------------------------------------------------------------------
EXPOSE ${REDIS_PORT} ${NGX_PORT}
# 7. Default command start supervisord # Start supervisord
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@ -1,48 +0,0 @@
FROM php:8.0-apache
RUN set -eux; \
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
gnupg \
lsb-release \
wget \
curl \
sudo \
redis-server \
nginx \
supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Node application
COPY web/node_server/ .
RUN npm install --only=production
#RUN npm ci --production
# PHP static files (public web root)
COPY web/html/ /var/www/html/
# NGINX config (overwrites the default)
COPY web/proxy/nginx.conf /etc/nginx/conf.d/default.conf
# Shared settings file (readonly)
COPY cosmostat_settings.yaml /app/cosmostat_settings.yaml
# Supervisor configuration
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Entrypoint script
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 6379
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@ -1,6 +0,0 @@
#!/bin/sh
# Ensure the shared config file is readable
chmod 644 /app/cosmostat_settings.yaml
# Let Supervisor do the heavy lifting
exec "$@"

View File

@ -1,22 +1,46 @@
[supervisord] [supervisord]
nodaemon=true nodaemon=true
logfile=/dev/stdout
logfile_maxbytes=0
# ------------------------------------------------------------------
# 1. Apache (from the base image)
# ------------------------------------------------------------------
[program:apache2]
command=/usr/sbin/apache2ctl -D FOREGROUND
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
autorestart=true
priority=1
# ------------------------------------------------------------------
# 2. Redis
# ------------------------------------------------------------------
[program:redis] [program:redis]
command=/usr/bin/redis-server command=/usr/bin/redis-server --daemonize no --protected-mode no
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:node]
command=sh -c "cd /app && node server.js"
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:apache]
command=/usr/sbin/httpd -DFOREGROUND
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
autorestart=true
priority=2
# ------------------------------------------------------------------
# 3. Nginx (will listen on 8080 by default)
# ------------------------------------------------------------------
[program:nginx] [program:nginx]
command=nginx -g "daemon off;" command=/usr/sbin/nginx -g 'daemon off;'
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
autorestart=true
priority=3
# ------------------------------------------------------------------
# 4. Node.js
# ------------------------------------------------------------------
# NOTE: Adjust the command/path to match your app
[program:node]
command=npm start
directory=/usr/src/app
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
autorestart=true
priority=4

View File

@ -0,0 +1,143 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cosmostat - <?php echo $_SERVER['SERVER_NAME'] ?></title>
<style>
.components {display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:1rem;}
.component {padding:10px; border:1px solid; border-radius:4px;}
.component h3{margin-top:0; margin-bottom:5px;}
.info-list {list-style:none; padding-left:0;}
.info-list li{margin-bottom:3px;}
.system-list {list-style:none; padding-left:0; margin-top:1em;}
.system-list li{margin-bottom:5px; font-weight:400;}
</style>
<link rel="stylesheet" href="src/styles.css">
</head>
<body>
<div class="card">
<h2>Matt-Cloud Cosmostat Dashboard</h2>
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://<?php echo $_SERVER['SERVER_NAME'] ?>/descriptor<br>
</code>
This will return the entire JSON descriptor variable
</div>
<div class="card">
<div id="host_components" class="column">
<!-- PHP to render static components -->
<?php
# load API settings, this requires a simple yaml file
$raw_api_settings = file('/app/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;
# load API data
$apiUrl = 'http://'.$api_bind_ip.':'.$customApiPort.'/php_summary';
echo "<!-- apiUrl - ".$apiUrl." -->";
$context = stream_context_create([
'http' => [
'timeout' => 5, // seconds
'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>');
}
$data = json_decode($json, true);
if ($data === null) {
die('<p style="color:red;">Malformed JSON returned from the API.</p>');
}
function h(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
<?php if (isset($data[0]['system_properties'])): ?>
<h2>System Properties</h2>
<div class="system">
<table>
<tr><td>
<ul class="system-list">
<?php foreach ($data[0]['system_properties'] as $prop): ?>
<li><?= h($prop['Property']); ?></li>
<?php endforeach; ?>
</ul>
</td><td>
<!-- Javascript to render static components -->
<h2>Live System Metrics</h2>
<div id="host_metrics" class="column">Connecting...</div>
</td></tr>
</table>
</div>
<?php endif; ?>
<?php if (isset($data[0]['system_components'])): ?>
<h2>Components</h2>
<div class="components">
<?php foreach ($data[0]['system_components'] 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; ?>
<!-- PHP rendered HTML ends here -->
</div>
</div>
<!-- Socket.IO client library -->
<script src="socket.io/socket.io.js"></script>
<!-- matt-cloud redis script -->
<script src="src/redis.js"></script>
<!-- Toggle the help text when the link is clicked -->
<script>
document.getElementById('helpToggle').addEventListener('click', function () {
const help = document.getElementById('helpText');
if (help.style.display === 'none' || help.style.display === '') {
help.style.display = 'block';
} else {
help.style.display = 'none';
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,145 @@
/* ------------------------------------------------------------
1. Socket-IO connection & helper functions (unchanged)
------------------------------------------------------------ */
const socket = io();
socket.on('host_metrics', renderStatsTable);
socket.on('connect_error', err => {
safeSetText('host_metrics', `Could not connect to server - ${err.message}`);
});
socket.on('reconnect', attempt => {
safeSetText('host_metrics', `Re-connected (attempt ${attempt})`);
});
function safeSetText(id, txt) {
const el = document.getElementById(id);
if (el) el.textContent = txt;
}
/* ------------------------------------------------------------------
2. Table rendering - now orders rows before building the table
------------------------------------------------------------------ */
// helper function for table row ordering
function renderStatsTable(data) {
socket.emit('tableRendered');
renderGenericTable('host_metrics', data, 'No Stats available');
}
function renderGenericTable(containerId, data, emptyMsg) {
const container = document.getElementById(containerId);
if (!Array.isArray(data) || !data.length) {
container.textContent = emptyMsg;
return;
}
/* Merge rows by name (new logic) */
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);
}
/* ------------------------------------------------------------
3. Merge rows by name
------------------------------------------------------------ */
function mergeRowsByName(data) {
const groups = {}; // { source: { ... } }
data.forEach(row => {
const source = row.Source; // <-- changed
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, // <-- keep the original key
Metric: grp.Metric,
Data: grp.Data,
Property: grp.Property,
Value: grp.Value
});
});
return merged;
}
// 3b. Order rows put “System”, “CPU”, “RAM” first
function orderRows(rows) {
// Priority list can be updated later
const priority = ['System', 'CPU', 'RAM'];
// Map source → priority index
const priorityMap = {};
priority.forEach((src, idx) => {
priorityMap[src] = idx;
});
// Stable sort: keep original position if priorities are equal
return [...rows].sort((a, b) => {
const aIdx = priorityMap.hasOwnProperty(a.Source)
? priorityMap[a.Source]
: Infinity; // anything not in priority goes to the end
const bIdx = priorityMap.hasOwnProperty(b.Source)
? priorityMap[b.Source]
: Infinity;
// If both have the same priority (or both Infinity), keep original order
return aIdx - bIdx;
});
}
/* ------------------------------------------------------------
4. Build an HTML table from an array of objects
------------------------------------------------------------ */
function buildTable(data) {
const cols = ['Source', 'Metric', 'Data']; // explicit order
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)) {
// Create a <span> for each item
val.forEach((v, idx) => {
td.id = 'host_metrics_column';
const span = document.createElement('span');
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'));
});
} else {
td.textContent = val !== undefined ? val : '';
}
});
});
return table;
}

View File

@ -0,0 +1,246 @@
/* -------------------------------------------------
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 {
margin: 0;
padding: 0;
background: var(--bg-body);
color: var(--clr-text);
font-family: Arial, Helvetica, sans-serif;
}
/* Links */
a { color: var(--clr-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* -------------------------------------------------
2. Layout - wrapper, sidebar, main
------------------------------------------------- */
.wrapper { display: flex; min-height: 100vh; }
.sidebar {
position: fixed; /* keep sidebar visible during scroll */
top: 0; /* stick to the top of the viewport */
left: 0; /* align to the left edge */
height: 100vh; /* full viewport height */
/* ---- size & spacing ------------------------------------------- */
width: 200px; /* same as before */
padding: 1rem;
overflow-y: auto; /* allow sidebar content to scroll if needed */
/* ---- look ------------------------------------------------------- */
background: var(--bg-sidebar);
/* optional: keep it above other content */
z-index: 1000;
}
.sidebar h3 { margin: 0 0 .4rem 0; font-size: 1.1rem; }
.sidebar ul { list-style: none; padding: 0; margin: 0; }
.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;
padding-left: 200px; /* space for the fixed sidebar */
/* optional: avoid accidental horizontal overflow */
overflow-x: hidden;
}
/* -------------------------------------------------
3. Card component
------------------------------------------------- */
.card {
max-width: 950px;
margin: 20px auto 1rem auto;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--clr-border);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,.3);
}
/* -------------------------------------------------
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 */
}
/* -------------------------------------------------
5. Lists & headings
------------------------------------------------- */
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); }
/* System & component lists */
.system-list, .info-list {
list-style: none; padding: 0; margin: 0;
}
.system-list li, .info-list li { margin-bottom: 5px; color: var(--clr-text); }
/* -------------------------------------------------
6. Components grid
------------------------------------------------- */
.components {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.component {
padding: 10px;
border: 1px solid var(--clr-border);
border-radius: 4px;
}
.component h3 { margin: 0 0 5px; }
/* -------------------------------------------------
7. Panel toggles / modal
------------------------------------------------- */
.help-link {
cursor: pointer;
user-select: none;
color: var(--clr-accent);
text-align: right;
}
.help-link:hover { text-decoration: underline; }
#helpText { display: none; }
.componentDetail-link {
cursor: pointer;
user-select: none;
color: var(--clr-accent);
text-align: left;
}
.componentDetail-link:hover { text-decoration: underline; }
#componentDetailText { display: none; }
/* -------------------------------------------------
8. Misc helpers
------------------------------------------------- */
/* Hide numeric markers in metric columns (if any) */
#host_metrics_column td { list-style: none; padding-left: 0; margin-left: 0; }
.host-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 6px;
margin-right: 8px;
vertical-align: middle;
background: #808080; /* default unknown / no timestamp */
transition: background-color 1s linear; /* smooth fade */
}
/* -------------------------------------------------
9. Mobile adjustments
------------------------------------------------- */
@media (max-width: 768px) {
/* 1. Make the whole page a column */
.wrapper {
flex-direction: column;
}
/* 2. Hide the sidebar initially */
.sidebar {
position: relative; /* take it out of the flow */
width: 100%;
max-height: 0; /* collapsed */
overflow: hidden;
transition: max-height 0.3s ease-out;
background: var(--bg-sidebar);
padding: 0; /* remove padding */
}
.sidebar.show {
max-height: 500px; /* enough for all items */
padding: 1rem;
}
/* 3. Move the toggle button into the header */
.mobile-toggle {
display: block;
font-size: 1.5rem;
background: transparent;
border: none;
color: var(--clr-accent);
padding: 0.5rem;
margin-bottom: 0.5rem;
}
/* 4. Main content no left padding */
.main {
padding-left: 0;
padding-right: 1rem;
}
/* 5. Table scroll on small screens */
.card table {
width: 100%;
table-layout: fixed;
}
.card table thead,
.card table tbody,
.card table tr,
.card table td,
.card table th {
display: block;
}
.card table tbody {
overflow-x: auto;
white-space: nowrap;
}
.card table td,
.card table th {
display: inline-block;
vertical-align: top;
width: 30%;
}
/* 6. Adjust the host status dot positioning */
.host-status {
margin-left: 2px;
margin-right: 12px;
}
}
/* Optional: style the drawer indicator */
.sidebar.show::before {
content: "✕ Close";
display: block;
padding: 0.5rem 1rem;
background: var(--bg-sidebar);
color: var(--clr-accent);
font-weight: bold;
}

View File

@ -0,0 +1,15 @@
{
"name": "redis-table-demo",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"redis": "^4.6.7",
"node-fetch": "^2.6.7",
"js-yaml": "^4.1.0"
}
}

View File

@ -0,0 +1,132 @@
// server.js
const http = require('http');
const express = require('express');
const { createClient } = require('redis');
const { Server } = require('socket.io');
const fetch = require('node-fetch'); // npm i node-fetch@2
const fs = require('fs');
const yaml = require('js-yaml'); // npm i js-yaml
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
/* --------------------------------------------------------------------- */
/* ---------- 1. Load the YAML configuration file ---------------------- */
/* --------------------------------------------------------------------- */
let config = {};
try {
const file = fs.readFileSync(path.resolve(__dirname, 'cosmostat_settings.yaml'), 'utf8');
config = yaml.load(file);
} catch (e) {
console.error('Failed to load config.yaml:', e);
process.exit(1);
}
const API_PORT = config.custom_api_port || 5000; // fallback to 5000
const API_HOST = config.api_bind_ip || '192.168.37.1'; // fallback IP
const API_BASE = `http://${API_HOST}:${API_PORT}`;
console.log('API URL:', API_BASE);
// ---------------------------------------------------------------------
// ---------- 2. Socket.io ------------------------------------------------
// ---------------------------------------------------------------------
io.on('connection', async socket => {
console.log('client connected:', socket.id);
// Call the external API every time a client connects
try {
const resp = await fetch(`${API_BASE}/start_timer`, { method: 'GET' });
const data = await resp.json();
console.log('API responded to connect:', data);
} catch (err) {
console.error('Failed to hit start_timer endpoint:', err);
}
// Listen for tableRendered event from the client
socket.on('tableRendered', async () => {
console.log('Client reported table rendered - starting timer');
try {
const resp = await fetch(`${API_BASE}/start_timer`, { method: 'GET' });
const text = await resp.text();
console.log('Timer endpoint responded:', text);
} catch (err) {
console.error('Failed to hit start_timer:', err);
}
});
});
/* --------------------------------------------------------------------- */
/* ---------- 3. Serve static files ----------------------------------- */
/* --------------------------------------------------------------------- */
app.use(express.static('public'));
/* --- 4. Redis subscriber (patched) --------------------------------- */
const redisClient = createClient({
url: 'redis://0.0.0.0:6379',
socket: { keepAlive: 60000, // 60s TCP keep-alive
reconnectStrategy: attempts => Math.min(attempts * 100, 3000) } // back-off
});
redisClient.on('error', err => console.error('Redis error', err));
(async () => {
await redisClient.connect();
const sub = redisClient.duplicate();
await sub.connect();
// --------------------------------------------------------------------
// Helper that re-subscribes to a channel (and re-sends the handler)
// --------------------------------------------------------------------
async function safeSubscribe(channel, handler) {
try {
await sub.subscribe(channel, handler);
console.log(`Subscribed to ${channel}`);
} catch (e) {
console.error(`Failed to subscribe to ${channel}`, e);
}
}
// ---------------------------------------------------------------
// Subscribe to all required channels
// ---------------------------------------------------------------
await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg));
await safeSubscribe('client_summary', (msg) => forward('client_summary', msg));
// ---------------------------------------------------------------
// Forward messages to Socket.io
// ---------------------------------------------------------------
function forward(channel, message) {
try {
const payload = JSON.parse(message);
io.emit(channel, payload);
} catch (e) {
console.error(`Failed to parse message from ${channel}`, e);
}
}
// ----------------------------------------------------------------
// Re-subscribe automatically when the Redis connection reconnects
// ----------------------------------------------------------------
sub.on('reconnecting', () => console.log('Redis reconnecting…'));
sub.on('ready', async () => {
console.log('Redis ready - re-subscribing to all channels');
await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg));
await safeSubscribe('client_summary', (msg) => forward('client_summary', msg));
});
// Optional: if the connection ends for any reason, close the process
sub.on('end', () => {
console.error('Redis connection closed - exiting');
process.exit(1);
});
})();
/* --------------------------------------------------------------------- */
/* ---------- 5. Start the HTTP server --------------------------------- */
/* --------------------------------------------------------------------- */
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});

View File

@ -0,0 +1,63 @@
# nginx.conf
# This file will be mounted into /etc/nginx/conf.d/default.conf inside the container
# Enable proxy buffers (optional but recommended)
proxy_buffering on;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
server {
listen 80;
server_name localhost;
# ---------------------------------------
# API Endpoints
# ---------------------------------------
location = /descriptor {
proxy_pass http://192.168.37.1:5000/descriptor;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /update_client {
proxy_pass http://192.168.37.1:5000/update_client;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /create_client {
proxy_pass http://192.168.37.1:5000/create_client;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ---------------------------------------
# WebSocket endpoint
# ---------------------------------------
location /socket.io/ {
proxy_pass http://192.168.37.1:3000/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ---------------------------------------
# All other paths → Apache (PHP)
# ---------------------------------------
location / {
proxy_pass http://192.168.37.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

View File

@ -62,29 +62,74 @@
/* ========================================================== /* ==========================================================
Sidebar building - uses short_id for status key Sidebar building - uses short_id for status key
========================================================== */ ========================================================== */
function buildList(systemList) { function buildList(systemList) {
const ul = document.getElementById('endpointList'); const ul = document.getElementById('endpointList');
if (!Array.isArray(systemList)) {
ul.innerHTML = ''; // nothing to show
return;
}
/* ────────────────────────────────────────
* Sort: servers first, then by IP
* ──────────────────────────────────────── */
const toInt = ip => {
// guard against undefined / empty string
if (!ip) return 0;
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0);
};
const sorted = [...systemList].sort((a, b) => {
// a. Servers go before nonservers
const aServer = !!a.is_server;
const bServer = !!b.is_server;
if (aServer !== bServer) return aServer ? -1 : 1; // true < false
// b. Same “is_server” status fall back to IP sorting
const aIp = a.active_ip ?? '';
const bIp = b.active_ip ?? '';
if (!aIp) return 1; // push empty IPs to the end
if (!bIp) return -1;
return toInt(aIp) - toInt(bIp);
});
/* ────────────────────────────────────────
* Bail if nothing actually changed
* ──────────────────────────────────────── */
const current = Array.from(ul.children).map(li => li.dataset.id); const current = Array.from(ul.children).map(li => li.dataset.id);
const newIds = systemList.map(s => s.short_id); const newIds = sorted.map(s => s.short_id);
if (arraysEqual(current, newIds)) return; if (arraysEqual(current, newIds)) return; // no visual change needed
/* ────────────────────────────────────────
* Build the DOM
* ──────────────────────────────────────── */
const selected = getSelectedId().toLowerCase(); const selected = getSelectedId().toLowerCase();
ul.innerHTML = ''; // reset list ul.innerHTML = ''; // reset
systemList.forEach(item => {
sorted.forEach(item => {
const li = document.createElement('li'); const li = document.createElement('li');
// status dot - keyed by short_id
// • Status dot
const status = document.createElement('span'); const status = document.createElement('span');
status.className = 'host-status'; status.className = 'host-status';
status.dataset.id = item.short_id; status.dataset.id = item.short_id;
// link - display hostname, encode short_id in URL
// • Link display hostname, encode short_id in URL
const a = document.createElement('a'); const a = document.createElement('a');
a.href = '?host=' + encodeURIComponent(item.short_id); a.href = '?host=' + encodeURIComponent(item.short_id);
a.textContent = item.hostname; a.textContent = item.hostname;
a.title = item.active_ip ? `Active IP: ${item.active_ip}` : '';
if (item.short_id.toLowerCase() === selected) a.classList.add('active'); if (item.short_id.toLowerCase() === selected) a.classList.add('active');
li.appendChild(status); li.appendChild(status);
li.appendChild(a); li.appendChild(a);
ul.appendChild(li); ul.appendChild(li);
}); });
} }
/* ========================================================== /* ==========================================================
Update status colors every second Update status colors every second
========================================================== */ ========================================================== */

View File

@ -14,7 +14,7 @@
scope: user scope: user
# create service working folder # create service working folder
- name: Cosmostat - API - create cosmos user service folder - name: Cosmostat - API - create cosmos user systemd folder
file: file:
path: "{{ user_service_folder }}" path: "{{ user_service_folder }}"
state: directory state: directory

79
tasks/docker.yaml Normal file
View File

@ -0,0 +1,79 @@
---
###############################################
# This part sets up cosmostat web dashboard
###############################################
- name: Cosmostat - Web - stop containers
community.docker.docker_compose_v2:
project_src: "{{ service_control_docker_folder }}"
state: stopped
ignore_errors: yes
# Create web Folder
- name: "Cosmostat - Web - create {{ service_control_docker_folder }}"
file:
path: "{{ service_control_docker_folder }}"
state: directory
mode: '0755'
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Web - copy web files
copy:
src: "docker/"
dest: "{{ service_control_docker_folder }}/"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Web - template docker-compose.yaml
template:
src: docker-compose.yaml
dest: "{{ service_control_docker_folder }}/docker-compose.yaml"
mode: 0644
- name: "Cosmostat - Web - template cosmostat_settings.yaml"
template:
src: cosmostat_settings.yaml
dest: "{{ service_control_docker_folder }}/cosmostat_settings.yaml"
owner: "{{ service_user }}"
group: "{{ service_user }}"
mode: 0644
#######################
# configure as server
- name: Cosmostat - Web - Configure Server Dashboard
when: cosmostat_server | bool
block:
- name: Cosmostat - Server Dashboard - replace index.php
copy:
src: server/server.php
dest: "{{ service_control_docker_folder }}/web/html/index.php"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Server Dashboard - delete redis.js
ansible.builtin.file:
path: "{{ service_control_docker_folder }}/web/html/src/redis.js"
state: absent
- name: Cosmostat - Server Dashboard - copy system_metrics.js
copy:
src: server/system_metrics.js
dest: "{{ service_control_docker_folder }}/web/html/src/system_metrics.js"
mode: 0755
owner: "{{ service_user }}"
group: "{{ service_user }}"
- name: Cosmostat - Web - Start containers
community.docker.docker_compose_v2:
project_src: "{{ service_control_docker_folder }}"
state: present
register: docker_output
- debug: |
msg="{{ docker_output.actions }}"
...

View File

@ -61,13 +61,6 @@
shell: "loginctl enable-linger {{ service_user }}" shell: "loginctl enable-linger {{ service_user }}"
register: user_linger register: user_linger
# - name: Reboot target after linger change
# reboot:
# msg: "Cosmostat - Init - Rebooting target for linger enable"
# pre_reboot_delay: 10
# reboot_timeout: 600
# when: user_linger.changed
# create service working folder # create service working folder
- name: Cosmostat - Init - create cosmostat service folder - name: Cosmostat - Init - create cosmostat service folder
file: file:

View File

@ -13,12 +13,11 @@
# set up API # set up API
- name: Build API - name: Build API
# when: false
include_tasks: api.yaml include_tasks: api.yaml
# set up web stack # set up web stack
- name: Build Web Dashboard - name: Build Web Dashboard
when: not disable_local_api when: not disable_local_dashboard | bool
include_tasks: web.yaml include_tasks: web.yaml
... ...

View File

@ -9,6 +9,11 @@
owner: "{{ service_user }}" owner: "{{ service_user }}"
group: "{{ service_user }}" group: "{{ service_user }}"
- name: Cosmostat - Server Dashboard - delete redis.js
ansible.builtin.file:
path: "{{ service_control_web_folder }}/html/src/redis.js"
state: absent
- name: Cosmostat - Server Dashboard - copy system_metrics.js - name: Cosmostat - Server Dashboard - copy system_metrics.js
copy: copy:
src: server/system_metrics.js src: server/system_metrics.js
@ -16,10 +21,5 @@
mode: 0755 mode: 0755
owner: "{{ service_user }}" owner: "{{ service_user }}"
group: "{{ service_user }}" group: "{{ service_user }}"
- name: Cosmostat - Server Dashboard - delete redis.js
ansible.builtin.file:
path: "{{ service_control_web_folder }}/html/src/redis.js"
state: absent
... ...

View File

@ -4,8 +4,9 @@
############################################### ###############################################
- name: Cosmostat - Web - stop containers - name: Cosmostat - Web - stop containers
when: not quick_refresh | bool
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ service_control_docker_folder }}" project_src: "{{ service_control_web_folder }}"
state: stopped state: stopped
ignore_errors: yes ignore_errors: yes
@ -18,14 +19,6 @@
owner: "{{ service_user }}" owner: "{{ service_user }}"
group: "{{ service_user }}" group: "{{ service_user }}"
#- name: Cosmostat - Web - copy docker files
# copy:
# src: "docker/"
# dest: "{{ service_control_docker_folder }}"
# mode: 0755
# owner: "{{ service_user }}"
# group: "{{ service_user }}"
- name: Cosmostat - Web - copy web files - name: Cosmostat - Web - copy web files
copy: copy:
src: "web/" src: "web/"
@ -55,11 +48,15 @@
include_tasks: server.yaml include_tasks: server.yaml
- name: Cosmostat - Web - Start containers - name: Cosmostat - Web - Start containers
when: not quick_refresh | bool
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ service_control_web_folder }}" project_src: "{{ service_control_web_folder }}"
state: present state: present
register: docker_output register: docker_output
- debug: |
- name: Cosmostat - Web - Show docker status
when: not quick_refresh | bool
debug: |
msg="{{ docker_output.actions }}" msg="{{ docker_output.actions }}"

View File

@ -34,7 +34,8 @@ custom_api_port: {{ custom_api_port }}
cosmostat_server: {{ cosmostat_server }} cosmostat_server: {{ cosmostat_server }}
cosmostat_server_api: "{{ cosmostat_server_api }}" cosmostat_server_api: "{{ cosmostat_server_api }}"
cosmostat_server_reporter: {{ cosmostat_server_reporter }} cosmostat_server_reporter: {{ cosmostat_server_reporter }}
disable_local_api: {{ disable_local_api }} disable_local_dashboard: {{ disable_local_dashboard }}
REAL_API_KEY: "{{ REAL_API_KEY }}" REAL_API_KEY: "{{ REAL_API_KEY }}"
cosmostat_server_ip: "{{ cosmostat_server_ip }}" cosmostat_server_ip: "{{ cosmostat_server_ip }}"
... ...

View File

@ -1,66 +1,26 @@
# for now there is no php code ---
# to save resources, also disabling nginx
# will map 3000 to 80 here unless this changes
services: services:
redis: cosmostat-dash:
container_name: redis container_name: cosmostat-dash
image: redis:7-alpine image: cosmostat-dash:latest
ports: restart: always
- {{ docker_gateway }}:6379:6379 build:
context: .
dockerfile: Dockerfile
networks: networks:
- cosmostat_net - cosmostat_net
restart: always ports:
- "{{ docker_gateway }}:6379:6379"
ws_node: - "{{ (docker_gateway + ':') if not public_dashboard | bool else '' }}{{ custom_port }}:80"
image: node:18-alpine
working_dir: /app
command: sh -c "npm install && node server.js"
container_name: ws_node
volumes: volumes:
- {{ service_control_web_folder }}/html:/usr/src/app/public # - "/opt/cosmostat/docker/web/html:/var/www/html"
- {{ service_control_web_folder }}/node_server:/app # - "/opt/cosmostat/docker/web/node_server:/app"
- /app/node_modules - "/opt/cosmostat/api/cosmostat_settings.yaml:/app/cosmostat_settings.yaml:ro"
ports:
# put back to 3000 if the stack is needed
- {{ (docker_gateway + ':') if secure_api else '' }}80:3000
networks:
- cosmostat_net
restart: always
depends_on:
- redis
# these will be disabled until a stack is needed
# web_dash:
# container_name: web_dash
# image: php:8.0-apache
# ports:
# - {{ (docker_gateway + ':') if secure_api else '' }}8080:80
# volumes:
# - ./html:/var/www/html/
# networks:
# - cosmostat_net
# restart: always
#
# nginx_proxy:
# container_name: nginx_proxy
# image: nginx:latest
# ports:
# - "{{ (docker_gateway + ':') if secure_api else '' }}80:80"
# volumes:
# - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf
# networks:
# - cosmostat_net
# restart: always
# depends_on:
# - web_dash
# - ws_nodenetworks:
networks: networks:
cosmostat_net: cosmostat_net:
external: true external: true
...