From bcf4ce45fc0394ea64d35062c97a5e5dfdedd29b Mon Sep 17 00:00:00 2001 From: nicolargo Date: Sun, 26 Nov 2023 10:02:30 +0100 Subject: Rename any reference to Bottle in doc and dep file. Ready to refactor the main glances_restful_api.py file. --- Makefile | 3 + README.rst | 8 +- docs/cmds.rst | 2 +- docs/docker.rst | 4 +- docs/man/glances.1 | 2 +- glances/README.txt | 4 +- glances/main.py | 2 +- glances/outputs/glances_bottle.py | 668 -------------------------------- glances/outputs/static/README.md | 2 +- glances/plugins/processlist/__init__.py | 2 +- glances/webserver.py | 10 +- optional-requirements.txt | 3 +- requirements.txt | 4 +- setup.py | 7 +- snap/snapcraft.yaml | 22 +- tox.ini | 3 +- webui-requirements.txt | 3 +- 17 files changed, 49 insertions(+), 700 deletions(-) delete mode 100644 glances/outputs/glances_bottle.py diff --git a/Makefile b/Makefile index 4ffc82b9..fd0cfae9 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,9 @@ test-min: ## Run unit tests in minimal environment test-min-with-upgrade: venv-min-upgrade ## Upgrade deps and run unit tests in minimal environment ./venv-min/bin/python ./unitest.py +test-restful-api: ## Run unit tests of the RESTful API + ./venv-min/bin/python ./unitest-restful.py + # =================================================================== # Linters and profilers # =================================================================== diff --git a/README.rst b/README.rst index 3af03fe4..50c9c0f3 100644 --- a/README.rst +++ b/README.rst @@ -92,11 +92,11 @@ Optional dependencies: - ``batinfo`` (for battery monitoring) - ``bernhard`` (for the Riemann export module) -- ``bottle`` (for Web server mode) - ``cassandra-driver`` (for the Cassandra export module) - ``chevron`` (for the action script feature) - ``docker`` (for the Containers Docker monitoring support) - ``elasticsearch`` (for the Elastic Search export module) +- ``FastAPI`` and ``Uvicorn`` (for Web server mode) - ``graphitesender`` (For the Graphite export module) - ``hddtemp`` (for HDD temperature monitoring support) [Linux-only] - ``influxdb`` (for the InfluxDB version 1 export module) @@ -207,10 +207,10 @@ Get the Glances container: The following tags are availables: - *latest-full* for a full Alpine Glances image (latest release) with all dependencies -- *latest* for a basic Alpine Glances (latest release) version with minimal dependencies (Bottle and Docker) +- *latest* for a basic Alpine Glances (latest release) version with minimal dependencies (FastAPI and Docker) - *dev* for a basic Alpine Glances image (based on development branch) with all dependencies (Warning: may be instable) - *ubuntu-latest-full* for a full Ubuntu Glances image (latest release) with all dependencies -- *ubuntu-latest* for a basic Ubuntu Glances (latest release) version with minimal dependencies (Bottle and Docker) +- *ubuntu-latest* for a basic Ubuntu Glances (latest release) version with minimal dependencies (FastAPI and Docker) - *ubuntu-dev* for a basic Ubuntu Glances image (based on development branch) with all dependencies (Warning: may be instable) Run last version of Glances container in *console mode*: @@ -319,7 +319,7 @@ Start Termux on your device and enter: $ apt update $ apt upgrade $ apt install clang python - $ pip install bottle + $ pip install fastapi uvicorn $ pip install glances And start Glances: diff --git a/docs/cmds.rst b/docs/cmds.rst index cffeb153..91a8ed53 100644 --- a/docs/cmds.rst +++ b/docs/cmds.rst @@ -172,7 +172,7 @@ Command-Line Options .. option:: -w, --webserver - run Glances in web server mode (bottle lib needed) + run Glances in web server mode (FastAPI lib needed) .. option:: --cached-time CACHED_TIME diff --git a/docs/docker.rst b/docs/docker.rst index ad91fd3d..9f4aa63e 100644 --- a/docs/docker.rst +++ b/docs/docker.rst @@ -28,7 +28,7 @@ Available tags (all images are based on both Alpine and Ubuntu Operating System) * - `latest` - Alpine - Latest Release - - Minimal + (Bottle & Docker) + - Minimal + (FastAPI & Docker) * - `dev` - Alpine - develop @@ -40,7 +40,7 @@ Available tags (all images are based on both Alpine and Ubuntu Operating System) * - `ubuntu-latest` - Ubuntu - Latest Release - - Minimal + (Bottle & Docker) + - Minimal + (FastAPI & Docker) * - `ubuntu-dev` - Ubuntu - develop diff --git a/docs/man/glances.1 b/docs/man/glances.1 index 87e52bf9..b2888dd8 100644 --- a/docs/man/glances.1 +++ b/docs/man/glances.1 @@ -254,7 +254,7 @@ set refresh time in seconds [default: 3 sec] .INDENT 0.0 .TP .B \-w, \-\-webserver -run Glances in web server mode (bottle lib needed) +run Glances in web server mode (FastAPI lib needed) .UNINDENT .INDENT 0.0 .TP diff --git a/glances/README.txt b/glances/README.txt index c5fd13eb..3e6de7eb 100644 --- a/glances/README.txt +++ b/glances/README.txt @@ -12,7 +12,7 @@ globals.py Share variables upon modules main.py Main script to rule them up... client.py Glances client server.py Glances server -webserver.py Glances web server (Bottle-based) +webserver.py Glances web server (Based on FastAPI) autodiscover.py Glances autodiscover module (via zeroconf) standalone.py Glances standalone (curses interface) password.py Manage password for Glances client/server @@ -27,7 +27,7 @@ plugins outputs => Glances UI glances_curses.py The curses interface - glances_bottle.py The web interface + glances_restful-api.py The HTTP/API & Web based interface ... exports => Glances exports diff --git a/glances/main.py b/glances/main.py index 03acfd7a..85db14aa 100644 --- a/glances/main.py +++ b/glances/main.py @@ -363,7 +363,7 @@ Examples of use: action='store_true', default=False, dest='webserver', - help='run Glances in web server mode (bottle needed)', + help='run Glances in web server mode (FastAPI and Uvicorn lib needed)', ) parser.add_argument( '--cached-time', diff --git a/glances/outputs/glances_bottle.py b/glances/outputs/glances_bottle.py deleted file mode 100644 index df6cfc71..00000000 --- a/glances/outputs/glances_bottle.py +++ /dev/null @@ -1,668 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of Glances. -# -# SPDX-FileCopyrightText: 2023 Nicolas Hennion -# -# SPDX-License-Identifier: LGPL-3.0-only -# - -"""RestFull API interface class.""" - -import os -import sys -import tempfile -from io import open -import webbrowser -import zlib -import socket -from urllib.parse import urljoin - -from glances.globals import b, json_dumps -from glances.timer import Timer -from glances.logger import logger - -try: - from bottle import Bottle, static_file, abort, response, request, auth_basic, template, TEMPLATE_PATH -except ImportError: - logger.critical('Bottle module not found. Glances cannot start in web server mode.') - sys.exit(2) - - -def compress(func): - """Compress result with deflate algorithm if the client ask for it.""" - - def wrapper(*args, **kwargs): - """Wrapper that take one function and return the compressed result.""" - ret = func(*args, **kwargs) - logger.debug( - 'Receive {} {} request with header: {}'.format( - request.method, - request.url, - ['{}: {}'.format(h, request.headers.get(h)) for h in request.headers.keys()], - ) - ) - if 'deflate' in request.headers.get('Accept-Encoding', ''): - response.headers['Content-Encoding'] = 'deflate' - ret = deflate_compress(ret) - else: - response.headers['Content-Encoding'] = 'identity' - return ret - - def deflate_compress(data, compress_level=6): - """Compress given data using the DEFLATE algorithm""" - # Init compression - zobj = zlib.compressobj( - compress_level, zlib.DEFLATED, zlib.MAX_WBITS, zlib.DEF_MEM_LEVEL, zlib.Z_DEFAULT_STRATEGY - ) - - # Return compressed object - return zobj.compress(b(data)) + zobj.flush() - - return wrapper - - -class GlancesBottle(object): - """This class manages the Bottle Web server.""" - - API_VERSION = '3' - - def __init__(self, config=None, args=None): - # Init config - self.config = config - - # Init args - self.args = args - - # Init stats - # Will be updated within Bottle route - self.stats = None - - # cached_time is the minimum time interval between stats updates - # i.e. HTTP/RESTful calls will not retrieve updated info until the time - # since last update is passed (will retrieve old cached info instead) - self.timer = Timer(0) - - # Load configuration file - self.load_config(config) - - # Set the bind URL (only used for log information purpose) - self.bind_url = urljoin('http://{}:{}/'.format(self.args.bind_address, self.args.port), self.url_prefix) - - # Init Bottle - self._app = Bottle() - # Enable CORS (issue #479) - self._app.install(EnableCors()) - # Password - if args.password != '': - self._app.install(auth_basic(self.check_auth)) - # Define routes - self._route() - - # Path where the statics files are stored - self.STATIC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static/public') - - # Paths for templates - TEMPLATE_PATH.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static/templates')) - - def load_config(self, config): - """Load the outputs section of the configuration file.""" - # Limit the number of processes to display in the WebUI - self.url_prefix = '/' - if config is not None and config.has_section('outputs'): - n = config.get_value('outputs', 'max_processes_display', default=None) - logger.debug('Number of processes to display in the WebUI: {}'.format(n)) - self.url_prefix = config.get_value('outputs', 'url_prefix', default='/') - logger.debug('URL prefix: {}'.format(self.url_prefix)) - - def __update__(self): - # Never update more than 1 time per cached_time - if self.timer.finished(): - self.stats.update() - self.timer = Timer(self.args.cached_time) - - def app(self): - return self._app() - - def check_auth(self, username, password): - """Check if a username/password combination is valid.""" - if username == self.args.username: - from glances.password import GlancesPassword - - pwd = GlancesPassword(username=username, config=self.config) - return pwd.check_password(self.args.password, pwd.get_hash(password)) - else: - return False - - def _route(self): - """Define route.""" - # REST API - self._app.route('/api/%s/status' % self.API_VERSION, method="GET", callback=self._api_status) - self._app.route('/api/%s/config' % self.API_VERSION, method="GET", callback=self._api_config) - self._app.route('/api/%s/config/' % self.API_VERSION, method="GET", callback=self._api_config_item) - self._app.route('/api/%s/args' % self.API_VERSION, method="GET", callback=self._api_args) - self._app.route('/api/%s/args/' % self.API_VERSION, method="GET", callback=self._api_args_item) - self._app.route('/api/%s/help' % self.API_VERSION, method="GET", callback=self._api_help) - self._app.route('/api/%s/pluginslist' % self.API_VERSION, method="GET", callback=self._api_plugins) - self._app.route('/api/%s/all' % self.API_VERSION, method="GET", callback=self._api_all) - self._app.route('/api/%s/all/limits' % self.API_VERSION, method="GET", callback=self._api_all_limits) - self._app.route('/api/%s/all/views' % self.API_VERSION, method="GET", callback=self._api_all_views) - self._app.route('/api/%s/' % self.API_VERSION, method="GET", callback=self._api) - self._app.route('/api/%s//history' % self.API_VERSION, method="GET", callback=self._api_history) - self._app.route( - '/api/%s//history/' % self.API_VERSION, method="GET", callback=self._api_history - ) - self._app.route('/api/%s//top/' % self.API_VERSION, method="GET", callback=self._api_top) - self._app.route('/api/%s//limits' % self.API_VERSION, method="GET", callback=self._api_limits) - self._app.route('/api/%s//views' % self.API_VERSION, method="GET", callback=self._api_views) - self._app.route('/api/%s//' % self.API_VERSION, method="GET", callback=self._api_item) - self._app.route( - '/api/%s///history' % self.API_VERSION, method="GET", callback=self._api_item_history - ) - self._app.route( - '/api/%s///history/' % self.API_VERSION, method="GET", callback=self._api_item_history - ) - self._app.route('/api/%s///' % self.API_VERSION, method="GET", callback=self._api_value) - self._app.route( - '/api/%s///' % self.API_VERSION, method="GET", callback=self._api_value - ) - bindmsg = 'Glances RESTful API Server started on {}api/{}'.format(self.bind_url, self.API_VERSION) - logger.info(bindmsg) - - # WEB UI - if not self.args.disable_webui: - self._app.route('/', method="GET", callback=self._index) - self._app.route('/', method=["GET"], callback=self._index) - self._app.route('/', method="GET", callback=self._resource) - bindmsg = 'Glances Web User Interface started on {}'.format(self.bind_url) - else: - bindmsg = 'The WebUI is disable (--disable-webui)' - - logger.info(bindmsg) - print(bindmsg) - - def start(self, stats): - """Start the bottle.""" - # Init stats - self.stats = stats - - # Init plugin list - self.plugins_list = self.stats.getPluginsList() - - # Bind the Bottle TCP address/port - if self.args.open_web_browser: - # Implementation of the issue #946 - # Try to open the Glances Web UI in the default Web browser if: - # 1) --open-web-browser option is used - # 2) Glances standalone mode is running on Windows OS - webbrowser.open(self.bind_url, new=2, autoraise=1) - - # Run the Web application - if self.url_prefix != '/': - # Create an outer Bottle class instance to manage url_prefix - self.main_app = Bottle() - self.main_app.mount(self.url_prefix, self._app) - try: - self.main_app.run(host=self.args.bind_address, port=self.args.port, quiet=not self.args.debug) - except socket.error as e: - logger.critical('Error: Can not ran Glances Web server ({})'.format(e)) - else: - try: - self._app.run(host=self.args.bind_address, port=self.args.port, quiet=not self.args.debug) - except socket.error as e: - logger.critical('Error: Can not ran Glances Web server ({})'.format(e)) - - def end(self): - """End the bottle.""" - logger.info("Close the Web server") - self._app.close() - if self.url_prefix != '/': - self.main_app.close() - - def _index(self, refresh_time=None): - """Bottle callback for index.html (/) file.""" - - if refresh_time is None or refresh_time < 1: - refresh_time = int(self.args.time) - - # Update the stat - self.__update__() - - # Display - return template("index.html", refresh_time=refresh_time) - - def _resource(self, filepath): - """Bottle callback for resources files.""" - # Return the static file - return static_file(filepath, root=self.STATIC_PATH) - - @compress - def _api_status(self): - """Glances API RESTful implementation. - - Return a 200 status code. - This entry point should be used to check the API health. - - See related issue: Web server health check endpoint #1988 - """ - response.status = 200 - - return "Active" - - @compress - def _api_help(self): - """Glances API RESTful implementation. - - Return the help data or 404 error. - """ - response.content_type = 'application/json; charset=utf-8' - - # Update the stat - view_data = self.stats.get_plugin("help").get_view_data() - try: - plist = json_dumps(view_data) - except Exception as e: - abort(404, "Cannot get help view data (%s)" % str(e)) - return plist - - @compress - def _api_plugins(self): - """Glances API RESTFul implementation. - - @api {get} /api/%s/pluginslist Get plugins list - @apiVersion 2.0 - @apiName pluginslist - @apiGroup plugin - - @apiSuccess {String[]} Plugins list. - - @apiSuccessExample Success-Response: - HTTP/1.1 200 OK - [ - "load", - "help", - "ip", - "memswap", - "processlist", - ... - ] - - @apiError Cannot get plugin list. - - @apiErrorExample Error-Response: - HTTP/1.1 404 Not Found - """ - response.content_type = 'application/json; charset=utf-8' - - # Update the stat - self.__update__() - - try: - plist = json_dumps(self.plugins_list) - except Exception as e: - abort(404, "Cannot get plugin list (%s)" % str(e)) - return plist - - @compress - def _api_all(self): - """Glances API RESTful implementation. - - Return the JSON representation of all the plugins - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if self.args.debug: - fname = os.path.join(tempfile.gettempdir(), 'glances-debug.json') - try: - with open(fname) as f: - return f.read() - except IOError: - logger.debug("Debug file (%s) not found" % fname) - - # Update the stat - self.__update__() - - try: - # Get the JSON value of the stat ID - statval = json_dumps(self.stats.getAllAsDict()) - except Exception as e: - abort(404, "Cannot get stats (%s)" % str(e)) - - return statval - - @compress - def _api_all_limits(self): - """Glances API RESTful implementation. - - Return the JSON representation of all the plugins limits - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - try: - # Get the JSON value of the stat limits - limits = json_dumps(self.stats.getAllLimitsAsDict()) - except Exception as e: - abort(404, "Cannot get limits (%s)" % (str(e))) - return limits - - @compress - def _api_all_views(self): - """Glances API RESTful implementation. - - Return the JSON representation of all the plugins views - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - try: - # Get the JSON value of the stat view - limits = json_dumps(self.stats.getAllViewsAsDict()) - except Exception as e: - abort(404, "Cannot get views (%s)" % (str(e))) - return limits - - @compress - def _api(self, plugin): - """Glances API RESTful implementation. - - Return the JSON representation of a given plugin - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if plugin not in self.plugins_list: - abort(400, "Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) - - # Update the stat - self.__update__() - - try: - # Get the JSON value of the stat ID - statval = self.stats.get_plugin(plugin).get_stats() - except Exception as e: - abort(404, "Cannot get plugin %s (%s)" % (plugin, str(e))) - - return statval - - @compress - def _api_top(self, plugin, nb=0): - """Glances API RESTful implementation. - - Return the JSON representation of a given plugin limited to the top nb items. - It is used to reduce the payload of the HTTP response (example: processlist). - - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if plugin not in self.plugins_list: - abort(400, "Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) - - # Update the stat - self.__update__() - - try: - # Get the value of the stat ID - statval = self.stats.get_plugin(plugin).get_export() - except Exception as e: - abort(404, "Cannot get plugin %s (%s)" % (plugin, str(e))) - - if isinstance(statval, list): - return json_dumps(statval[:nb]) - else: - return json_dumps(statval) - - @compress - def _api_history(self, plugin, nb=0): - """Glances API RESTful implementation. - - Return the JSON representation of a given plugin history - Limit to the last nb items (all if nb=0) - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if plugin not in self.plugins_list: - abort(400, "Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) - - # Update the stat - self.__update__() - - try: - # Get the JSON value of the stat ID - statval = self.stats.get_plugin(plugin).get_stats_history(nb=int(nb)) - except Exception as e: - abort(404, "Cannot get plugin history %s (%s)" % (plugin, str(e))) - return statval - - @compress - def _api_limits(self, plugin): - """Glances API RESTful implementation. - - Return the JSON limits of a given plugin - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if plugin not in self.plugins_list: - abort(400, "Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) - - # Update the stat - # self.__update__() - - try: - # Get the JSON value of the stat limits - ret = self.stats.get_plugin(plugin).limits - except Exception as e: - abort(404, "Cannot get limits for plugin %s (%s)" % (plugin, str(e))) - return ret - - @compress - def _api_views(self, plugin): - """Glances API RESTful implementation. - - Return the JSON views of a given plugin - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if plugin not in self.plugins_list: - abort(400, "Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) - - # Update the stat - # self.__update__() - - try: - # Get the JSON value of the stat views - ret = self.stats.get_plugin(plugin).get_views() - except Exception as e: - abort(404, "Cannot get views for plugin %s (%s)" % (plugin, str(e))) - return ret - - # No compression see issue #1228 - # @compress - def _api_itemvalue(self, plugin, item, value=None, history=False, nb=0): - """Father method for _api_item and _api_value.""" - response.content_type = 'application/json; charset=utf-8' - - if plugin not in self.plugins_list: - abort(400, "Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) - - # Update the stat - self.__update__() - - if value is None: - if history: - ret = self.stats.get_plugin(plugin).get_stats_history(item, nb=int(nb)) - else: - ret = self.stats.get_plugin(plugin).get_stats_item(item) - - if ret is None: - abort(404, "Cannot get item %s%s in plugin %s" % (item, 'history ' if history else '', plugin)) - else: - if history: - # Not available - ret = None - else: - ret = self.stats.get_plugin(plugin).get_stats_value(item, value) - - if ret is None: - abort( - 404, "Cannot get item %s(%s=%s) in plugin %s" % ('history ' if history else '', item, value, plugin) - ) - - return ret - - @compress - def _api_item(self, plugin, item): - """Glances API RESTful implementation. - - Return the JSON representation of the couple plugin/item - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - - """ - return self._api_itemvalue(plugin, item) - - @compress - def _api_item_history(self, plugin, item, nb=0): - """Glances API RESTful implementation. - - Return the JSON representation of the couple plugin/history of item - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - - """ - return self._api_itemvalue(plugin, item, history=True, nb=int(nb)) - - @compress - def _api_value(self, plugin, item, value): - """Glances API RESTful implementation. - - Return the process stats (dict) for the given item=value - HTTP/200 if OK - HTTP/400 if plugin is not found - HTTP/404 if others error - """ - return self._api_itemvalue(plugin, item, value) - - @compress - def _api_config(self): - """Glances API RESTful implementation. - - Return the JSON representation of the Glances configuration file - HTTP/200 if OK - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - try: - # Get the JSON value of the config' dict - args_json = json_dumps(self.config.as_dict()) - except Exception as e: - abort(404, "Cannot get config (%s)" % str(e)) - return args_json - - @compress - def _api_config_item(self, item): - """Glances API RESTful implementation. - - Return the JSON representation of the Glances configuration item - HTTP/200 if OK - HTTP/400 if item is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - config_dict = self.config.as_dict() - if item not in config_dict: - abort(400, "Unknown configuration item %s" % item) - - try: - # Get the JSON value of the config' dict - args_json = json_dumps(config_dict[item]) - except Exception as e: - abort(404, "Cannot get config item (%s)" % str(e)) - return args_json - - @compress - def _api_args(self): - """Glances API RESTful implementation. - - Return the JSON representation of the Glances command line arguments - HTTP/200 if OK - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - try: - # Get the JSON value of the args' dict - # Use vars to convert namespace to dict - # Source: https://docs.python.org/%s/library/functions.html#vars - args_json = json_dumps(vars(self.args)) - except Exception as e: - abort(404, "Cannot get args (%s)" % str(e)) - return args_json - - @compress - def _api_args_item(self, item): - """Glances API RESTful implementation. - - Return the JSON representation of the Glances command line arguments item - HTTP/200 if OK - HTTP/400 if item is not found - HTTP/404 if others error - """ - response.content_type = 'application/json; charset=utf-8' - - if item not in self.args: - abort(400, "Unknown argument item %s" % item) - - try: - # Get the JSON value of the args' dict - # Use vars to convert namespace to dict - # Source: https://docs.python.org/%s/library/functions.html#vars - args_json = json_dumps(vars(self.args)[item]) - except Exception as e: - abort(404, "Cannot get args item (%s)" % str(e)) - return args_json - - -class EnableCors(object): - name = 'enable_cors' - api = 2 - - def apply(self, fn, context): - def _enable_cors(*args, **kwargs): - # set CORS headers - response.headers['Access-Control-Allow-Origin'] = '*' - response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, OPTIONS' - response.headers[ - 'Access-Control-Allow-Headers' - ] = 'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token' - - if request.method != 'OPTIONS': - # actual request; reply with the actual response - return fn(*args, **kwargs) - - return _enable_cors diff --git a/glances/outputs/static/README.md b/glances/outputs/static/README.md index bac2decb..5e8e7838 100644 --- a/glances/outputs/static/README.md +++ b/glances/outputs/static/README.md @@ -65,7 +65,7 @@ static | |--- public # path where builds are put | -|--- templates (bottle) +|--- templates ``` ## Data diff --git a/glances/plugins/processlist/__init__.py b/glances/plugins/processlist/__init__.py index 99219fcb..ac6ade93 100644 --- a/glances/plugins/processlist/__init__.py +++ b/glances/plugins/processlist/__init__.py @@ -470,7 +470,7 @@ class PluginModel(GlancesPluginModel): # Process list # Loop over processes (sorted by the sort key previously compute) # This is a Glances bottleneck (see flame graph), - # get_process_curses_data should be optimzed + # TODO: get_process_curses_data should be optimzed for position, process in enumerate(processes_list_sorted): ret.extend(self.get_process_curses_data(process, position == args.cursor_position, args)) diff --git a/glances/webserver.py b/glances/webserver.py index c6da8cba..44c24ec2 100644 --- a/glances/webserver.py +++ b/glances/webserver.py @@ -2,17 +2,17 @@ # # This file is part of Glances. # -# SPDX-FileCopyrightText: 2022 Nicolas Hennion +# SPDX-FileCopyrightText: 2023 Nicolas Hennion # # SPDX-License-Identifier: LGPL-3.0-only # -"""Glances Web Interface (Bottle based).""" +"""Glances Restful/API and Web based interface.""" from glances.globals import WINDOWS from glances.processes import glances_processes from glances.stats import GlancesStats -from glances.outputs.glances_bottle import GlancesBottle +from glances.outputs.glances_restful_api import GlancesRestfulApi class GlancesWebServer(object): @@ -30,8 +30,8 @@ class GlancesWebServer(object): # Initial system information update self.stats.update() - # Init the Bottle Web server - self.web = GlancesBottle(config=config, args=args) + # Init the Web server + self.web = GlancesRestfulApi(config=config, args=args) def serve_forever(self): """Main loop for the Web server.""" diff --git a/optional-requirements.txt b/optional-requirements.txt index 0de8809c..ef1cb735 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -3,11 +3,11 @@ batinfo bernhard -bottle cassandra-driver chevron docker>=6.1.1 elasticsearch +fastapi; python_version >= "3.8" graphitesender hddtemp influxdb>=1.0.0 # For InfluxDB < 1.8 @@ -34,6 +34,7 @@ scandir; python_version < "3.5" six sparklines statsd +uvicorn; python_version >= "3.8" wifi zeroconf==0.112.0; python_version < "3.7" zeroconf; python_version >= "3.7" diff --git a/requirements.txt b/requirements.txt index 355233dc..7e241a84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ psutil>=5.6.7 defusedxml packaging -ujson<4; python_version >= "3.5" and python_version < "3.6" -ujson<5; python_version >= "3.6" and python_version < "3.7" -ujson>=5.4.0; python_version >= "3.7" +ujson>=5.4.0 pytz diff --git a/setup.py b/setup.py index 092a3a82..6049b513 100755 --- a/setup.py +++ b/setup.py @@ -44,7 +44,8 @@ def get_install_requires(): 'ujson>=5.4.0', ] if sys.platform.startswith('win'): - requires.append('bottle') + requires.append('fastapi') + requires.append('uvicorn') requires.append('requests') return requires @@ -67,7 +68,7 @@ def get_install_extras_require(): 'smart': ['pySMART.smartx'], 'snmp': ['pysnmp'], 'sparklines': ['sparklines'], - 'web': ['bottle', 'requests'], + 'web': ['fastapi', 'uvicorn', 'requests'], 'wifi': ['wifi'] } if sys.platform.startswith('linux'): @@ -123,7 +124,7 @@ setup( 'Development Status :: 5 - Production/Stable', 'Environment :: Console :: Curses', 'Environment :: Web Environment', - 'Framework :: Bottle', + 'Framework :: FastAPI', 'Intended Audience :: Developers', 'Intended Audience :: End Users/Desktop', 'Intended Audience :: System Administrators', diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 4af96e96..7185846c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -53,16 +53,28 @@ parts: override-pull: | snapcraftctl pull "$SNAPCRAFT_STAGE"/scriptlets/selective-checkout - bottle: + + fastapi: + plugin: python + source: https://github.com/tiangolo/fastapi.git + source-tag: '0.104.1' + source-depth: 1 + override-build: | + mkdir -p $SNAPCRAFT_PART_BUILD/dist + cp -r $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/fastapi-dist + organize: + fastapi-dist: fastapi/dist + + uvicorn: plugin: python - source: https://github.com/bottlepy/bottle.git - source-branch: release-0.12 + source: https://github.com/encode/uvicorn.git + source-tag: '0.24.0.post1' source-depth: 1 override-build: | mkdir -p $SNAPCRAFT_PART_BUILD/dist - cp -r $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/bottle-dist + cp -r $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/uvicorn-dist organize: - bottle-dist: bottle/dist + uvicorn-dist: uvicorn/dist docker: plugin: python diff --git a/tox.ini b/tox.ini index 6c9d454c..30a67b06 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,8 @@ deps = defusedxml packaging ujson - bottle + fastapi + uvicorn requests commands = python unitest.py diff --git a/webui-requirements.txt b/webui-requirements.txt index 465b985d..309e06cf 100644 --- a/webui-requirements.txt +++ b/webui-requirements.txt @@ -1,4 +1,5 @@ # install with base requirements file -r requirements.txt -bottle +fastapi; python_version >= "3.8" +uvicorn; python_version >= "3.8" -- cgit v1.2.3 From c28089ae478388d28e375113a8f84b0924fbce2e Mon Sep 17 00:00:00 2001 From: nicolargo Date: Sun, 26 Nov 2023 22:40:36 +0100 Subject: First step of the migration --- README.rst | 4 +++- glances/globals.py | 14 +++++++++++--- glances/main.py | 2 +- glances/plugins/plugin/model.py | 26 ++++++++++++++++++++++---- optional-requirements.txt | 2 ++ setup.py | 4 +++- snap/snapcraft.yaml | 22 ++++++++++++++++++++++ tox.ini | 2 ++ webui-requirements.txt | 2 ++ 9 files changed, 68 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 50c9c0f3..5192ae9c 100644 --- a/README.rst +++ b/README.rst @@ -101,8 +101,10 @@ Optional dependencies: - ``hddtemp`` (for HDD temperature monitoring support) [Linux-only] - ``influxdb`` (for the InfluxDB version 1 export module) - ``influxdb-client`` (for the InfluxDB version 2 export module) +- ``jinja2`` (for templating, used under the hood by FastAPI) - ``kafka-python`` (for the Kafka export module) - ``netifaces`` (for the IP plugin) +- ``orjson`` (fast JSON library, used under the hood by FastAPI) - ``py3nvml`` (for the GPU plugin) - ``pycouchdb`` (for the CouchDB export module) - ``pika`` (for the RabbitMQ/ActiveMQ export module) @@ -319,7 +321,7 @@ Start Termux on your device and enter: $ apt update $ apt upgrade $ apt install clang python - $ pip install fastapi uvicorn + $ pip install fastapi uvicorn orjson jinja2 $ pip install glances And start Glances: diff --git a/glances/globals.py b/glances/globals.py index 87a48817..ccf3f5be 100644 --- a/glances/globals.py +++ b/glances/globals.py @@ -315,10 +315,10 @@ def json_dumps(data): return ujson.dumps(data, ensure_ascii=False) -def json_dumps_dictlist(data, item): +def dictlist(data, item): if isinstance(data, dict): try: - return json_dumps({item: data[item]}) + return {item: data[item]} except (TypeError, IndexError, KeyError): return None elif isinstance(data, list): @@ -326,13 +326,21 @@ def json_dumps_dictlist(data, item): # Source: # http://stackoverflow.com/questions/4573875/python-get-index-of-dictionary-item-in-list # But https://github.com/nicolargo/glances/issues/1401 - return json_dumps({item: list(map(itemgetter(item), data))}) + return {item: list(map(itemgetter(item), data))} except (TypeError, IndexError, KeyError): return None else: return None +def json_dumps_dictlist(data, item): + dl = dictlist(data, item) + if dl is None: + return None + else: + return json_dumps(dl) + + def string_value_to_float(s): """Convert a string with a value and an unit to a float. Example: diff --git a/glances/main.py b/glances/main.py index 85db14aa..52c9439f 100644 --- a/glances/main.py +++ b/glances/main.py @@ -363,7 +363,7 @@ Examples of use: action='store_true', default=False, dest='webserver', - help='run Glances in web server mode (FastAPI and Uvicorn lib needed)', + help='run Glances in web server mode (FastAPI, Uvicorn, Jinja2 and OrJsonLib needed)', ) parser.add_argument( '--cached-time', diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index aa67bcb0..b25c2174 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -16,7 +16,7 @@ I am your father... import re import copy -from glances.globals import iterkeys, itervalues, listkeys, mean, nativestr, json_dumps, json_dumps_dictlist +from glances.globals import iterkeys, itervalues, listkeys, mean, nativestr, json_dumps, json_dumps_dictlist, dictlist from glances.actions import GlancesActions from glances.history import GlancesHistory from glances.logger import logger @@ -395,6 +395,13 @@ class GlancesPluginModel(object): """Return the stats object in JSON format.""" return self.get_stats() + def get_raw_stats_item(self, item): + """Return the stats object for a specific item in RAW format. + + Stats should be a list of dict (processlist, network...) + """ + return dictlist(self.stats, item) + def get_stats_item(self, item): """Return the stats object for a specific item in JSON format. @@ -402,8 +409,8 @@ class GlancesPluginModel(object): """ return json_dumps_dictlist(self.stats, item) - def get_stats_value(self, item, value): - """Return the stats object for a specific item=value in JSON format. + def get_raw_stats_value(self, item, value): + """Return the stats object for a specific item=value. Stats should be a list of dict (processlist, network...) """ @@ -413,11 +420,22 @@ class GlancesPluginModel(object): if not isinstance(value, int) and value.isdigit(): value = int(value) try: - return json_dumps({value: [i for i in self.stats if i[item] == value]}) + return {value: [i for i in self.stats if i[item] == value]} except (KeyError, ValueError) as e: logger.error("Cannot get item({})=value({}) ({})".format(item, value, e)) return None + def get_stats_value(self, item, value): + """Return the stats object for a specific item=value in JSON format. + + Stats should be a list of dict (processlist, network...) + """ + rsv = self.get_raw_stats_value(item, value) + if rsv is None: + return None + else: + return json_dumps(rsv) + def update_views_hidden(self): """Update the hidden views diff --git a/optional-requirements.txt b/optional-requirements.txt index ef1cb735..5d9ba5e3 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -12,8 +12,10 @@ graphitesender hddtemp influxdb>=1.0.0 # For InfluxDB < 1.8 influxdb-client; python_version >= "3.7" # For InfluxDB >= 1.8 +jinja2 kafka-python netifaces +orjson; python_version >= "3.8" packaging; python_version >= "3.7" paho-mqtt pika diff --git a/setup.py b/setup.py index 6049b513..c32e6e86 100755 --- a/setup.py +++ b/setup.py @@ -46,6 +46,8 @@ def get_install_requires(): if sys.platform.startswith('win'): requires.append('fastapi') requires.append('uvicorn') + requires.append('orjson') + requires.append('jinja2') requires.append('requests') return requires @@ -68,7 +70,7 @@ def get_install_extras_require(): 'smart': ['pySMART.smartx'], 'snmp': ['pysnmp'], 'sparklines': ['sparklines'], - 'web': ['fastapi', 'uvicorn', 'requests'], + 'web': ['fastapi', 'uvicorn', 'jinja2', 'orjson', 'requests'], 'wifi': ['wifi'] } if sys.platform.startswith('linux'): diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7185846c..b230edaa 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -76,6 +76,28 @@ parts: organize: uvicorn-dist: uvicorn/dist + orjson: + plugin: python + source: https://github.com/ijl/orjson.git + source-tag: '3.9.10' + source-depth: 1 + override-build: | + mkdir -p $SNAPCRAFT_PART_BUILD/dist + cp -r $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/orjson-dist + organize: + orjson-dist: orjson/dist + + jinja2: + plugin: python + source: https://github.com/pallets/jinja.git + source-tag: '3.1.2' + source-depth: 1 + override-build: | + mkdir -p $SNAPCRAFT_PART_BUILD/dist + cp -r $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/jinja2-dist + organize: + jinja2-dist: jinja2/dist + docker: plugin: python source: https://github.com/docker/docker-py.git diff --git a/tox.ini b/tox.ini index 30a67b06..f214f405 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,8 @@ deps = ujson fastapi uvicorn + orjson + jinja2 requests commands = python unitest.py diff --git a/webui-requirements.txt b/webui-requirements.txt index 309e06cf..e24c83a0 100644 --- a/webui-requirements.txt +++ b/webui-requirements.txt @@ -3,3 +3,5 @@ fastapi; python_version >= "3.8" uvicorn; python_version >= "3.8" +orjson; python_version >= "3.8" +jinja2 \ No newline at end of file -- cgit v1.2.3 From 322d8c715aa749ad804ffc8e154a2c1e363fb9a8 Mon Sep 17 00:00:00 2001 From: nicolargo Date: Sun, 26 Nov 2023 22:41:16 +0100 Subject: First step of the migration --- glances/outputs/glances_restful_api.py | 725 +++++++++++++++++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 glances/outputs/glances_restful_api.py diff --git a/glances/outputs/glances_restful_api.py b/glances/outputs/glances_restful_api.py new file mode 100644 index 00000000..06ab36f8 --- /dev/null +++ b/glances/outputs/glances_restful_api.py @@ -0,0 +1,725 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Glances. +# +# SPDX-FileCopyrightText: 2023 Nicolas Hennion +# +# SPDX-License-Identifier: LGPL-3.0-only +# + +"""RestFull API interface class.""" + +import os +import sys +import tempfile +from io import open +import webbrowser +import socket +from urllib.parse import urljoin +# Replace typing_extensions by typing when Python 3.8 support will be dropped +# from typing import Annotated +from typing_extensions import Annotated + +from glances.globals import json_dumps +from glances.timer import Timer +from glances.logger import logger + +# FastAPI import + +# TODO: not sure import is needed +try: + import jinja2 +except ImportError: + logger.critical('Jinja2 import error. Glances cannot start in web server mode.') + sys.exit(2) + +try: + from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, Request + from fastapi.security import HTTPBasic, HTTPBasicCredentials + from fastapi.middleware.cors import CORSMiddleware + from fastapi.middleware.gzip import GZipMiddleware + from fastapi.responses import HTMLResponse, ORJSONResponse + from fastapi.templating import Jinja2Templates + from fastapi.staticfiles import StaticFiles +except ImportError: + logger.critical('FastAPI import error. Glances cannot start in web server mode.') + sys.exit(2) + +try: + import uvicorn +except ImportError: + logger.critical('Uvicorn import error. Glances cannot start in web server mode.') + sys.exit(2) + +security = HTTPBasic() + + +class GlancesRestfulApi(object): + """This class manages the Restful API server.""" + + API_VERSION = '3' + + def __init__(self, config=None, args=None): + # Init config + self.config = config + + # Init args + self.args = args + + # Init stats + # Will be updated within Bottle route + self.stats = None + + # cached_time is the minimum time interval between stats updates + # i.e. HTTP/RESTful calls will not retrieve updated info until the time + # since last update is passed (will retrieve old cached info instead) + self.timer = Timer(0) + + # Load configuration file + self.load_config(config) + + # Set the bind URL (only used for log information purpose) + self.bind_url = urljoin('http://{}:{}/'.format(self.args.bind_address, + self.args.port), + self.url_prefix) + + # FastAPI Init + self._app = FastAPI(dependencies=[Depends(self.authentication)]) + if self.url_prefix != '/': + self._app.include_router(APIRouter(prefix=self.url_prefix)) + + # Set path for WebUI + # self.STATIC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static/public') + self.STATIC_PATH = 'static/public' + # TEMPLATE_PATH.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static/templates')) + self.TEMPLATE_PATH = 'static/templates' + self._templates = Jinja2Templates(directory=self.TEMPLATE_PATH) + + # FastAPI Enable CORS + # https://fastapi.tiangolo.com/tutorial/cors/ + self._app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # FastAPI Enable GZIP compression + # https://fastapi.tiangolo.com/advanced/middleware/ + self._app.add_middleware(GZipMiddleware, + minimum_size=1000) + + # FastAPI Define routes + self._app.include_router(self._router()) + + def load_config(self, config): + """Load the outputs section of the configuration file.""" + # Limit the number of processes to display in the WebUI + self.url_prefix = '/' + if config is not None and config.has_section('outputs'): + n = config.get_value('outputs', 'max_processes_display', default=None) + logger.debug('Number of processes to display in the WebUI: {}'.format(n)) + self.url_prefix = config.get_value('outputs', 'url_prefix', default='/') + logger.debug('URL prefix: {}'.format(self.url_prefix)) + + def __update__(self): + # Never update more than 1 time per cached_time + if self.timer.finished(): + self.stats.update() + self.timer = Timer(self.args.cached_time) + + def app(self): + return self._app() + + # TODO: the password comparaison is not working for the moment. + # Perahps because the password is hashed in the GlancesPassword class + # and the one given by creds.password is not hashed ? + def authentication(self, creds: Annotated[HTTPBasicCredentials, Depends(security)]): + """Check if a username/password combination is valid.""" + # print(creds.username, creds.password) + # print(self.args.username, self.args.password) + if creds.username == self.args.username: + from glances.password import GlancesPassword + + pwd = GlancesPassword(username=creds.username, config=self.config) + # print(self.args.password, pwd.get_hash(creds.username)) + return pwd.check_password(self.args.password, pwd.get_hash(creds.username)) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + + def _router(self): + """Define a custom router for Glances path.""" + router = APIRouter() + + # REST API + router.add_api_route('/api/%s/status' % self.API_VERSION, + status_code=status.HTTP_200_OK, + response_class=ORJSONResponse, + endpoint=self._api_status) + router.add_api_route('/api/%s/config' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_config) + router.add_api_route('/api/%s/config/{item}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_config_item) + router.add_api_route('/api/%s/args' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_args) + router.add_api_route('/api/%s/args/{item}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_args_item) + router.add_api_route('/api/%s/help' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_help) + router.add_api_route('/api/%s/pluginslist' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_plugins) + router.add_api_route('/api/%s/all' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_all) + router.add_api_route('/api/%s/all/limits' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_all_limits) + router.add_api_route('/api/%s/all/views' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_all_views) + router.add_api_route('/api/%s/{plugin}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api) + router.add_api_route('/api/%s/{plugin}/history' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_history) + router.add_api_route('/api/%s/{plugin}/history/{nb}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_history) + router.add_api_route('/api/%s/{plugin}/top/' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_top) + router.add_api_route('/api/%s/{plugin}/limits' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_limits) + router.add_api_route('/api/%s/{plugin}/views' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_views) + router.add_api_route('/api/%s/{plugin}/{item}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_item) + router.add_api_route('/api/%s/{plugin}/{item}/history' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_item_history) + router.add_api_route('/api/%s/{plugin}/{item}/history/{nb}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_item_history) + router.add_api_route('/api/%s/{plugin}/{item}/{value}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_value) + + # Restful API + bindmsg = 'Glances RESTful API Server started on {}api/{}'.format(self.bind_url, self.API_VERSION) + logger.info(bindmsg) + + # WEB UI + if not self.args.disable_webui: + # Template + router.add_api_route('/', + response_class=HTMLResponse, + endpoint=self._index) + + # TODO: to be migrated to another route + # router.add_api_route('/{refresh_time}', + # endpoint=self._index) + + # Statics files + # self._app.mount("/static", StaticFiles(directory=self.STATIC_PATH), name="static") + self._app.mount("/", + StaticFiles(directory=self.STATIC_PATH), + name="static") + + bindmsg = 'Glances Web User Interface started on {}'.format(self.bind_url) + else: + bindmsg = 'The WebUI is disable (--disable-webui)' + + logger.info(bindmsg) + print(bindmsg) + + return router + + def start(self, stats): + """Start the bottle.""" + # Init stats + self.stats = stats + + # Init plugin list + self.plugins_list = self.stats.getPluginsList() + + # Bind the Bottle TCP address/port + if self.args.open_web_browser: + # Implementation of the issue #946 + # Try to open the Glances Web UI in the default Web browser if: + # 1) --open-web-browser option is used + # 2) Glances standalone mode is running on Windows OS + webbrowser.open(self.bind_url, new=2, autoraise=1) + + # Run the Web application + try: + uvicorn.run(self._app, + host=self.args.bind_address, + port=self.args.port, + access_log=self.args.debug) + except socket.error as e: + logger.critical('Error: Can not ran Glances Web server ({})'.format(e)) + + def end(self): + """End the bottle.""" + logger.info("Close the Web server") + # TODO: close FastAPI instance gracefully + # self._app.close() + # if self.url_prefix != '/': + # self.main_app.close() + + # Example from FastAPI documentation + # @app.get("/", response_class=HTMLResponse) + # def home(request: Request): + # return templates.TemplateResponse("index.html", {"request": request}) + + def _index(self, refresh_time=None): + """Return main index.html (/) file.""" + + if refresh_time is None or refresh_time < 1: + refresh_time = int(self.args.time) + + # Update the stat + self.__update__() + + # Display + # return template("index.html", refresh_time=refresh_time) + return self.templates.TemplateResponse("index.html") + + def _api_status(self): + """Glances API RESTful implementation. + + Return a 200 status code. + This entry point should be used to check the API health. + + See related issue: Web server health check endpoint #1988 + """ + + # TODO: return a more useful status + return "Active" + + def _api_help(self): + """Glances API RESTful implementation. + + Return the help data or 404 error. + """ + try: + plist = self.stats.get_plugin("help").get_view_data() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get help view data (%s)" % str(e)) + + return ORJSONResponse(plist) + + def _api_plugins(self): + """Glances API RESTFul implementation. + + @api {get} /api/%s/pluginslist Get plugins list + @apiVersion 2.0 + @apiName pluginslist + @apiGroup plugin + + @apiSuccess {String[]} Plugins list. + + @apiSuccessExample Success-Response: + HTTP/1.1 200 OK + [ + "load", + "help", + "ip", + "memswap", + "processlist", + ... + ] + + @apiError Cannot get plugin list. + + @apiErrorExample Error-Response: + HTTP/1.1 404 Not Found + """ + # Update the stat + self.__update__() + + try: + plist = self.plugins_list + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get plugin list (%s)" % str(e)) + + return ORJSONResponse(plist) + + def _api_all(self): + """Glances API RESTful implementation. + + Return the JSON representation of all the plugins + HTTP/200 if OK + HTTP/400 if plugin is not found + HTTP/404 if others error + """ + if self.args.debug: + fname = os.path.join(tempfile.gettempdir(), 'glances-debug.json') + try: + with open(fname) as f: + return f.read() + except IOError: + logger.debug("Debug file (%s) not found" % fname) + + # Update the stat + self.__update__() + + try: + # Get the RAW value of the stat ID + statval = self.stats.getAllAsDict() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get stats (%s)" % str(e)) + + return ORJSONResponse(statval) + + def _api_all_limits(self): + """Glances API RESTful implementation. + + Return the JSON representation of all the plugins limits + HTTP/200 if OK + HTTP/400 if plugin is not found + HTTP/404 if others error + """ + try: + # Get the RAW value of the stat limits + limits = self.stats.getAllLimitsAsDict() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get limits (%s)" % str(e)) + + return ORJSONResponse(limits) + + def _api_all_views(self): + """Glances API RESTful implementation. + + Return the JSON representation of all the plugins views + HTTP/200 if OK + HTTP/400 if plugin is not found + HTTP/404 if others error + """ + try: + # Get the RAW value of the stat view + limits = self.stats.getAllViewsAsDict() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get views (%s)" % str(e)) + + return ORJSONResponse(limits) + + def _api(self, plugin): + """Glances API RESTful implementation. + + Return the JSON representation of a given plugin + HTTP/200 if OK + HTTP/400 if plugin is not found + HTTP/404 if others error + """ + if plugin not in self.plugins_list: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) + + # Update the stat + self.__update__() + + try: + # Get the RAW value of the stat ID + statval = self.stats.get_plugin(plugin).get_raw() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get plugin %s (%s)" % (plugin, str(e))) + + return ORJSONResponse(statval) + + def _api_top(self, plugin, nb: int = 0): + """Glances API RESTful implementation. + + Return the JSON representation of a given plugin limited to the top nb items. + It is used to reduce the payload of the HTTP response (example: processlist). + + HTTP/200 if OK + HTTP/400 if plugin is not found + HTTP/404 if others error + """ + if plugin not in self.plugins_list: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) + + # Update the stat + self.__update__() + + try: + # Get the RAW value of the stat ID + statval = self.stats.get_plugin(plugin).get_export() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get plugin %s (%s)" % (plugin, str(e))) + + if isinstance(statval, list): + statval = statval[:nb] + + return ORJSONResponse(statval) + + def _api_history(self, plugin, nb: int = 0): + """Glances API RESTful implementation. + + Return the JSON representation of a given plugin history + Limit to the last nb items (all if nb=0) + HTTP/200 if OK + HTTP/400 if plugin is not found + HTTP/404 if others error + """ + if plugin not in self.plugins_list: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unknown plugin %s (available plugins: %s)" % (plugin, self.plugins_list)) + + # Update the stat + sel