diff --git a/.github/workflows/docker-build-latest.yml b/.github/workflows/docker-build-latest.yml index 2478dec..b7ce2e7 100644 --- a/.github/workflows/docker-build-latest.yml +++ b/.github/workflows/docker-build-latest.yml @@ -28,4 +28,4 @@ jobs: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build the Docker image - run: docker buildx build --platform linux/amd64,linux/arm64/v8,linux/arm/v7 --file Dockerfile --tag ${{ env.IMAGE_NAME }}:latest --push . + run: docker buildx build --platform linux/amd64,linux/arm64/v8 --file nutcase/docker/Dockerfile --tag ${{ env.IMAGE_NAME }}:latest --push . diff --git a/.github/workflows/docker-buildpush.yml b/.github/workflows/docker-buildpush.yml index 5a59f38..fa1e524 100644 --- a/.github/workflows/docker-buildpush.yml +++ b/.github/workflows/docker-buildpush.yml @@ -4,7 +4,7 @@ on: push: # branches: # - main -# - V0.2* +# - V0.3* tags: [ "*" ] # label: # branches: [ "main" ] @@ -34,5 +34,5 @@ jobs: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build the Docker image - run: docker buildx build --platform linux/amd64,linux/arm64/v8,linux/arm/v7 --file Dockerfile --tag kronos443/nutcase:$GITHUB_REF_NAME --push . + run: docker buildx build --platform linux/amd64,linux/arm64/v8 --file nutcase/docker/Dockerfile --tag kronos443/nutcase:$GITHUB_REF_NAME --push . # -$(date +'%Y-%m-%d_%H-%M-%S') diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b9d4fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv +__pycache__ +nutcase/config/*.log +nutcase/config/*.log.* diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7ea79a7..0000000 --- a/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM python:alpine3.18 -WORKDIR /app -#RUN apk add nano -RUN apk add tzdata -COPY ./app . -RUN pip install --no-cache-dir -r requirements.txt -RUN rm requirements.txt -CMD [ "python", "/app/nutcase.py" ] diff --git a/app/apc_server_handler.py b/app/apc_server_handler.py deleted file mode 100644 index 6bb8bb7..0000000 --- a/app/apc_server_handler.py +++ /dev/null @@ -1,299 +0,0 @@ -import logging -import logging.handlers - -import socket -from struct import pack, unpack, unpack_from -import re -import time -import pickle - -import globals - -#======================================================================= -# Connect to the app log -#======================================================================= -log = logging.getLogger('__main__.' + __name__) - -Test_Data_Back_UPS_RS_1500 = [ - "APC : 001,037,0906", - "DATE : Sun Apr 26 17:22:22 EDT 2009", - "HOSTNAME : mail.kroptech.com", - "VERSION : 3.14.2 (10 September 2007) redhat", - "UPSNAME : ups0", - "CABLE : USB Cable", - "MODEL : Back-UPS RS 1500", - "UPSMODE : Stand Alone", - "STARTTIME: Sun Apr 26 10:22:46 EDT 2009", - "STATUS : ONLINE", - "LINEV : 123.0 Volts", - "LOADPCT : 24.0 Percent Load Capacity", - "BCHARGE : 100.0 Percent", - "TIMELEFT : 144.5 Minutes", - "MBATTCHG : 5 Percent", - "MINTIMEL : 3 Minutes", - "MAXTIME : 0 Seconds", - "SENSE : Medium", - "LOTRANS : 097.0 Volts", - "HITRANS : 138.0 Volts", - "ALARMDEL : Always", - "BATTV : 26.8 Volts", - "LASTXFER : Low line voltage", - "NUMXFERS : 0", - "TONBATT : 0 seconds", - "CUMONBATT: 0 seconds", - "XOFFBATT : N/A", - "SELFTEST : NO", - "STATFLAG : 0x07000008 Status Flag", - "MANDATE : 2003-05-08", - "SERIALNO : JB0319033692", - "BATTDATE : 2001-09-25", - "NOMINV : 120", - "NOMBATTV : 24.0", - "FIRMWARE : 8.g6 .D USB FW:g6", - "APCMODEL : Back-UPS RS 1500", - "END APC : Sun Apr 26 17:22:32 EDT 2009" - ] - -Test_Data_Smart_UPS_600 = [ - "APC : 001,048,1088", - "DATE : Fri Dec 03 16:49:24 EST 1999", - "HOSTNAME : daughter", - "RELEASE : 3.7.2", - "CABLE : APC Cable 940-0024C", - "MODEL : APC Smart-UPS 600", - "UPSMODE : Stand Alone", - "UPSNAME : SU600", - "LINEV : 122.1 Volts", - "MAXLINEV : 123.3 Volts", - "MINLINEV : 122.1 Volts", - "LINEFREQ : 60.0 Hz", - "OUTPUTV : 122.1 Volts", - "LOADPCT : 32.7 Percent Load Capacity", - "BATTV : 26.6 Volts", - "BCHARGE : 095.0 Percent", - "MBATTCHG : 15 Percent", - "TIMELEFT : 19.0 Minutes", - "MINTIMEL : 3 Minutes", - "SENSE : Medium", - "DWAKE : 000 Seconds", - "DSHUTD : 020 Seconds", - "LOTRANS : 106.0 Volts", - "HITRANS : 129.0 Volts", - "RETPCT : 010.0 Percent", - "STATFLAG : 0x08 Status Flag", - "STATUS : ONLINE", - "ITEMP : 34.6 C Internal", - "ALARMDEL : Low Battery", - "LASTXFER : Unacceptable Utility Voltage Change", - "SELFTEST : NO", - "STESTI : 336", - "DLOWBATT : 05 Minutes", - "DIPSW : 0x00 Dip Switch", - "REG1 : N/A", - "REG2 : N/A", - "REG3 : 0x00 Register 3", - "MANDATE : 03/30/95", - "SERIALNO : 13035861", - "BATTDATE : 05/05/98", - "NOMOUTV : 115.0", - "NOMBATTV : 24.0", - "HUMIDITY : N/A", - "AMBTEMP : N/A", - "EXTBATTS : N/A", - "BADBATTS : N/A", - "FIRMWARE : N/A", - "APCMODEL : 6TD", - "END APC : Fri Dec 03 16:49:25 EST 1999", - ] - -#==================================================================================================== -# Debug load & save -#==================================================================================================== -def Save_Data( Data ): - Dict = { "raw": Data } - with open('./scrap/raw_apc.pickle', 'wb') as handle: - pickle.dump(Dict, handle, protocol=pickle.HIGHEST_PROTOCOL) - return - -def Load_Data(): - with open('./scrap/raw_apc.pickle', 'rb') as handle: - Dict = pickle.load(handle) - return Dict["raw"] - -#==================================================================================================== -# Query_APC_NIS_Socket -#==================================================================================================== -def Query_APC_NIS_Socket(Target_Address, Target_Port, Command): - Skt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - Skt.settimeout(10) - Packet = pack('>H6s', len(Command), Command) - - try: - Skt.connect( (Target_Address, Target_Port) ) - Skt.send( Packet ) - time.sleep(5.0) - Data = Skt.recv(4096 * 2) - if len(Data) == 2: - log.debug( 'Short data so retying' ) - time.sleep(1.0) - Data2 = Skt.recv(4096 * 2) - c = "2s{}s".format( len(Data2) ) - Data = pack(c, Data, Data2) - - Skt.close() - except Exception as Error: - log.error( 'Unable to connect to APC server: {}'.format(Error) ) - return False, [] - - return True, Data - -#==================================================================================================== -# Parse_Packet_To_Lines -#==================================================================================================== -def Parse_Packet_To_Lines( Byte_Data ): - if len(Byte_Data) < 3: - log.debug('Short response from server: {} bytes'.format( len(Byte_Data) ) ) - return [] - - Lines = [] - Offset = 0 - while True: - Line_Length_tup = unpack_from('>H', Byte_Data, Offset) - Offset += 2 - Line_Length = Line_Length_tup[0] - if Line_Length == 0: - break - - struct_format = '{}b'.format(Line_Length) - Byte_tup = unpack_from(struct_format, Byte_Data, Offset) - Offset += Line_Length - - Line = '' - for item in Byte_tup: - Line += chr(item) - - Lines.append( Line.rstrip('\n') ) - return Lines - -#==================================================================================================== -# Find_Vaiable_By_Name -#==================================================================================================== -def Find_Variable_By_Name( Name, Variables ): - return next((sub for sub in Variables if sub['name'] == Name), None) - -#==================================================================================================== -# Format_APC_Data -#==================================================================================================== -def Format_APC_Data( Scrape_Lines ): - Line_Pattern = re.compile("^([A-Za-z0-9 ]+)\s*:\s+(.*)$") - - Variables = [] - for Line in Scrape_Lines: - matches = re.search(Line_Pattern, Line) - if matches: - Variables.append( { - "name": matches.group(1).strip(), - "value": matches.group(2).strip() - } ) - else: - log.warning("No line match for {}".format(Line) ) - - if Version := Find_Variable_By_Name( "VERSION", Variables ): - Server_Version = Version["value"] - else: - Server_Version = "Not found" - - if Name := Find_Variable_By_Name( "UPSNAME", Variables ): - UPS_Name = Name["value"] - else: - UPS_Name = "Not found" - - UPS_Description = "N/A" - if Name := Find_Variable_By_Name( "MODEL", Variables ): - UPS_Description = Name["value"] - elif Name := Find_Variable_By_Name( "APCMODEL", Variables ): - UPS_Description = Name["value"] - else: - UPS_Description = "---" - - if Name := Find_Variable_By_Name( "HOSTNAME", Variables ): - UPS_Description += " @ " + Name["value"] - elif Name := Find_Variable_By_Name( "UPSMODE", Variables ): - UPS_Description += " @ " + Name["value"] - else: - UPS_Description += " @ ---" - - UPS = {} - UPS["name"] = UPS_Name - UPS["description"] = UPS_Description - UPS["variables"] = Variables - - Scrape_Data = {} - Scrape_Data["server_version"] = Server_Version - Scrape_Data["ups_list"] = [ UPS ] - Scrape_Data["debug"] = Scrape_Lines - - return True, Scrape_Data - -#==================================================================================================== -# Scrape entry point: Scrape_APC_Server -#==================================================================================================== -def Get_APC_Log( Target_Address, Target_Port = 3551 ): - log.debug( "Getting logs from APC server, address {} port {}".format( Target_Address, Target_Port ) ) - - Command = b'events' - Status, Byte_Data = Query_APC_NIS_Socket( Target_Address, Target_Port, Command ) - if Status: - log.debug( "APC Log retrival successful." ) - else: - log.warning( "APC Log retrival unsuccessful." ) - return False, {} - - Scrape_Lines = Parse_Packet_To_Lines( Byte_Data ) - log.debug( "Log lines:" ) - for Line in Scrape_Lines: - log.debug( " {}".format( Line) ) - - return Scrape_Lines - -#==================================================================================================== -# Scrape entry point: Scrape_APC_Server -#==================================================================================================== -def Scrape_APC_Server( Target_Address, Target_Port = 3551 ): - log.debug( "Scrape started of APC server, address {} port {}".format( Target_Address, Target_Port ) ) - - #==================================================================================== - # 1. Scrape the byte data from the server - # 2. Split the NIS format byte stream to a list of lines as strings - # 3. Parse the lines returned to make the Scrape_Data dictionary - #==================================================================================== - # Byte_Data = Load_Data() - # Status = True - Command = b'status' # b'events' - Status, Byte_Data = Query_APC_NIS_Socket( Target_Address, Target_Port, Command ) - if Status: - log.debug( "Scrape successful." ) - else: - log.warning( "Scrape unsuccessful." ) - return False, {} - - # Scrape_Lines = Test_Data_Back_UPS_RS_1500 - # Scrape_Lines = Test_Data_Smart_UPS_600 - Scrape_Lines = Parse_Packet_To_Lines( Byte_Data ) - - Status, Scrape_Data = Format_APC_Data( Scrape_Lines ) - if Status: - log.debug( "Scrape_Data constructed." ) - Scrape_Data["server_address"] = Target_Address - Scrape_Data["server_port"] = Target_Port - if globals.Config: - log.debug( "Reworking variables." ) - # rework_data.Rework_Variables( Scrape_Data, globals.Config ) - else: - log.debug( "Not reworking variables." ) - - # Save_Data( Scrape_Data ) - else: - log.warning( "Scrape unsuccessful." ) - - return Status, Scrape_Data diff --git a/app/configuration.py b/app/configuration.py deleted file mode 100644 index adacdff..0000000 --- a/app/configuration.py +++ /dev/null @@ -1,143 +0,0 @@ -import logging -import os -import yaml - -import globals - -#======================================================================= -# Connect to the app log -#======================================================================= -log = logging.getLogger('__main__.' + __name__) - -#==================================================================================================== -# Check and add variables that are requested to be reworks to a list in the reworks dictionary -# This speeds up parsing after a scrape by making a quick lookup table of vaiable names. -#==================================================================================================== -def List_Variables( Config ): - Var_List = [] - for Rework in Config['rework']: - if Rework["from"] not in Var_List: - Var_List.append( Rework["from"] ) - - Config["rework_var_list"] = Var_List - return - -#==================================================================================================== -# Load_Config -# Returns: -# True : Config passes tests -# False : Config failed tests -#==================================================================================================== -def Parse_Config( Config ): - Sections = [ 'credentials', 'rework' ] - Styles = [ 'time', 'simple-enum', 'ratio', "comp-enum" ] - - #================================================================================= - # Check information is present - #================================================================================= - if not Config: - log.debug("Control file has no directives") - return True - - if len( Config ) == 0: - log.debug("Control file has no directives length = 0") - return True - - for Section in Config: - try: - assert Section in Sections - # log.debug("Section {} found".format( Section ) ) - except AssertionError: - log.error("Unknown section type {}".format( Section )) - return False - - #================================================================================= - # Check all required fields are present - #================================================================================= - if 'credentials' in Config: - # log.debug("Checking {} credential(s)".format( len(Config['credentials']) ) ) - for Entry in Config['credentials']: - try: - assert 'device' in Entry - assert 'username' in Entry - assert 'password' in Entry - except AssertionError: - log.error("All credentials must have 'device', 'username' and 'password' {}".format( Entry )) - return False - - if 'rework' in Config: - # log.debug("Checking {} rework(s)".format( len(Config['rework']) ) ) - for Entry in Config['rework']: - try: - assert 'from' in Entry - assert 'to' in Entry - assert 'style' in Entry - assert 'control' in Entry - except AssertionError: - log.error("All rework actions must have 'from', 'to', 'style' and 'control' {}".format( Entry )) - return False - - try: - assert Entry['style'] in Styles - except AssertionError: - log.error("Unknown 'style' {}, known styles are: {}".format( Entry, ", ".join( Styles ) )) - return False - - if Entry['style'] == 'simple-enum' or Entry['style'] == 'comp-enum': - try: - assert 'from' in Entry['control'] - assert 'to' in Entry['control'] - assert 'default' in Entry['control'] - assert isinstance( Entry['control']['from'], list) - assert isinstance( Entry['control']['to'], list) - assert len( Entry['control']['from'] ) == len( Entry['control']['to'] ) - except AssertionError: - log.error("All enum style directives must have 'from', 'to' and 'default' in 'control' and 'from' and 'to' must be arrays of equal length\n{}".format( Entry )) - return False - return True - -#==================================================================================================== -# Load_Config -#==================================================================================================== -def Load_Config(): - #================================================================================= - # Find the configuration file, if present - #================================================================================= - Config_Filename = os.environ.get('CONFIG_FILE', "nutcase.yml") - Config_File = os.path.join(globals.Config_Path, Config_Filename) - - if not os.path.isfile( Config_File ): - log.debug("Config file not found {}".format( Config_File ) ) - return None - - #================================================================================= - # Load the YAML and convert to a dictionary - #================================================================================= - try: - with open(Config_File, "r") as Config_File_Handle: - try: - Config = yaml.safe_load( Config_File_Handle ) - log.info("Loaded Control_YAML from {}".format( Config_File )) - log.debug("Control_YAML:\n{}".format( Config )) - except yaml.YAMLError as Error: - log.error("Error loading Control_YAML {}".format( Error )) - return None - except Exception as Error: - log.warning("Could not open config file {}. Error: {}".format( Config_File, Error ) ) - return None - - #================================================================================= - # Do validity checking in the loaded configuration - #================================================================================= - if not Parse_Config( Config ): - log.error("Config file has issues, file ignored {}".format( Config_File ) ) - return None - - #================================================================================= - # Prepare a list of variables to be reworked in the dictionary - # This LUT speeds up parsing after a scrape. - #================================================================================= - if Config: - List_Variables( Config ) - - return Config diff --git a/app/format_to_json.py b/app/format_to_json.py deleted file mode 100644 index e424d41..0000000 --- a/app/format_to_json.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging -import json # For returning JSON structures - -#======================================================================= -# Connect to the app log -#======================================================================= -log = logging.getLogger('__main__.' + __name__) - -#==================================================================================================== -# Format_For_JSON -# Main function to return a dictionary suitable for turning in to JSON data -#==================================================================================================== -def Format_For_JSON( Scrape_Data ): - log.debug("In Format_To_JSON. Scrape_Data is:\n{}".format( Scrape_Data )) - - Output_Dict = {} - if "server_version" in Scrape_Data: - Output_Dict["server_version"] = Scrape_Data["server_version"] - if "server_address" in Scrape_Data: - Output_Dict["server_address"] = Scrape_Data["server_address"] - if "server_port" in Scrape_Data: - Output_Dict["server_port"] = Scrape_Data["server_port"] - - if "ups_list" in Scrape_Data: - for ups in Scrape_Data["ups_list"]: - Output_Dict[ ups["name"] ] = {} - Output_Dict[ ups["name"] ]["description"] = ups["description"] - for Var in ups["variables"]: - Output_Dict[ ups["name"] ][ Var["name"] ] = Var["value"] - if "clients" in ups: - Output_Dict[ ups["name"] ]["clients" ] = {} - Output_Dict[ ups["name"] ]["clients" ]["count"] = len(ups["clients"]) - Output_Dict[ ups["name"] ]["clients" ]["list"] = ups["clients"] - - log.debug("Output_Dict:\n{}".format(Output_Dict)) - return Output_Dict diff --git a/app/globals.py b/app/globals.py deleted file mode 100755 index b3c3c87..0000000 --- a/app/globals.py +++ /dev/null @@ -1,11 +0,0 @@ -#============================================================== -# Global declarations for information used in multiple modules -#============================================================== -App_Version = "0.2.2" -Config = None -Config_Path = str -Log_File = str -Log_Requests = bool -Log_Request_Debug = bool -Order_Metrics = bool -Default_Log_Lines = int diff --git a/app/http_server.py b/app/http_server.py deleted file mode 100755 index bfe6cbf..0000000 --- a/app/http_server.py +++ /dev/null @@ -1,360 +0,0 @@ -import logging -import re -import json - -from enum import Enum -from http.server import BaseHTTPRequestHandler, HTTPServer -from http import HTTPStatus -from urllib.parse import urlparse -from urllib.parse import parse_qs -from ipaddress import ip_address # For parsing target addresses - -import server_constants -import nut_server_handler -import apc_server_handler -import format_to_text -import format_to_json -import globals - -#======================================================================= -# Connect to the app log -#======================================================================= -log = logging.getLogger('__main__.' + __name__) - -#======================================================================= -# Enumerated types -#======================================================================= -Server_Protocol = Enum('Server_Protocol', ['NUT', 'APC', 'NONE']) - -#======================================================================= -# Utility to print the last n lines of a log file -#======================================================================= -def Tail_File( File_Name, Display_Lines=20): - Lines = [] - First_Line = '
'.format(server_constants.Monospace_Small_Font)
- Last_Line = '
'
-
- try:
- File_Handle = open( File_Name, 'r')
-
- Log_Pattern = re.compile("^([0-9-]+) ([0-9:,]+) (DEBUG|INFO|WARNING|ERROR|CRITICAL|FATAL) (.+)$")
- count = 0
-
- while True:
- count += 1
- line = File_Handle.readline()
- if not line:
- break
- line = line.rstrip("\n")
- match = re.search(Log_Pattern, line)
- if match:
- if match.group(3) == "DEBUG": Level_Colour = server_constants.HTML_Colour_Green
- elif match.group(3) == "INFO": Level_Colour = server_constants.HTML_Colour_LightBlue
- elif match.group(3) == "WARNING": Level_Colour = server_constants.HTML_Colour_Yellow
- elif match.group(3) == "ERROR": Level_Colour = server_constants.HTML_Colour_Red
- elif match.group(3) == "CRITICAL": Level_Colour = server_constants.HTML_Colour_DarkRed
- elif match.group(3) == "FATAL": Level_Colour = server_constants.HTML_Colour_DarkRed
- else: Level_Colour = server_constants.HTML_Colour_Blue
-
- line = '{date} {time} {level} {message}Could not open log file: {}
".format( globals.Log_File ) ) - return False, Lines - - return True, Result - -#==================================================================================================== -# The web server end point handlers -#==================================================================================================== -# Send_Text_Reply -#==================================================================================================== -def Send_Text_Reply( Channel, Scrape_Data ): - Formatted_Data = format_to_text.Format_For_Prometheus( Scrape_Data ) - - Content_Type = server_constants.Content_Types["text"] - if server_constants.Accepts_Openmetrics in Channel.headers["Accept"]: - Content_Type = server_constants.Content_Types["openmetrics"] - - Channel.send_response(200) - Channel.send_header(keyword='Content-type', value=Content_Type) - Channel.end_headers() - - Channel.wfile.write(bytes(Formatted_Data, "utf-8")) - return - -#======================================================================= -# Send_Object_As_JSON -#======================================================================= -def Send_Object_As_JSON( Channel, Data_Object, Response_Message ): - Formatted_JSON_Text = json.dumps(Data_Object) - - Channel.send_response(code=200, message=Response_Message) - Channel.send_header(keyword='Content-type', value=server_constants.Content_Types["json"]) - Channel.end_headers() - Channel.wfile.write(Formatted_JSON_Text.encode('utf-8')) - return - -#==================================================================================================== -# Send_Not_Found - If an error occurs while scraping the NUT server -#==================================================================================================== -def Send_HTML_Reply( Channel, Control ): - Channel.send_response( Control["code"], Control["message"] ) - Channel.send_header(keyword='Content-type', value=server_constants.Content_Types["html"]) - Channel.end_headers() - - Channel.wfile.write(bytes("Unknown server mode requested: {}".format( URL_Parameters["mode"][0] ) ] - Lines += server_constants.HTML_Usage - - Control = { "code": HTTPStatus.NOT_FOUND, - "message": HTTPStatus.NOT_FOUND.phrase, - "title": HTTPStatus.NOT_FOUND.phrase, - "lines": Lines - } - Send_HTML_Reply( self, Control ) - return - - #============================================================================================ - # Route to a responder based on the end point path - #============================================================================================ - if Parsed_Path.path == "/log": - Log_Lines = globals.Default_Log_Lines - if "lines" in URL_Parameters: - Log_Lines = int(URL_Parameters["lines"][0]) - - rtn, Lines = Tail_File( globals.Log_File, Log_Lines ) - - if rtn: Control = { "code": HTTPStatus.OK, "title": "Log file" } - else: Control = { "code": HTTPStatus.NOT_FOUND, "title": "Error - log file" } - Control["message"] = "Log file" - Control["lines"] = Lines - Send_HTML_Reply( self, Control ) - elif Parsed_Path.path == "/help" or Parsed_Path.path == "/" : - Control = { "code": HTTPStatus.OK, - "message": "Usage", - "title": "Usage", - "lines": server_constants.HTML_Usage - } - Send_HTML_Reply( self, Control ) - elif Parsed_Path.path == "/health": - Health_Check = { "OK": "true" } - Response_Message = "Health check" - Send_Object_As_JSON( self, Health_Check, Response_Message ) - else: - if Parsed_Path.path == "/metrics" or \ - Parsed_Path.path == "/json" or \ - Parsed_Path.path == "/raw" or \ - Parsed_Path.path == "/apclog": - - #==================================================================================== - # Get the target address and, optionally port, from the URL parameters - #==================================================================================== - Target_Resolved, Target_Address, Parameter_Port = Resolve_Address_And_Port( URL_Parameters ) - if Parameter_Port: - Target_Port = int(Parameter_Port) - - if not Target_Resolved: - Lines = [ "
Target address not resolved: {}".format( URL_Parameters ) ] - Lines += server_constants.HTML_Usage - - Control = { "code": HTTPStatus.NOT_FOUND, - "message": HTTPStatus.NOT_FOUND.phrase, - "title": HTTPStatus.NOT_FOUND.phrase, - "lines": Lines - } - Send_HTML_Reply( self, Control ) - log.warning('Target address not resolved, returned 404: {}'.format(Parsed_Path.path) ) - return - - if Parsed_Path.path == "/apclog": - Log_Lines = apc_server_handler.Get_APC_Log(Target_Address, Target_Port) - Response_Message = "Data" - Send_Object_As_JSON( self, Log_Lines, Response_Message ) - return - - if Server_Type == Server_Protocol.NUT: - Scrape_Return, Scrape_Data = nut_server_handler.Scrape_NUT_Server( Target_Address, Target_Port ) - elif Server_Type == Server_Protocol.APC: - Scrape_Return, Scrape_Data = apc_server_handler.Scrape_APC_Server( Target_Address, Target_Port ) - - if not Scrape_Return: - Control = { "code": HTTPStatus.NOT_FOUND, - "message": HTTPStatus.NOT_FOUND.phrase, - "title": HTTPStatus.NOT_FOUND.phrase, - "lines": [ "
Not found" ] - } - Send_HTML_Reply( self, Control ) - return - - if Parsed_Path.path == "/metrics": - Send_Text_Reply( self, Scrape_Data ) - elif Parsed_Path.path == "/json": - Formatted_JSON_Object = format_to_json.Format_For_JSON( Scrape_Data ) - Response_Message = "Data" - Send_Object_As_JSON( self, Formatted_JSON_Object, Response_Message ) - elif Parsed_Path.path == "/raw": - Response_Message = "Data" - Send_Object_As_JSON( self, Scrape_Data, Response_Message ) - else: - Lines = [ "
Path not found: {}".format( Parsed_Path.path ) ] - Lines += server_constants.HTML_Usage - - Control = { "code": HTTPStatus.NOT_FOUND, - "message": HTTPStatus.NOT_FOUND.phrase, - "title": HTTPStatus.NOT_FOUND.phrase, - "lines": Lines - } - Send_HTML_Reply( self, Control ) - log.warning('Returned 404: {}'.format(Parsed_Path.path) ) - return - - def log_message(self, format, *args): - if globals.Log_Requests: - log.info("%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), format%args)) - return - -#==================================================================================================== -# Launch the web browser -#==================================================================================================== -def Launch_HTTP_Server( Server_Port ): - NUTCase_WebServer = HTTPServer(('', Server_Port), NUTCaseServer) - log.info( "Server starting on port {}".format( Server_Port ) ) - - try: - NUTCase_WebServer.serve_forever() - except (KeyboardInterrupt): - log.info("User terminated program") - except Exception as Error: - log.error("HTTP Server terminated: {}".format( Error )) - - log.info( "Server stopped" ) - return diff --git a/app/nutcase.py b/app/nutcase.py deleted file mode 100755 index 1485b5f..0000000 --- a/app/nutcase.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -#================================================================================================== -# -# NUTCase -# -# A multi-purpose interface between NUT-Tools or APC Daemon servers and the Prometheus/Grafana -# data logging system and JSON consumers such as HomePage. -# -#================================================================================================== -import logging -import logging.handlers -import os - -import globals -import http_server -import configuration - -#================================================================================================== -# Set up logging objects and logging to a file and the console -#================================================================================================== -def Configure_Log(log): - try: log.setLevel( os.environ.get('LOG_LEVEL', "INFO").upper() ) - except Exception: log.setLevel( logging.DEBUG ) - - Log_Console_Handler = logging.StreamHandler() - - Log_Filename = os.environ.get('LOG_FILE', "nutcase.log") - globals.Log_File = os.path.join(globals.Config_Path, Log_Filename) - try: - Log_File_Handler = logging.handlers.RotatingFileHandler(globals.Log_File, maxBytes=250000, backupCount=5) - except Exception as Error: - log.fatal("Can't open logfile for writing: {} {}".format(globals.Log_File, Error) ) - exit(1) - - Log_Formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(module)s: %(message)s') - Log_Console_Handler.setFormatter(Log_Formatter) - Log_File_Handler.setFormatter(Log_Formatter) - - log.addHandler(Log_Console_Handler) - log.addHandler(Log_File_Handler) - return - -#================================================================================================== -# Main function -#================================================================================================== -def main(): - #==================================================================================== - # Set paths & options from environment variables - globals.Config_Path = os.environ.get('CONFIG_PATH', "/config") - globals.Default_Log_Lines = int( os.environ.get('DEFAULT_LOG_LINES', "20") ) - - if os.environ.get('LOG_REQUESTS', "True").lower() == 'true': - globals.Log_Requests = True - else: globals.Log_Requests = False - if os.environ.get('LOG_REQUESTS_DEBUG', "False").lower() == 'true': - globals.Log_Request_Debug = True - else: globals.Log_Request_Debug = False - if os.environ.get('ORDER_METRICS', "True").lower() == 'true': - globals.Order_Metrics = True - else: globals.Order_Metrics = False - - #==================================================================================== - # Set up logging to a file and the console - log = logging.getLogger() - Configure_Log( log ) - - #==================================================================================== - # Set some main values - log.info("Program starting. App version is {}, Logging level is {}".format( - globals.App_Version, logging.getLevelName(logging.root.level)) ) - Server_Port = int(os.environ.get('PORT', '9995')) - - #==================================================================================== - # Load the configuration if given - globals.Config = configuration.Load_Config() - log.debug("Configuration in use:\n{}".format( globals.Config )) - - #==================================================================================== - # Launch the web browser - http_server.Launch_HTTP_Server( Server_Port ) - - return - -#================================================================================================== -# Entry point -#================================================================================================== -if __name__ == '__main__': - main() diff --git a/app/requirements.txt b/app/requirements.txt deleted file mode 100644 index be2b74d..0000000 --- a/app/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -PyYAML==6.0.1 diff --git a/app/server_constants.py b/app/server_constants.py deleted file mode 100755 index 74c1485..0000000 --- a/app/server_constants.py +++ /dev/null @@ -1,89 +0,0 @@ -#==================================================================================================== -# HTML Font & style strings -#==================================================================================================== -Main_Font = 'font-family: Arial, Helvetica, sans-serif;' -Lucida_Font = 'font-family:Lucida Sans Unicode;' -Verdana_Font = 'font-family:Verdana;' -Lucida_Console_Font = 'font-family:Lucida Console;' -Monospace_Font = 'font-family:monospace;font-size:14px;' -Monospace_Small_Font = 'font-family:monospace;font-size:12px;' - -HTML_Colour_Blue = 'color:Blue;' -HTML_Colour_Yellow = 'color:Gold;' -HTML_Colour_Green = 'color:LimeGreen;' -HTML_Colour_DarkGreen = 'color:Green;' -HTML_Colour_Red = 'color:OrangeRed;' -HTML_Colour_DarkRed = 'color:Crimson;' -HTML_Colour_LightBlue = 'color:DarkTurquoise;' - -#==================================================================================================== -# HTML Constants -#==================================================================================================== -Accepts_Openmetrics = 'application/openmetrics-text' - -Content_Types = { - 'text': 'text/plain; charset=UTF-8', - 'html': 'text/html; charset=UTF-8', - 'json': 'application/json; charset=UTF-8', - 'openmetrics': 'application/openmetrics-text; version=1.0.0; charset=UTF-8', -} - -#==================================================================================================== -# HTML Response lines -#==================================================================================================== -Title_Colour = HTML_Colour_DarkGreen -Line_Colour = HTML_Colour_Blue - -HTML_Usage = [ - '
Usage examples:
'.format( Main_Font ), - - 'Valid end-points (paths).
'.format( Main_Font, Title_Colour ), - ''.format( Monospace_Font, Line_Colour ), - '/help Returns this message.
', - '/log Displays the last log lines
', - '/health Returns a JSON structure to confirm NUTCase is alive.
', - '/metrics Returns text output compatible with Prometheus scraping.
', - '/json Returns JSON output compatible with HomePage and Webhooks.
', - '/raw Returns JSON output suitable only for diagnostics.
', - '/apclog Displays the event log of an APC server.
', - '
', - - 'Valid parameters.
'.format( Main_Font, Title_Colour ), - ''.format( Monospace_Font, Line_Colour ), - 'lines Sets the number of lines to display. Only valid with /log
', - 'mode Optional. Specifies apc or nut mode. Default is nut.
', - 'target Specifies address and, optionally, port of the server.
', - 'addr Specifies address of the server as an alternative to target.
', - 'port Overrides the server default port if usering addr
', - '
', - - 'Specifying the server parameters.
'.format( Main_Font, Title_Colour ), - ' Note: this is valid for end-points json, raw & metrics
'.format( Main_Font, Title_Colour ), - ''.format( Monospace_Font, Line_Colour ), - '/metrics?target=A.B.C.D Specify the address of the server, assuming the default port.
', - '/metrics?target=A.B.C.D:P Specify the address of the server and the port (p).
', - '/metrics?addr=A.B.C.D Specify the address of the server, assuming the default port.
', - '/metrics?addr=A.B.C.D&port=P Specify the address of the server and the port (P).
', - '
', - - 'Specifying an APC server parameters.
'.format( Main_Font, Title_Colour ), - ' Note: this is valid for end-points json & raw. All other combinations of target, addr & port (above) are valid
'.format( Main_Font, Title_Colour ), - ' All other combinations of target, addr & port (above) are valid
'.format( Main_Font, Title_Colour ), - ' mode=nut is also valid for NUT servers but is the default and need not be given.
'.format( Main_Font, Title_Colour ), - ''.format( Monospace_Font, Line_Colour ), - '/json?mode=apc&target=A.B.C.D Specify the address of the server, assuming the default port.
', - '
', - - 'View the log file.
'.format( Main_Font, Title_Colour ), - ''.format( Monospace_Font, Line_Colour ), - '/log View the the last default (20) number of lines from the log.
', - '/log?lines=30 View the the last Specifyed number of lines from the log.
', - '
', - - 'For example, if your NUT server is at 10.0.20.50, serving on the default port (3493) and nutcase is on address 10.0.20.40 on the default port (9995) then to get data for Prometheus enter:
'.format( Main_Font, Title_Colour ), - ''.format( Monospace_Font, Line_Colour ), - 'http://10.0.20.40:9995/metrics?target=10.0.20.50:3493', - '
', - - '
For details go to NUTCase on GitHub
'.format( Main_Font ), -] diff --git a/docker_run.sh b/docker_run.sh deleted file mode 100755 index a04e6dd..0000000 --- a/docker_run.sh +++ /dev/null @@ -1,7 +0,0 @@ -docker run -d \ - --name=Nutcase \ - -e TZ=Europe/London \ - -p 9995:9995 \ - -v /home/arthurm/Documents/Software/nut_export/nutcase/config:/config \ - --restart unless-stopped \ - kronos443/nutcase:test diff --git a/nutcase/app/app/__init__.py b/nutcase/app/app/__init__.py new file mode 100755 index 0000000..7070532 --- /dev/null +++ b/nutcase/app/app/__init__.py @@ -0,0 +1,73 @@ +import logging +# from logging.handlers import SMTPHandler, RotatingFileHandler +import os +from flask import Flask +from config import Config_Development # , Config_Production, Config + +from app.utils import webhook +from app.utils import configuration +from app.utils import app_log_config + +#================================================= +# Initialise components + +#================================================================================================== +# Main app creation function +#================================================================================================== +def create_app(config_class=Config_Development): + app = Flask(__name__) + app.config.from_object(config_class) + + #==================================================================================== + # Register the app Blueprints + #==================================================================================== + from app.main import bp as main_bp + app.register_blueprint(main_bp) + + from app.api import bp as api_bp + app.register_blueprint(api_bp, url_prefix='/api') + + from app.events import bp as events_bp + app.register_blueprint(events_bp, url_prefix='/events') + + #==================================================================================== + # Set up the application logging + #==================================================================================== + app_log_config.Add_Logging_Levels() + + Logfile_Directory = os.path.join(app.config['CONFIG_PATH'], app.config['LOGFILE_SUBPATH']) + if not os.path.exists(Logfile_Directory): + os.mkdir(Logfile_Directory) + + app_log_config.Add_RF_Handler( app ) + + #==================================================================================== + # Set the logging level from the environment variable LOG_LEVEL if present. + #==================================================================================== + Console_Level = os.environ.get('LOG_LEVEL', app.config['DEFAULT_CONSOLE_LEVEL']).upper() + Logfile_Level = os.environ.get('LOG_LEVEL', app.config['DEFAULT_LOGFILE_LEVEL']).upper() + + app.logger.info("Init: Console_Level {} Logfile_Level {}".format( Console_Level, Logfile_Level )) + app_log_config.Set_Log_Level( app, Console_Level, "con" ) + app_log_config.Set_Log_Level( app, Logfile_Level, "rfh" ) + app.logger.setLevel( 1 ) # Set the root logger to pass everything on + + #==================================================================================== + # Load the app configuration from a YAML file + #==================================================================================== + configuration.Load_Config( app ) + + #==================================================================================== + # Log starting and call a web hook + #==================================================================================== + app.logger.info("{} starting. Version {}, Logging: console {} logfile {}".format( + app.config['APP_NAME'], app.config['APP_VERSION'], + logging.getLevelName( app_log_config.Get_Handler( app, 'con' ).level), + logging.getLevelName( app_log_config.Get_Handler( app, 'rfh' ).level), + )) + + webhook.Call_Webhook( app, "ok", { "status": "up", "msg": "NUTCase starting" } ) + + return app + +from app import models diff --git a/nutcase/app/app/api/__init__.py b/nutcase/app/app/api/__init__.py new file mode 100755 index 0000000..fac61f0 --- /dev/null +++ b/nutcase/app/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('api', __name__) + +from app.api import routes diff --git a/nutcase/app/app/api/routes.py b/nutcase/app/app/api/routes.py new file mode 100755 index 0000000..f83643f --- /dev/null +++ b/nutcase/app/app/api/routes.py @@ -0,0 +1,60 @@ +from flask import current_app, request + +from app.api import bp + +from app.utils import scrape +from app.utils import gui_data_format + +#==================================================================================== +# Serve the end-point /api/status +#==================================================================================== +@bp.route('/status') +def route_status(): + Result = {} + + Args = dict(request.args) + + Device = request.args.get('dev') + if not Device: + current_app.logger.debug("No device in args") + return Result + + Scrape_Return, Scrape_Data = scrape.Get_Scrape_Data( Args ) + if not Scrape_Return: + current_app.logger.debug("No scrape data") + return Result + + Result = gui_data_format.Process_Data_For_GUI( Scrape_Data, Device ) + return Result + +#==================================================================================== +# Serve the end-point /api/status +#==================================================================================== +@bp.route('/devices') +def route_devices(): + Result = {} + Device = request.args.get('dev') + Addr = request.args.get('addr') + + Result = gui_data_format.Process_Device_Pulldown( Addr, Device, Result ) + return Result + +#==================================================================================== +# Serve the end-point /api/default - Returns JSON for the default (or last) server +# in the config file server section. +#==================================================================================== +@bp.route('/default') +def route_default(): + Result = {} + + if len( current_app.config['SERVERS'] ) == 0: + return Result + + for s in current_app.config['SERVERS']: + Result['addr'] = s['server'] + Result['port'] = s['port'] + Result['device'] = s['device'] + if 'default' in s: + break + + return Result diff --git a/nutcase/app/app/cli.py b/nutcase/app/app/cli.py new file mode 100755 index 0000000..88a4607 --- /dev/null +++ b/nutcase/app/app/cli.py @@ -0,0 +1,8 @@ +#import os +#import click + +def register(app): + @app.cli.group() + def translate(): + """Translation and localization commands.""" + pass diff --git a/nutcase/app/app/events/__init__.py b/nutcase/app/app/events/__init__.py new file mode 100755 index 0000000..0dbe539 --- /dev/null +++ b/nutcase/app/app/events/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('events', __name__) + +from app.events import routes diff --git a/nutcase/app/app/events/forms.py b/nutcase/app/app/events/forms.py new file mode 100755 index 0000000..60cc3ce --- /dev/null +++ b/nutcase/app/app/events/forms.py @@ -0,0 +1,25 @@ +from flask_wtf import FlaskForm +# StringField, SelectField, SubmitField, IntegerField, BooleanField # TextAreaField +from wtforms import SelectField, SubmitField +# from wtforms.validators import ValidationError, DataRequired, Length + +class Events_Form(FlaskForm): + Event_Level = SelectField(u'Level', choices=[ + (20, "Info"), + (30, "Warning"), + (40, "Alert"), + ], + # default=('alert', "Alert") + coerce=int + ) + + Lines_Per_Page = SelectField(u'Lines Per Page', choices=[ + ( 10, "10"), + ( 20, "20"), + ( 50, "50"), + (100, "100"), + ( 0, "All") + ], coerce=int) + + submit = SubmitField('Clear') + diff --git a/nutcase/app/app/events/log_utils.py b/nutcase/app/app/events/log_utils.py new file mode 100644 index 0000000..d7248bd --- /dev/null +++ b/nutcase/app/app/events/log_utils.py @@ -0,0 +1,167 @@ +from flask import current_app + +#==================================================================================== +# Event table prototype HTML +#==================================================================================== +Table_Header = """ + +Log file not found" ] + + return render_template('main/log.html', title="Log file", lines=Lines, files=Markup(File_List), filename=Filename ) + +#==================================================================================== +# Serve the end-point /help +#==================================================================================== +@bp.route('/help') +def route_help(): + return render_template('main/help.html', title="Help" ) + +#==================================================================================== +# Serve the end-point /health +#==================================================================================== +@bp.route('/health') +def route_health(): + Health = { "OK": "true" } + Formatted_JSON_Text = json.dumps(Health) + Content_Type = server_constants.Content_Types["json"] + r = Response(response=Formatted_JSON_Text, status=HTTPStatus.OK, mimetype=Content_Type) + return r + +#==================================================================================== +# Serve the end-point /apclog +#==================================================================================== +@bp.route('/apclog') +def route_apclog(): + URL_Parameters = dict (request.args) + + Server_Type, Target_Port = scrape.Check_Mode( URL_Parameters ) + + if not Server_Type: + r = Response(response=HTTPStatus.NOT_FOUND.phrase, status=HTTPStatus.NOT_FOUND) + return r + + Target_Resolved, Target_Address, Parameter_Port = scrape.Resolve_Address_And_Port( URL_Parameters ) + if Parameter_Port: + Target_Port = Parameter_Port + + if not Target_Resolved: + r = Response(response=HTTPStatus.NOT_FOUND.phrase, status=HTTPStatus.NOT_FOUND) + return r + + Log_Lines = apc_server_handler.Get_APC_Log(Target_Address, Target_Port) + return render_template('main/apclog.html', lines=Log_Lines ) + +#==================================================================================== +# Serve the end-point /download +#==================================================================================== +@bp.route('/download', methods=['GET', 'POST']) +@bp.route('/download/
`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `