├── .flake8
├── .gitignore
├── LICENSE
├── README.md
├── beholder
├── __init__.py
├── __main__.py
├── common
│ ├── __init__.py
│ └── formatter.py
├── facade
│ ├── __init__.py
│ └── dashboard_facade.py
├── service
│ ├── __init__.py
│ ├── cpu_info_service.py
│ ├── info_service.py
│ ├── memory_info_service.py
│ ├── network_info_service.py
│ ├── service_info_service.py
│ ├── storage_info_service.py
│ ├── system_info_service.py
│ └── temperature_info_service.py
├── static
│ ├── css
│ │ ├── beholder.css
│ │ ├── bootstrap.dark.min.css
│ │ └── bootstrap.light.min.css
│ └── js
│ │ ├── beholder.js
│ │ ├── bootstrap.bundle.min.js
│ │ └── masonry.min.js
└── templates
│ ├── dashboard.html
│ ├── index.html
│ └── widget
│ ├── cpu_info.html
│ ├── mem_info.html
│ ├── network_info.html
│ ├── services_info.html
│ ├── storage_info.html
│ ├── sys_info.html
│ └── temperature_info.html
├── images
├── beholder_dark_screenshot.png
├── beholder_light_screenshot.png
└── paypal_button.png
├── poetry.lock
├── pyproject.toml
└── tests
├── __init__.py
├── common
├── __init__.py
└── test_formatter.py
├── facade
├── __init__.py
└── test_dashboard_facade.py
└── service
├── __init__.py
├── test_cpu_info_service.py
├── test_memory_info_service.py
├── test_network_info_service.py
├── test_service_info.py
├── test_storage_info_service.py
├── test_system_info_service.py
└── test_temperature_info_service.py
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 |
3 | max-line-length=120
4 |
5 | color=auto
6 |
7 | count=True
8 |
9 | statistics=True
10 |
11 | exclude=
12 | # Ignoring git repository.
13 | .git,
14 |
15 | # Ignoring pycache.
16 | __pycache__,
17 |
18 | # Ignoring IntelliJ idea settings.
19 | .idea,
20 |
21 | # Ignoring integration tests.
22 | features,
23 |
24 | # Ignoring fixtures.
25 | tests/fixtures
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | **/*.pyc
2 | **/__pycache__
3 |
4 | **/*.iml
5 | **/.idea
6 | **/.Makefile.swp
7 | **/*.swp
8 |
9 | **/coverage
10 | **/.coverage
11 | **/test_report.*
12 | **/.pytest_cache
13 | **/lint
14 |
15 | **/*.pem
16 |
17 | **/.vscode
18 | **/.venv
19 | venv
20 | **/dist
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 NullPy Consulting Ltd (Ireland)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Beholder
2 |
3 | ## 1. Introduction.
4 |
5 | **Beholder** is a simple, minimalist and responsive dashboard with basic system information fully implemented in Python
6 | and intended to be used on low resource nodes (e.g.: single board computers) running any Linux distro.
7 | The highlighted features are:
8 |
9 | * Node basic information (hostname, kernel version, locale, timezone, up time, etc.)
10 | * CPU summary and load.
11 | * Physical and SWAP memory usage.
12 | * Storage details and usage.
13 | * Customizable service watch list.
14 | * Network interfaces summary and status.
15 | * Sensors temperature with temperature bands (when available).
16 | * Automatic support to light and dark themes.
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Dark Mode
31 |
32 |
33 | Light Mode
34 |
35 |
36 |
37 |
38 |
39 | ## 2. Usage.
40 |
41 | The following sections will explain how to install and use the application.
42 |
43 | ### 2.1. Cloning and building the application locally.
44 |
45 | In order to clone the repository, build the application locally and install it, your local environment will required:
46 |
47 | | Component | Version | Why do you need it |
48 | |-----------|:-------:|--------------------|
49 | | Python | \>= 3.8 | Python runtime is required to run the application locally. |
50 | | Poetry | \>= 1.4.2 | Poetry manages the project dependency and is used to test and build the application. |
51 | | PIP | >= 22.3.1 | PIP will be used to install the dependencies.
52 |
53 | The installation of the aforementioned components are out of the scope of this documentation and guides to do it can
54 | easily be found on-line. No special settings are required.
55 |
56 | After have the local environment installed and configured, you can clone this repository with the following command:
57 |
58 | ```bash
59 | git clone git@github.com:NullPyDev/beholder.git
60 | ```
61 |
62 | From the ```/beholder``` root path, install all the project dependencies with the following command:
63 |
64 | ```bash
65 | poetry install
66 | ```
67 |
68 | Run the test for a quick sanity check:
69 |
70 | ```bash
71 | poetry run task test
72 | ```
73 |
74 | Build the library locally:
75 |
76 | ```bash
77 | poetry build
78 | ```
79 |
80 | Install it using **PIP**:
81 |
82 | ```bash
83 | pip install dist/beholder-0.1.0-py3-none-any.whl
84 | ```
85 |
86 | Finally, you can start the application with the following command:
87 |
88 | ```bash
89 | python -m beholder
90 | ```
91 |
92 | You can test the application using the address [http://127.0.0.1:2312](http://127.0.0.1:2312).
93 |
94 | ### 2.2 Configuring the application.
95 |
96 | All the configurations are done through environment variables, which are:
97 |
98 | | Variable | Description | Default Value |
99 | |----------|---------------------------------------------------------------------------------------------------------------------|:-------------:|
100 | | BEHOLDER_HOST | Ip address of the interface used to listen for incoming connections. | 0.0.0.0 |
101 | | BEHOLDER_PORT | TCP port used to listen for incoming connections. | 2312 |
102 | | BEHOLDER_SERVICES_WATCHLIST | Commad separated list with the names of services to have its status watched by the application. | None |
103 | | BEHOLDER_TEMPERATURE_SCALE | Temperature scale ([C]elsius or [F]ahrenheit) to be used to display the temperatures probed from available sensors. | C |
104 |
105 | For example, lets start the application listening on port **8080**, watch the services ```crond```, ```cupsd``` and
106 | ```sshd```, and display the temperatures using the Fahrenheit scale. We can use the following command to achieve that:
107 |
108 | ```bash
109 | BEHOLDER_PORT=8080 BEHOLDER_SERVICES_WATCHLIST="crond,cupsd,ssh" BEHOLDER_TEMPERATURE_SCALE="F" python -m beholder
110 | ```
111 |
112 | ## 3. Development.
113 |
114 | The development environment requires the same components from the step ```2.1 Cloning and building the application locally```.
115 | [Poetry](https://python-poetry.org) is used to manage the project dependencies and automate common task during the development life cycle.
116 |
117 | ### 3.1 Installing all the required dependencies.
118 |
119 | To install all the required dependencies to run and develop the application locally, we can use the following command:
120 |
121 | ```bash
122 | poetry install
123 | ```
124 |
125 | It will create a local virtual environment and install all the required dependencies in the appropriated version.
126 |
127 | ### 3.2 Running the application on development mode.
128 |
129 | In development mode, the application automatically reloads any changes made in the source (python, html, css, js). To
130 | run the application in development mode we can use the following command:
131 |
132 | ```bash
133 | poetry run task beholder
134 | ```
135 |
136 | Or alternatively...
137 |
138 | ```bash
139 | poetry run flask --debug --app beholder/__main__.py run
140 | ```
141 |
142 | It will start the application locally listening on the TCP port ```5000```.
143 |
144 | ### 3.3 Run unit tests.
145 |
146 | The application is covered by unit tests, and we have the **commitment to keep its coverage always over 90%**. To run
147 | all the unit tests, we can use the following command:
148 |
149 | ```bash
150 | poetry run task test
151 | ```
152 |
153 | Or alternatively...
154 |
155 | ```bash
156 | poetry run coverage run --source=beholder -m pytest tests/ && coverage html --directory coverage/
157 | ```
158 |
159 | After execute all tests successfully, a coverage report will be generated on ```/coverage/index.html```. The coverage
160 | report is not versioned.
161 |
162 | ### 3.4 Checking code standards.
163 |
164 | This project uses [Flake8](https://flake8.pycqa.org/en/latest/) and several of its plugins to statically check its
165 | adherence of the code to the code standards, and [Black](https://github.com/psf/black). In order to format and check the
166 | code, we can use the following commands:
167 |
168 | First we execute black to automatically format all the files:
169 |
170 | ```bash
171 | poetry run task format
172 | ```
173 |
174 | Subsequently, we run ```Flake8``` to check any code standard violation:
175 |
176 | ```bash
177 | poetry run task check
178 | ```
179 |
180 | ```Flake8``` will generate a violation report on ```/lint/index.html```. Although some violations are false positive, we
181 | should always double-check if there is an alternative or workaround to improve the code.
182 |
183 | ### 3.5 Build the application locally.
184 |
185 | As described in the section ```2.1. Cloning and building the application locally.``` of this document, we can build
186 | the application using the following command:
187 |
188 | ```bash
189 | poetry build
190 | ```
191 |
192 | ## 4. Roadmap, Collaboration and Support.
193 |
194 | Although our intention is to keep this monitor as simple as possible, we plan to add the following features as soon as
195 | we can:
196 |
197 | * Report of current running processes, including its CPU and memory usage.
198 | * Report with all the open ports and established connections, identified by process.
199 | * Report with all open files, identified by process.
200 |
201 | If you want to collaborate, just fork the project locally, do the changes and open a pull request that I will review as
202 | soon as possible.
203 |
204 | If you found any bug or have any questions, please, use the [Issues](https://github.com/NullPyDev/beholder/issues) in this
205 | repository.
206 |
207 | If you want to support me to keep maintaining this project, consider to pay me a coffee using the following button:
208 |
209 |
214 |
215 | **Seriously, € 1,00 would help me a lot to keep supporting this and future projects**
216 |
217 |
218 | ## 5. License.
219 |
220 | MIT License
221 |
222 | Copyright (c) 2023 NullPy Consulting Ltd (Ireland)
223 |
224 | Permission is hereby granted, free of charge, to any person obtaining a copy
225 | of this software and associated documentation files (the "Software"), to deal
226 | in the Software without restriction, including without limitation the rights
227 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
228 | copies of the Software, and to permit persons to whom the Software is
229 | furnished to do so, subject to the following conditions:
230 |
231 | The above copyright notice and this permission notice shall be included in all
232 | copies or substantial portions of the Software.
233 |
234 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
235 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
236 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
237 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
238 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
239 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
240 | SOFTWARE.
241 |
--------------------------------------------------------------------------------
/beholder/__init__.py:
--------------------------------------------------------------------------------
1 | BEHOLDER_VERSION = "0.1.2"
2 |
--------------------------------------------------------------------------------
/beholder/__main__.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from flask import Flask, render_template, request
4 | from waitress import serve
5 |
6 | from beholder.facade.dashboard_facade import DashboardFacade
7 |
8 | # Flask app instance.
9 | beholder_app: Flask = Flask(__name__)
10 |
11 | # Used to retrieve all the data required to compose the node dashboard.
12 | facade: DashboardFacade = DashboardFacade()
13 |
14 |
15 | @beholder_app.route("/", methods=["GET"])
16 | def dashboard():
17 | """Display the system dashboard. This method accepts an optional query parameter called "template". When omitted,
18 | "index" is adopted as default value, making the response to render then full index page. The other possible value
19 | is "dashboard", which is used when refreshing the dashboard data through Ajax requests and will only render the
20 | dashboard content.
21 |
22 | This method actually delegates the task to gather the system summaries to the :class:`DashboardFacade` class.
23 |
24 | :return: Rendered templated, filled with the summaries retrieved from the available info services.
25 | """
26 | template_name: str = f"{request.args.get('template', 'index')}.html"
27 | summaries: dict[str, any] = facade.load_summaries()
28 | return render_template(template_name, summaries=summaries)
29 |
30 |
31 | if __name__ == "__main__":
32 | # Application entry point when executed as a stand-alone application. Meant for production environment.
33 | interface: str = os.environ.get("BEHOLDER_HOST", "0.0.0.0")
34 | port: int = int(os.environ.get("BEHOLDER_PORT", "2312"))
35 | serve(beholder_app, host=interface, port=port)
36 |
--------------------------------------------------------------------------------
/beholder/common/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NullPyDev/beholder/d2fe1ddaec90ce5ac57564204ce25d703861e7a1/beholder/common/__init__.py
--------------------------------------------------------------------------------
/beholder/common/formatter.py:
--------------------------------------------------------------------------------
1 | def size_formatter(size, suffix="B") -> str:
2 | """Format the received value into a proper human-readable data size information.
3 |
4 | :param size: Size to be formatted, expressed in bytes.
5 | :param suffix: Desired scale suffix. Default: "B".
6 | :return: Formatted data size, with proper suffix and 2 decimal places.
7 | """
8 | factor = 1_024
9 | for unit in ["", "K", "M", "G", "T"]:
10 | if size < factor:
11 | return f"{size:,.2f} {unit}{suffix}"
12 | size /= factor
13 |
14 |
15 | def frequency_formatter(frequency):
16 | """Format the received frequency expressed in Mhz into a proper human-readable frequency information.
17 |
18 | :param frequency: Frequency to be formatted, expressed in Mhz.
19 | :return: Formatted frequency, with proper suffix and 2 decimal places.
20 | """
21 | factor = 1_000
22 | for unit in ["Mhz", "Ghz"]:
23 | if frequency < factor:
24 | return f"{frequency:.2f} {unit}"
25 | frequency /= factor
26 |
--------------------------------------------------------------------------------
/beholder/facade/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NullPyDev/beholder/d2fe1ddaec90ce5ac57564204ce25d703861e7a1/beholder/facade/__init__.py
--------------------------------------------------------------------------------
/beholder/facade/dashboard_facade.py:
--------------------------------------------------------------------------------
1 | from ..service.cpu_info_service import CPUInfoService
2 | from ..service.info_service import InfoService
3 | from ..service.memory_info_service import MemoryInfoService
4 | from ..service.network_info_service import NetworkInfoService
5 | from ..service.service_info_service import ServiceInfoService
6 | from ..service.storage_info_service import StorageInfoService
7 | from ..service.system_info_service import SystemInfoService
8 | from ..service.temperature_info_service import TemperatureInfoService
9 |
10 |
11 | class DashboardFacade:
12 | """Provide a single point to retrieve and gather all the required information to compose the system dashboard."""
13 |
14 | # Info service used to retrieve basic node information.
15 | __sys_info_service: InfoService = None
16 |
17 | # Info service used to retrieve CPU information and usage statistics.
18 | __cpu_info_service: InfoService = None
19 |
20 | # Info service used to retrieve memory information and usage statistics.
21 | __mem_info_service: InfoService = None
22 |
23 | # Info service used to retrieve storage information and usage statistics.
24 | __storage_info_service: InfoService = None
25 |
26 | # Info service used to retrieve watched services state.
27 | __services_info_service: InfoService = None
28 |
29 | # Info service used to retrieve network information, statistics and state.
30 | __network_info_service: InfoService = None
31 |
32 | # Info service used to retrieve the temperature readings from all the available sensors in the system.
33 | __temperature_info_service: InfoService = None
34 |
35 | def __init__(self):
36 | """Default class constructor. Instantiate all the required infor services used to gather the dashboard
37 | information.
38 | """
39 | self.__sys_info_service = SystemInfoService()
40 | self.__cpu_info_service = CPUInfoService()
41 | self.__mem_info_service = MemoryInfoService()
42 | self.__storage_info_service = StorageInfoService()
43 | self.__services_info_service = ServiceInfoService()
44 | self.__network_info_service = NetworkInfoService()
45 | self.__temperature_info_service = TemperatureInfoService()
46 |
47 | def load_summaries(self) -> dict[str, any]:
48 | """Retrieve and return the following system summaries:
49 |
50 | * sys_info: Basic system information retrieved using the :class:`SystemInfoService`.
51 | * cpu_info: CPU information and statistics, retrieved using the :class:`CPUInfoService`.
52 | * mem_info: Memory information and statistics, retrieved using the :class:`MemoryInfoService`.
53 | * storage_info: Storage information and statistics, retrieved using the :class:`StorageInfoService`.
54 | * services_info: Watched services statuses, retrieved using the :class:`ServiceInfoService`.
55 | * network_info: Network interfaces information and statistics, retrieved using the :class:`NetworkInfoService`.
56 | * temperature_info: Temperature readings retrieved from available sensors, retrieved using the
57 | :class:`TemperatureInfoService`
58 |
59 | :return: A dictionary filled with the aforementioned summaries. Please refer to each info service documentation
60 | for a proper description of its structure.
61 | """
62 | summaries: dict[str, any] = {
63 | "sys_info": self.__sys_info_service.get_info(),
64 | "cpu_info": self.__cpu_info_service.get_info(),
65 | "mem_info": self.__mem_info_service.get_info(),
66 | "storage_info": self.__storage_info_service.get_info(),
67 | "services_info": self.__services_info_service.get_info(),
68 | "network_info": self.__network_info_service.get_info(),
69 | "temperature_info": self.__temperature_info_service.get_info(),
70 | }
71 | return summaries
72 |
--------------------------------------------------------------------------------
/beholder/service/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NullPyDev/beholder/d2fe1ddaec90ce5ac57564204ce25d703861e7a1/beholder/service/__init__.py
--------------------------------------------------------------------------------
/beholder/service/cpu_info_service.py:
--------------------------------------------------------------------------------
1 | import psutil
2 |
3 | from ..common.formatter import frequency_formatter
4 | from ..service.info_service import InfoService
5 |
6 |
7 | class CPUInfoService(InfoService):
8 | """Info service implementation specialized to retrieve details about the system CPU and its usage."""
9 |
10 | # Retrieved CPU details.
11 | __info: dict[str, any] = None
12 |
13 | def get_info(self) -> dict[str, any]:
14 | """Retrieve, format and return details about the installed CPU and its current load. The returned dictionary
15 | contains the following entries:
16 |
17 | * physical_cores: Number of available physical cores. Retrieved just once.
18 | * total_cores: Number of available physical and local cores. Retrieved just once.
19 | * max_frequency: Maximum CPU frequency. Retrieved just once.
20 | * current_frequency: Current CPU frequency.
21 | * pct_current_frequency: Percentage of the current CPU frequency in relation to the maximum CPU frequency.
22 | * total_load: Total loaded applied to the CPU expressed as percentage.
23 |
24 | :return: A dictionary containing the aforementioned entries.
25 | """
26 | if not self.__info:
27 | self.__info = {
28 | "physical_cores": psutil.cpu_count(logical=False),
29 | "total_cores": psutil.cpu_count(logical=True),
30 | "max_frequency": frequency_formatter(psutil.cpu_freq().max),
31 | }
32 | self.__info["current_frequency"] = frequency_formatter(psutil.cpu_freq().current)
33 | self.__info["pct_current_frequency"] = (psutil.cpu_freq().current / psutil.cpu_freq().max) * 100
34 | self.__info["total_load"] = psutil.cpu_percent()
35 | return self.__info
36 |
--------------------------------------------------------------------------------
/beholder/service/info_service.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 |
4 | class InfoService(ABC):
5 | """Common interface shared among any class that provides system information and statistics for a certain aspect of
6 | the system.
7 | """
8 |
9 | @abstractmethod
10 | def get_info(self) -> dict[str, any]:
11 | """Retrieves the desired information and statistics for a certain aspect of the system.
12 |
13 | :return: Information and statistics gathered by the info service.
14 | """
15 |
--------------------------------------------------------------------------------
/beholder/service/memory_info_service.py:
--------------------------------------------------------------------------------
1 | import psutil
2 |
3 | from beholder.common.formatter import size_formatter
4 | from ..service.info_service import InfoService
5 |
6 |
7 | class MemoryInfoService(InfoService):
8 | """Infor service implementation specialized to retrieve details about the available memory and its usage."""
9 |
10 | # Retrieved memory details.
11 | __info: dict[str, any] = None
12 |
13 | def get_info(self) -> dict[str, any]:
14 | """Retrieve, formant and return details about the available memory and its current usage. The returned
15 | dictionary contains the following entries:
16 |
17 | * physical_memory: Dictionary containing the total available and the percentual used of the physical memory.
18 | * swap_memory: Dictionary containing the total available and the percentual used of the SWAP memory.
19 |
20 | :return: Dictionary containing the aforementioned entries.
21 | """
22 | if not self.__info:
23 | self.__info = {
24 | "physical_memory": {"total_available": size_formatter(psutil.virtual_memory().total)},
25 | "swap_memory": {"total_available": size_formatter(psutil.swap_memory().total)},
26 | }
27 |
28 | self.update_pct_used_for_memory(self.__info["physical_memory"], psutil.virtual_memory())
29 | self.update_pct_used_for_memory(self.__info["swap_memory"], psutil.swap_memory())
30 | return self.__info
31 |
32 | @staticmethod
33 | def update_pct_used_for_memory(memory_summary: dict[str, any], memory_info):
34 | memory_summary["pct_used"] = memory_info.percent
35 | memory_summary["total_free"] = size_formatter(memory_info.free)
36 |
--------------------------------------------------------------------------------
/beholder/service/network_info_service.py:
--------------------------------------------------------------------------------
1 | import psutil
2 | from psutil._common import snicaddr, snicstats
3 |
4 | from ..service.info_service import InfoService
5 |
6 |
7 | class NetworkInfoService(InfoService):
8 | """Info service implementation specialized to retrieved details and statistics about the network interfaces
9 | available in the system.
10 | """
11 |
12 | def get_info(self) -> dict[str, any]:
13 | """Retrieve, format and return details about the available network interfaces in the system. The returned
14 | dictionary contains the following entries:
15 |
16 | * interface_name: Dictionary entry key.
17 | * active: A boolean flag indicating if the interface is active (True) or not (False).
18 | * addresses: A list of dictionary containing the addresses assigned to the interface, which has the
19 | following entries:
20 | * address: Address assigned to the interface.
21 | * address_type: Type of the address assigned to the interface (E.g. IP, IPv6, MAC).
22 |
23 | The loopback interface is intentionally removed from the list for the simplicity sake.
24 |
25 | :return: A dictionary containing the aforementioned entries.
26 | """
27 | if_summaries: dict[str, any] = {}
28 | if_addresses: dict[str, list[snicaddr]] = psutil.net_if_addrs()
29 | if_statuses: dict[str, snicstats] = psutil.net_if_stats()
30 |
31 | for interface, addresses in if_addresses.items():
32 | if interface != "lo":
33 | if_summaries[interface] = {
34 | "active": if_statuses[interface][0],
35 | "addresses": self.load_interface_addresses(addresses),
36 | }
37 |
38 | return if_summaries
39 |
40 | @staticmethod
41 | def load_interface_addresses(addresses: list[snicaddr]) -> list[dict[str, any]]:
42 | """Retrieve the addresses assigned to the network interface. Each dictionary in the list contains the following
43 | entries:
44 |
45 | * address: Address assigned to the interface.
46 | * address_type: Type of the address assigned to the interface (E.g. IP, IPv6, MAC).
47 |
48 | :param addresses: Addresses assigned to the network interface.
49 | :return: A list of dictionaries containing the aforementioned entries.
50 | """
51 | address_summaries: list[dict[str, any]] = []
52 | for address in addresses:
53 | address_summaries.append({"address": address.address.split("%")[0], "address_type": address.family.name})
54 | return address_summaries
55 |
--------------------------------------------------------------------------------
/beholder/service/service_info_service.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import psutil
4 |
5 | from ..service.info_service import InfoService
6 |
7 |
8 | class ServiceInfoService(InfoService):
9 | """Info service implementation specialized to retrieve the status of services in the system."""
10 |
11 | # List of the services to be watched by the application.
12 | __service_watch_list: dict[str, bool] = {}
13 |
14 | def __init__(self):
15 | """Default class constructor. Initializes the service watch list based on the BEHOLDER_SERVICES_WATCHLIST
16 | environment variable, which should contains a coma separated list of all services to be watched by the
17 | application. If the environment variable is not available, an empty string is used by default.
18 | """
19 | services: list[str] = os.environ.get("BEHOLDER_SERVICES_WATCHLIST", "").strip().split(",")
20 | if len(services) > 0 and services[0] != "":
21 | self.__service_watch_list = {service: False for service in services}
22 |
23 | def get_info(self) -> dict[str, any]:
24 | """Retrieve, format and return the status of all the watched services. The returned dictionary has the service
25 | name and the entry key, and the service status as the entry value.
26 |
27 | :return: A dictionary with the aforementioned entries.
28 | """
29 | services_statuses: dict[str, any] = {}
30 |
31 | if len(self.__service_watch_list) > 0:
32 | services_statuses = self.__service_watch_list.copy()
33 | for process in psutil.process_iter():
34 | with process.oneshot():
35 | if process.name() in services_statuses:
36 | services_statuses[process.name()] = process.is_running()
37 |
38 | return services_statuses
39 |
--------------------------------------------------------------------------------
/beholder/service/storage_info_service.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from logging import Logger
3 |
4 | import psutil
5 |
6 | from ..common.formatter import size_formatter
7 | from ..service.info_service import InfoService
8 |
9 |
10 | class StorageInfoService(InfoService):
11 | """Info service implementation specialized to retrieve the details and usage statistics from all the storage
12 | devices available in the node.
13 | """
14 |
15 | # Local logger.
16 | __logger: Logger = None
17 |
18 | def __init__(self):
19 | """Default class constructor."""
20 | self.__logger = logging.getLogger(__name__)
21 |
22 | def get_info(self) -> dict[str, any]:
23 | """Retrieve, format and return details about all available storage devices and its usage. The returned
24 | dictionary contains the following entries:
25 |
26 | * device: Entry key.
27 | * total_size: Disk capacity.
28 | * total_free: Total space available in the disk.
29 | * pct_used: Used space expressed in percentage.
30 |
31 | :return: A dictionary containing the aforementioned entries.
32 | """
33 | disks: dict[str, any] = {}
34 | for partition in psutil.disk_partitions():
35 | if partition.device not in disks:
36 | try:
37 | usage = psutil.disk_usage(partition.mountpoint)
38 |
39 | except PermissionError:
40 | self.__logger.warning(
41 | "It was not possible to retrieve usage statistics for the device '%s' "
42 | "due the following error:",
43 | partition.device,
44 | exc_info=True,
45 | )
46 | continue
47 |
48 | disks[partition.device] = {
49 | "total_size": size_formatter(usage.total),
50 | "total_free": size_formatter(usage.free),
51 | "pct_used": usage.percent,
52 | }
53 |
54 | return disks
55 |
--------------------------------------------------------------------------------
/beholder/service/system_info_service.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import locale
3 | import platform
4 |
5 | import psutil
6 |
7 | from beholder import BEHOLDER_VERSION
8 | from tzlocal import get_localzone
9 |
10 | from ..service.info_service import InfoService
11 |
12 |
13 | class SystemInfoService(InfoService):
14 | """Info service implementation specialized to retrieve the basic details about the node."""
15 |
16 | # Details about kernel, os and its versions.
17 | __uname: platform.uname_result = None
18 |
19 | # Cache of immutable system details.
20 | __info: dict[str, any] = None
21 |
22 | def __init__(self):
23 | """Default class constructor. Initialized the "uname" attribute."""
24 | self.__uname = platform.uname()
25 |
26 | def get_info(self) -> dict[str, any]:
27 | """
28 | Retrieve, format and return the basic system details, The returned dictionary contains the following entries:
29 |
30 | * system: Operation system.
31 | * host_name: Name of the host.
32 | * release: Kernel version.
33 | * architecture: Node architecture (e.g.: x86, x86_64).
34 | * beholder_version: Version of Beholder service.
35 | * boot_time: Timestamp of when the system started.
36 | * locale: Node's configured locale.
37 | * encoding: Node's configured encoding.
38 | * time_zone: Node's configured timezone.
39 | * current_time: Node's current date and time.
40 | * up_time: Time passed since the node started.
41 |
42 | :return: A dictionary containing the aforementioned entries.
43 | """
44 | if not self.__info:
45 | self.__info = {
46 | "system": self.__uname.system,
47 | "host_name": self.__uname.node,
48 | "release": self.__uname.release,
49 | "architecture": self.__uname.machine,
50 | "beholder_version": BEHOLDER_VERSION,
51 | "boot_time": datetime.datetime.fromtimestamp(psutil.boot_time()),
52 | }
53 |
54 | self.__info["locale"] = locale.getlocale()[0]
55 | self.__info["encoding"] = locale.getlocale()[1]
56 | self.__info["time_zone"] = get_localzone().key
57 | self.__info["current_time"] = datetime.datetime.now().strftime("%d/%m/%Y - %H:%M:%S")
58 | self.__info["up_time"] = self.get_uptime()
59 | return self.__info
60 |
61 | def get_uptime(self) -> str:
62 | """Format the up-time value, showing by default, days, hours and minutes since the host started.
63 |
64 | :return: Up-time formatted as aforementioned.
65 | """
66 | now: datetime.datetime = datetime.datetime.utcnow()
67 | delta: datetime.timedelta = now - self.__info["boot_time"]
68 |
69 | days: int = delta.days
70 | hours, rem = divmod(delta.seconds, 3_600)
71 | minutes, seconds = divmod(rem, 60)
72 |
73 | return f"{days} day(s), {hours} hour(s) and {minutes} minute(s)"
74 |
--------------------------------------------------------------------------------
/beholder/service/temperature_info_service.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import psutil
4 | from psutil._common import shwtemp
5 |
6 | from ..service.info_service import InfoService
7 |
8 |
9 | class TemperatureInfoService(InfoService):
10 | """Info service implementation specialized to retrieve the temperature reading of all available sensors."""
11 |
12 | # Temperature scale to be used (C or F)
13 | __temp_scale: str = None
14 |
15 | def __init__(self):
16 | """Default class constructor. Initializes the property with the temperature scale to be used, by reading from
17 | the BEHOLDER_TEMPERATURE_SCALE environment variable. If this variable is not available, Celsius (C) is used by
18 | default.
19 | """
20 | self.__temp_scale = os.environ.get("BEHOLDER_TEMPERATURE_SCALE", "C").strip().upper()
21 |
22 | def get_info(self) -> dict[str, any]:
23 | """Retrieve, format and return de temperature readings for each available sensor. The returned dictionary
24 | contains the following entries:
25 |
26 | * sensor_name: Entry key.
27 | * sensor_label: Label assigned to the sensor.
28 | * scale: Temperature scale used.
29 | * temperature: Current temperature reading.
30 | * low_band: Final temperature of the thermal lower band. It is calculated as 50% of the high temperature.
31 | * medium_band: Final temperature of the thermal medium band. It is calculated as 75% of the high temperature.
32 | * high_band: Final temperature of the thermal high band. It is the high temperature.
33 |
34 | :return: A dictionary containing the aforementioned entries.
35 | """
36 | sensor_readings: dict[str, any] = {}
37 | use_fahrenheit: bool = self.__temp_scale == "F"
38 | temp_readings: dict[str, list[shwtemp]] = psutil.sensors_temperatures(fahrenheit=use_fahrenheit)
39 |
40 | for sensor_name, temperatures in temp_readings.items():
41 | readings: list[dict[str, any]] = []
42 |
43 | for temperature in temperatures:
44 | high = 0.0
45 |
46 | # Workaround for some sensor with wrong temperature values for high.
47 | if temperature.high and self.__temp_scale == "C":
48 | if temperature.high > 1_000:
49 | high = temperature.high / 1_000
50 | elif temperature.high > 100:
51 | high = temperature.high / 100
52 | else:
53 | high = temperature.high
54 |
55 | readings.append(
56 | {
57 | "sensor_label": temperature.label,
58 | "scale": self.__temp_scale,
59 | "temperature": temperature.current,
60 | "low_band": high * 0.5 if temperature.high else 0.0,
61 | "medium_band": high * 0.75 if temperature.high else 0.0,
62 | "high_band": high if temperature.high else 0.0,
63 | }
64 | )
65 |
66 | sensor_readings[sensor_name] = readings
67 |
68 | return sensor_readings
69 |
--------------------------------------------------------------------------------
/beholder/static/css/beholder.css:
--------------------------------------------------------------------------------
1 | @import url("/static/css/bootstrap.dark.min.css");
2 | @import url("/static/css/bootstrap.light.min.css");
3 |
4 | @media (prefers-color-scheme: dark) {
5 | .navbar-brand {
6 | color: #fff !important;
7 | }
8 |
9 | .navbar-text {
10 | color: #c9c9c9 !important;
11 | }
12 |
13 | .card {
14 | border-radius: 7px !important;
15 | }
16 |
17 | .progress-bar {
18 | color: #fff !important;
19 | font-weight: bolder;
20 | }
21 | }
22 |
23 | .content {
24 | padding-top: 60px;
25 | }
26 |
27 | .navbar {
28 | border: none !important;
29 | }
30 |
31 | .navbar-brand {
32 | border: none !important;
33 | }
--------------------------------------------------------------------------------
/beholder/static/js/beholder.js:
--------------------------------------------------------------------------------
1 | let httpRequest;
2 | let dashboardContentElement = document.getElementById("dashboard");
3 | let refreshIntervalId = window.setInterval(refreshDashboard, 10000);
4 |
5 | function refreshDashboard() {
6 | httpRequest = new XMLHttpRequest();
7 | if (!httpRequest) {
8 | console.log("It was not possible to prepare a request to refresh the dashboard content.");
9 | return;
10 | }
11 |
12 | httpRequest.onreadystatechange = updateDashboardContent;
13 | httpRequest.open("GET", "/?template=dashboard", true);
14 | httpRequest.send();
15 | }
16 |
17 | function updateDashboardContent() {
18 | if (httpRequest.readyState === XMLHttpRequest.DONE) {
19 | if (httpRequest.status === 200) {
20 | dashboardContentElement.innerHTML = httpRequest.responseText;
21 | new Masonry(dashboardContentElement, { itemSelector: ".widget", percentPosition: true });
22 | } else {
23 | console.log("It was not possible to update the dashboard: " + httpRequest.status);
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/beholder/static/js/masonry.min.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * Masonry PACKAGED v4.2.2
3 | * Cascading grid layout library
4 | * https://masonry.desandro.com
5 | * MIT License
6 | * by David DeSandro
7 | */
8 |
9 | !function(t,e){"function"==typeof define&&define.amd?define("jquery-bridget/jquery-bridget",["jquery"],function(i){return e(t,i)}):"object"==typeof module&&module.exports?module.exports=e(t,require("jquery")):t.jQueryBridget=e(t,t.jQuery)}(window,function(t,e){"use strict";function i(i,r,a){function h(t,e,n){var o,r="$()."+i+'("'+e+'")';return t.each(function(t,h){var u=a.data(h,i);if(!u)return void s(i+" not initialized. Cannot call methods, i.e. "+r);var d=u[e];if(!d||"_"==e.charAt(0))return void s(r+" is not a valid method");var l=d.apply(u,n);o=void 0===o?l:o}),void 0!==o?o:t}function u(t,e){t.each(function(t,n){var o=a.data(n,i);o?(o.option(e),o._init()):(o=new r(n,e),a.data(n,i,o))})}a=a||e||t.jQuery,a&&(r.prototype.option||(r.prototype.option=function(t){a.isPlainObject(t)&&(this.options=a.extend(!0,this.options,t))}),a.fn[i]=function(t){if("string"==typeof t){var e=o.call(arguments,1);return h(this,t,e)}return u(this,t),this},n(a))}function n(t){!t||t&&t.bridget||(t.bridget=i)}var o=Array.prototype.slice,r=t.console,s="undefined"==typeof r?function(){}:function(t){r.error(t)};return n(e||t.jQuery),i}),function(t,e){"function"==typeof define&&define.amd?define("ev-emitter/ev-emitter",e):"object"==typeof module&&module.exports?module.exports=e():t.EvEmitter=e()}("undefined"!=typeof window?window:this,function(){function t(){}var e=t.prototype;return e.on=function(t,e){if(t&&e){var i=this._events=this._events||{},n=i[t]=i[t]||[];return-1==n.indexOf(e)&&n.push(e),this}},e.once=function(t,e){if(t&&e){this.on(t,e);var i=this._onceEvents=this._onceEvents||{},n=i[t]=i[t]||{};return n[e]=!0,this}},e.off=function(t,e){var i=this._events&&this._events[t];if(i&&i.length){var n=i.indexOf(e);return-1!=n&&i.splice(n,1),this}},e.emitEvent=function(t,e){var i=this._events&&this._events[t];if(i&&i.length){i=i.slice(0),e=e||[];for(var n=this._onceEvents&&this._onceEvents[t],o=0;oe;e++){var i=h[e];t[i]=0}return t}function n(t){var e=getComputedStyle(t);return e||a("Style returned "+e+". Are you running this code in a hidden iframe on Firefox? See https://bit.ly/getsizebug1"),e}function o(){if(!d){d=!0;var e=document.createElement("div");e.style.width="200px",e.style.padding="1px 2px 3px 4px",e.style.borderStyle="solid",e.style.borderWidth="1px 2px 3px 4px",e.style.boxSizing="border-box";var i=document.body||document.documentElement;i.appendChild(e);var o=n(e);s=200==Math.round(t(o.width)),r.isBoxSizeOuter=s,i.removeChild(e)}}function r(e){if(o(),"string"==typeof e&&(e=document.querySelector(e)),e&&"object"==typeof e&&e.nodeType){var r=n(e);if("none"==r.display)return i();var a={};a.width=e.offsetWidth,a.height=e.offsetHeight;for(var d=a.isBorderBox="border-box"==r.boxSizing,l=0;u>l;l++){var c=h[l],f=r[c],m=parseFloat(f);a[c]=isNaN(m)?0:m}var p=a.paddingLeft+a.paddingRight,g=a.paddingTop+a.paddingBottom,y=a.marginLeft+a.marginRight,v=a.marginTop+a.marginBottom,_=a.borderLeftWidth+a.borderRightWidth,z=a.borderTopWidth+a.borderBottomWidth,E=d&&s,b=t(r.width);b!==!1&&(a.width=b+(E?0:p+_));var x=t(r.height);return x!==!1&&(a.height=x+(E?0:g+z)),a.innerWidth=a.width-(p+_),a.innerHeight=a.height-(g+z),a.outerWidth=a.width+y,a.outerHeight=a.height+v,a}}var s,a="undefined"==typeof console?e:function(t){console.error(t)},h=["paddingLeft","paddingRight","paddingTop","paddingBottom","marginLeft","marginRight","marginTop","marginBottom","borderLeftWidth","borderRightWidth","borderTopWidth","borderBottomWidth"],u=h.length,d=!1;return r}),function(t,e){"use strict";"function"==typeof define&&define.amd?define("desandro-matches-selector/matches-selector",e):"object"==typeof module&&module.exports?module.exports=e():t.matchesSelector=e()}(window,function(){"use strict";var t=function(){var t=window.Element.prototype;if(t.matches)return"matches";if(t.matchesSelector)return"matchesSelector";for(var e=["webkit","moz","ms","o"],i=0;is?"round":"floor";r=Math[a](r),this.cols=Math.max(r,1)},n.getContainerWidth=function(){var t=this._getOption("fitWidth"),i=t?this.element.parentNode:this.element,n=e(i);this.containerWidth=n&&n.innerWidth},n._getItemLayoutPosition=function(t){t.getSize();var e=t.size.outerWidth%this.columnWidth,i=e&&1>e?"round":"ceil",n=Math[i](t.size.outerWidth/this.columnWidth);n=Math.min(n,this.cols);for(var o=this.options.horizontalOrder?"_getHorizontalColPosition":"_getTopColPosition",r=this[o](n,t),s={x:this.columnWidth*r.col,y:r.y},a=r.y+t.size.outerHeight,h=n+r.col,u=r.col;h>u;u++)this.colYs[u]=a;return s},n._getTopColPosition=function(t){var e=this._getTopColGroup(t),i=Math.min.apply(Math,e);return{col:e.indexOf(i),y:i}},n._getTopColGroup=function(t){if(2>t)return this.colYs;for(var e=[],i=this.cols+1-t,n=0;i>n;n++)e[n]=this._getColGroupY(n,t);return e},n._getColGroupY=function(t,e){if(2>e)return this.colYs[t];var i=this.colYs.slice(t,t+e);return Math.max.apply(Math,i)},n._getHorizontalColPosition=function(t,e){var i=this.horizontalColIndex%this.cols,n=t>1&&i+t>this.cols;i=n?0:i;var o=e.size.outerWidth&&e.size.outerHeight;return this.horizontalColIndex=o?i+t:this.horizontalColIndex,{col:i,y:this._getColGroupY(i,t)}},n._manageStamp=function(t){var i=e(t),n=this._getElementOffset(t),o=this._getOption("originLeft"),r=o?n.left:n.right,s=r+i.outerWidth,a=Math.floor(r/this.columnWidth);a=Math.max(0,a);var h=Math.floor(s/this.columnWidth);h-=s%this.columnWidth?0:1,h=Math.min(this.cols-1,h);for(var u=this._getOption("originTop"),d=(u?n.top:n.bottom)+i.outerHeight,l=a;h>=l;l++)this.colYs[l]=Math.max(d,this.colYs[l])},n._getContainerSize=function(){this.maxY=Math.max.apply(Math,this.colYs);var t={height:this.maxY};return this._getOption("fitWidth")&&(t.width=this._getContainerFitWidth()),t},n._getContainerFitWidth=function(){for(var t=0,e=this.cols;--e&&0===this.colYs[e];)t++;return(this.cols-t)*this.columnWidth-this.gutter},n.needsResizeLayout=function(){var t=this.containerWidth;return this.getContainerWidth(),t!=this.containerWidth},i});
--------------------------------------------------------------------------------
/beholder/templates/dashboard.html:
--------------------------------------------------------------------------------
1 |
2 | {% include "widget/sys_info.html" %}
3 |
4 |
5 | {% include "widget/cpu_info.html" %}
6 |
7 |
8 | {% include "widget/mem_info.html" %}
9 |
10 |
11 | {% include "widget/storage_info.html" %}
12 |
13 |
14 | {% include "widget/services_info.html" %}
15 |
16 |
17 | {% include "widget/network_info.html" %}
18 |
19 |
20 | {% include "widget/temperature_info.html" %}
21 |