diff --git a/alerts/dummy.py b/alerts/dummy.py new file mode 100644 index 0000000..7dacca3 --- /dev/null +++ b/alerts/dummy.py @@ -0,0 +1,20 @@ +from helpers.alert_manager import AlertProvider +from package import BETA +from baps_types.alert import WARNING, Alert +# Dummy alert provider for testing basics like UI without needing to actually cause errors. + + +class DummyAlertProvider(AlertProvider): + + def get_alerts(self): + if BETA: + return [Alert( + { + "start_time": -1, + "id": "test", + "title": "BAPSicle is in Debug Mode", + "description": "This is a test alert. It will not appear on production builds.", + "module": "Test", + "severity": WARNING + } + )] diff --git a/alerts/player.py b/alerts/player.py new file mode 100644 index 0000000..9ed9da8 --- /dev/null +++ b/alerts/player.py @@ -0,0 +1,99 @@ +# Any alerts produced by the player.py instances. +import json +from typing import Any, Dict, List, Optional +from datetime import datetime, timedelta +from helpers.os_environment import resolve_external_file_path +from helpers.alert_manager import AlertProvider +from baps_types.alert import CRITICAL, WARNING, Alert +from baps_types.happytime import happytime + +MODULE = "Player" # This should match the log file, so the UI will link to the logs page. + + +class PlayerAlertProvider(AlertProvider): + + _server_state: Dict[str, Any] + _states: List[Optional[Dict[str, Any]]] = [] + _player_count: int + + def __init__(self): + # Player count only changes after server restart, may as well just load this once. + with open(resolve_external_file_path("state/BAPSicleServer.json")) as file: + self._server_state = json.loads(file.read()) + + self._player_count = int(self._server_state["num_channels"]) + self._states = [None] * self._player_count + + # To simplify monitoring (and allow detection of things going super + # weird), we are going to read from the state file to work out the alerts. + def get_alerts(self): + for channel in range(self._player_count): + with open(resolve_external_file_path("state/Player{}.json".format(channel))) as file: + self._states[channel] = json.loads(file.read()) + + funcs = [self._channel_count, self._initialised, self._start_time] + + alerts: List[Alert] = [] + + for func in funcs: + func_alerts = func() + if func_alerts: + alerts.extend(func_alerts) + + return alerts + + def _channel_count(self): + if self._player_count <= 0: + return [Alert({ + "start_time": -1, # Now + "id": "no_channels", + "title": "There are no players configured.", + "description": "The number of channels configured is {}. \ + Please set to at least 1 on the 'Server Config' page." + .format(self._player_count), + "module": MODULE+"Handler", + "severity": CRITICAL + })] + + def _initialised(self): + alerts: List[Alert] = [] + for channel in range(self._player_count): + if self._states[channel] and not self._states[channel]["initialised"]: + alerts.append(Alert({ + "start_time": -1, # Now + "id": "player_{}_not_initialised".format(channel), + "title": "Player {} is not initialised.".format(channel), + "description": "This typically means the player channel was not able find the configured sound output \ + on the system. Please check the 'Player Config' and Player logs to determine the cause.", + "module": MODULE+str(channel), + "severity": CRITICAL + })) + return alerts + + def _start_time(self): + server_start_time = self._server_state["start_time"] + server_start_time = datetime.fromtimestamp(server_start_time) + delta = timedelta( + seconds=30, + ) + + alerts: List[Alert] = [] + for channel in range(self._player_count): + start_time = self._states[channel]["start_time"] + start_time = datetime.fromtimestamp(start_time) + if (start_time > server_start_time + delta): + alerts.append(Alert({ + "start_time": -1, + "id": "player_{}_restarted".format(channel), + "title": "Player {} restarted after the server started.".format(channel), + "description": + """Player {} last restarted at {}, after the server first started at {}, suggesting a failure. + +This likely means there was an unhandled exception in the player code, causing the server to restart the player. + +Please check player logs to investigate the cause. Please restart the server to clear this warning.""" + .format(channel, happytime(start_time), happytime(server_start_time)), + "module": MODULE+str(channel), + "severity": WARNING + })) + return alerts diff --git a/alerts/server.py b/alerts/server.py new file mode 100644 index 0000000..d89e186 --- /dev/null +++ b/alerts/server.py @@ -0,0 +1,77 @@ +# Any alerts produced by the server.py layer. This likely means BIG issues. +import json +from typing import Any, Dict, List +from datetime import datetime, timedelta +from helpers.os_environment import resolve_external_file_path +from helpers.alert_manager import AlertProvider +from baps_types.alert import CRITICAL, WARNING, Alert +from baps_types.happytime import happytime + +MODULE = "BAPSicleServer" # This should match the log file, so the UI will link to the logs page. + + +class ServerAlertProvider(AlertProvider): + + _state: Dict[str, Any] + # To simplify monitoring (and allow detection of things going super + # weird), we are going to read from the state file to work out the alerts. + + def get_alerts(self): + with open(resolve_external_file_path("state/BAPSicleServer.json")) as file: + self._state = json.loads(file.read()) + + funcs = [self._api_key, self._start_time] + + alerts: List[Alert] = [] + + for func in funcs: + func_alerts = func() + if func_alerts: + alerts.extend(func_alerts) + + return alerts + + def _api_key(self): + if not self._state["myradio_api_key"]: + return [Alert({ + "start_time": -1, # Now + "id": "api_key_missing", + "title": "MyRadio API Key is not configured.", + "description": "This means you will be unable to load show plans, audio items, or tracklist. \ + Please set one on the 'Server Config' page.", + "module": MODULE, + "severity": CRITICAL + })] + + if len(self._state["myradio_api_key"]) < 10: + return [Alert({ + "start_time": -1, + "id": "api_key_missing", + "title": "MyRadio API Key seems incorrect.", + "description": "The API key is less than 10 characters, it's probably not a valid one. \ + If it is valid, it shouldn't be.", + "module": MODULE, + "severity": WARNING + })] + + def _start_time(self): + start_time = self._state["start_time"] + start_time = datetime.fromtimestamp(start_time) + delta = timedelta( + days=1, + ) + if (start_time + delta > datetime.now()): + return [Alert({ + "start_time": -1, + "id": "server_restarted", + "title": "BAPSicle restarted recently.", + "description": + """The BAPSicle server restarted at {}, less than a day ago. + +It may have been automatically restarted by the OS. + +If this is not expected, please check logs to investigate why BAPSicle restarted/crashed.""" + .format(happytime(start_time)), + "module": MODULE, + "severity": WARNING + })] diff --git a/baps_types/alert.py b/baps_types/alert.py new file mode 100644 index 0000000..33ddd76 --- /dev/null +++ b/baps_types/alert.py @@ -0,0 +1,69 @@ +from typing import Any, Dict, Optional +from datetime import datetime + +CRITICAL = "Critical" +WARNING = "Warning" + + +class Alert: + start_time: datetime + last_time: datetime + end_time: Optional[datetime] + id: str + title: str + description: str + module: str + severity: str + + @property + def ui_class(self) -> str: + if self.severity == CRITICAL: + return "danger" + if self.severity == WARNING: + return "warning" + return "info" + + # This alert has happened again. + def reoccured(self): + self.last_time = datetime.now() + self.end_time = None + + # This alert has finished, just update end time and keep last_time. + def cleared(self): + self.end_time = datetime.now() + + @property + def __dict__(self): + attrs = ["start_time", "last_time", "end_time", "id", "title", "description", "module", "severity"] + out = {} + for attr in attrs: + out[attr] = self.__getattribute__(attr) + + return out + + def __init__(self, new_data: Dict[str, Any]): + required_vars = [ + "start_time", # Just in case an alert wants to show starting earlier than it is reported. + "id", + "title", + "description", + "module", + "severity" + ] + + for key in required_vars: + if key not in new_data.keys(): + raise KeyError("Key {} is missing from data to create Alert.".format(key)) + + # if type(new_data[key]) != type(getattr(self,key)): + # raise TypeError("Key {} has type {}, was expecting {}." + # .format(key, type(new_data[key]), type(getattr(self,key)))) + + # Account for if the creator didn't want to set a custom time. + if key == "start_time" and new_data[key] == -1: + new_data[key] = datetime.now() + + setattr(self, key, new_data[key]) + + self.last_time = self.start_time + self.end_time = None diff --git a/baps_types/happytime.py b/baps_types/happytime.py new file mode 100644 index 0000000..9fb6426 --- /dev/null +++ b/baps_types/happytime.py @@ -0,0 +1,5 @@ +from datetime import datetime + + +def happytime(date: datetime): + return date.strftime("%Y-%m-%d %H:%M:%S") diff --git a/helpers/alert_manager.py b/helpers/alert_manager.py new file mode 100644 index 0000000..b2e2775 --- /dev/null +++ b/helpers/alert_manager.py @@ -0,0 +1,97 @@ +from typing import Any, List, Optional + +# Magic for importing alert providers from alerts directory. +from pkgutil import iter_modules +from importlib import import_module +from inspect import getmembers, isclass +from sys import modules + +from baps_types.alert import Alert +import alerts + + +def iter_namespace(ns_pkg): + # Specifying the second argument (prefix) to iter_modules makes the + # returned name an absolute name instead of a relative one. This allows + # import_module to work without having to do additional modification to + # the name. + return iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".") + + +class AlertProvider(): + + def __init__(self): + return None + + def get_alerts(self): + return [] + + +class AlertManager(): + _alerts: List[Alert] + _providers: List[AlertProvider] = [] + + def __init__(self): + self._alerts = [] + + # Find all the alert providers from the /alerts/ directory. + providers = { + name: import_module(name) + for _, name, _ + in iter_namespace(alerts) + } + + for provider in providers: + classes: List[Any] = [ + mem[1] for mem in getmembers( + modules[provider], + isclass) if mem[1].__module__ == modules[provider].__name__] + + if (len(classes) != 1): + print(classes) + raise Exception("Can't import plugin " + provider + " because it doesn't have 1 class.") + + self._providers.append(classes[0]()) + + print("Discovered alert providers: ", self._providers) + + def poll_alerts(self): + + # Poll modules for any alerts. + new_alerts: List[Optional[Alert]] = [] + for provider in self._providers: + provider_alerts = provider.get_alerts() + if provider_alerts: + new_alerts.extend(provider_alerts) + + # Here we replace new firing alerts with older ones, to keep any context. + # (This doesn't do much yet really, just remembering the start_time) + for existing in self._alerts: + found = False + for new in new_alerts: + # given we're removing alerts, got to skip any we removed. + if not new: + continue + + if existing.id == new.id: + # Alert is continuing. Replace it with the old one. + index = new_alerts.index(new) + existing.reoccured() + new_alerts[index] = None # We're going to merge the existing and new, so clear the new one out. + found = True + break + if not found: + # The existing alert is gone, mark it as ended. + existing.cleared() + + self._alerts.extend([value for value in new_alerts if value]) # Remove any nulled out new alerts + + @property + def alerts_current(self): + self.poll_alerts() + return [alert for alert in self._alerts if not alert.end_time] + + @property + def alerts_previous(self): + self.poll_alerts() + return [alert for alert in self._alerts if alert.end_time] diff --git a/player.py b/player.py index 2120241..7e3411e 100644 --- a/player.py +++ b/player.py @@ -35,6 +35,7 @@ from pygame import mixer from mutagen.mp3 import MP3 from syncer import sync from threading import Timer +from datetime import datetime from helpers.normalisation import get_normalised_filename_if_available, get_original_filename_from_normalised from helpers.myradio_api import MyRadioAPI @@ -1002,7 +1003,7 @@ class Player: self.out_q = out_q self.logger = LoggingManager( - "Player" + str(channel), debug=package.build_beta) + "Player" + str(channel), debug=package.BETA) self.api = MyRadioAPI(self.logger, server_state) @@ -1013,6 +1014,8 @@ class Player: self.__rate_limited_params, ) + self.state.update("start_time", datetime.now().timestamp()) + self.state.add_callback(self._send_status) self.state.update("channel", channel) diff --git a/server.py b/server.py index 83a80d0..82dde6e 100644 --- a/server.py +++ b/server.py @@ -12,6 +12,7 @@ Date: October, November 2020 """ +from datetime import datetime from file_manager import FileManager import multiprocessing from multiprocessing.queues import Queue @@ -224,6 +225,7 @@ class BAPSicleServer: ) self.state.update("running_state", "running") + self.state.update("start_time", datetime.now().timestamp()) print("Launching BAPSicle...") diff --git a/ui-templates/404.html b/ui-templates/404.html deleted file mode 100644 index 5c3bae0..0000000 --- a/ui-templates/404.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'base.html' %} -{% block content_inner %} -
-{% endblock %} diff --git a/ui-templates/alerts.html b/ui-templates/alerts.html new file mode 100644 index 0000000..c90fd60 --- /dev/null +++ b/ui-templates/alerts.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% from 'parts/alert_list.html' import alert_list %} +{% block head %} + +{% endblock %} +{% block content_inner %} + {% if data %} +
{% for channel in data.channels %}
- Player {{channel.channel}}:
- {% if channel.output %}
- {{channel.output}}
+ Player {{loop.index0}}:
+ {% if channel %}
+ {% if channel.output %}
+ {{channel.output}}
+ {% else %}
+ Default Audio Device
+ {% endif %}
+ {% if not channel.initialised %}
+ BAPSicle is having some issues. Please review the alerts page.
+ {% else %}
+ BAPSicle seems to be running well. If this doesn't seem to be the case, try restarting or taking a closer look at the logs. Version: {{data.server_version}} - Build: {{data.server_build}} - Branch: {{data.server_branch}} Server Name: {{data.server_name}} {{ alert.description | replace("\n\n", " ") | replace("\n", " - ERROR: Player did not start, potentially configured sound output is missing? Check Alerts.
+ {% endif %}
{% else %}
- Default Audio Device
- {% endif %}
- {% if not channel.initialised %}
- - ERROR: Player did not start, potentially missing sound output?
+ - ERROR: Player did not respond, likely it is dead :/ Check Alerts.
{% endif %}
{% endfor %}
Set for:
-{% for channel in data.channels %}
+{% for channel in data.channels if channel %}
Player {{channel.channel}} /
{% endfor %}
Default Audio Output
@@ -37,7 +41,9 @@ Default Audio Output
{% if host_api.usable %}
Set for:
{% for channel in data.channels %}
- {% if channel.output == output.name %}
+ {% if not channel %}
+ Player {{loop.index0}}
+ {% elif channel.output == output.name %}
Player {{channel.channel}}
{% else %}
Player {{channel.channel}}
diff --git a/ui-templates/error.html b/ui-templates/error.html
new file mode 100644
index 0000000..00730d2
--- /dev/null
+++ b/ui-templates/error.html
@@ -0,0 +1,9 @@
+{% extends 'base.html' %}
+{% block content_inner %}
+
+{% endblock %}
diff --git a/ui-templates/index.html b/ui-templates/index.html
index 53c5aa4..52ebd66 100644
--- a/ui-templates/index.html
+++ b/ui-templates/index.html
@@ -1,4 +1,7 @@
{% extends 'base.html' %}
+{% block head %}
+
+{% endblock %}
{% block content %}
+ {% if data.alert_count > 0 %}
+ Something's up! {{data.alert_count}}View Alerts
+ We're all good!
+
+ Logs
+ Restart
+ Quit
+
{{ alert.title }}
+ Since {{ alert.start_time | happytime }}
+ Last Seen {{ alert.last_time | happytime }}
+ {% if alert.end_time %}
+ Ended {{ alert.end_time | happytime }}
+ {% endif %}
+
")}}