diff options
Diffstat (limited to 'glances/outputs/glances_restful_api.py')
-rw-r--r-- | glances/outputs/glances_restful_api.py | 740 |
1 files changed, 740 insertions, 0 deletions
diff --git a/glances/outputs/glances_restful_api.py b/glances/outputs/glances_restful_api.py new file mode 100644 index 00000000..a0c994e6 --- /dev/null +++ b/glances/outputs/glances_restful_api.py @@ -0,0 +1,740 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Glances. +# +# SPDX-FileCopyrightText: 2023 Nicolas Hennion <nicolas@nicolargo.com> +# +# 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 glances import __version__, __apiversion__ +from glances.password import GlancesPassword +from glances.timer import Timer +from glances.logger import logger + +# FastAPI import +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 = __apiversion__ + + 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 + self.bind_url = urljoin('http://{}:{}/'.format(self.args.bind_address, self.args.port), self.url_prefix) + + # FastAPI Init + if self.args.password: + self._app = FastAPI(dependencies=[Depends(self.authentication)]) + self._password = GlancesPassword(username=args.username, config=config) + + else: + self._app = FastAPI() + self._password = None + + # Change the default root path + 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.TEMPLATE_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '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_origins=[self.bind_url], + 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() + + def authentication(self, creds: Annotated[HTTPBasicCredentials, Depends(security)]): + """Check if a username/password combination is valid.""" + if creds.username == self.args.username: + # check_password and get_hash are (lru) cached to optimize the requests + if self._password.check_password(self.args.password, self._password.get_hash(creds.password)): + return creds.username + + # If the username/password combination is invalid, return an HTTP 401 + 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/{section}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_config_section, + ) + router.add_api_route( + '/api/%s/config/{section}/{item}' % self.API_VERSION, + response_class=ORJSONResponse, + endpoint=self._api_config_section_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/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/help' % self.API_VERSION, response_class=ORJSONResponse, endpoint=self._api_help) + 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/{nb}' % 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 for the root index.html file + router.add_api_route('/', response_class=HTMLResponse, endpoint=self._index) + + # Statics files + self._app.mount("/static", 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 Web server""" + logger.info("Close the Web server") + + def _index(self, request: Request): + """Return main index.html (/) file. + + Parameters are available through the request object. + Example: http://localhost:61208/?refresh=5 + + Note: This function is only called the first time the page is loaded. + """ + refresh_time = request.query_params.get('refresh', default=max(1, int(self.args.time))) + + # Update the stat + self.__update__() + + # Display + return self._templates.TemplateResponse( + "index.html", + { + "request": request, + "refresh_time": refresh_time, + }, + ) + + 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 + """ + + return ORJSONResponse({'version': __version__}) + + 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 + self.__update__() + + try: + # Get the RAW value of the stat ID + statval = self.stats.get_plugin(plugin).get_raw_history(nb=int(nb)) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get plugin history %s (%s)" % (plugin, str(e)) + ) + + return statval + + 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 + """ + 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), + ) + + try: + # Get the RAW value of the stat limits + ret = self.stats.get_plugin(plugin).limits + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get limits for plugin %s (%s)" % (plugin, str(e)) + ) + + return ORJSONResponse(ret) + + 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 + """ + 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), + ) + + try: + # Get the RAW value of the stat views + ret = self.stats.get_plugin(plugin).get_views() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get views for plugin %s (%s)" % (plugin, str(e)) + ) + + return ORJSONResponse(ret) + + 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 + """ + 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 views + ret = self.stats.get_plugin(plugin).get_raw_stats_item(item) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get item %s in plugin %s (%s)" % (item, plugin, str(e)), + ) + + return ORJSONResponse(ret) + + def _api_item_history(self, plugin, item, nb: int = 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 + + """ + 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 history + ret = self.stats.get_plugin(plugin).get_raw_history(item, nb=nb) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get history for plugin %s (%s)" % (plugin, str(e)) + ) + + return ORJSONResponse(ret) + + 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 + """ + 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 + ret = self.stats.get_plugin(plugin).get_raw_stats_value(item, value) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get %s = %s for plugin %s (%s)" % (item, value, plugin, str(e)), + ) + + return ORJSONResponse(ret) + + 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 + """ + try: + # Get the RAW value of the config' dict + args_json = self.config.as_dict() + except Exception as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get config (%s)" % str(e)) + + return ORJSONResponse(args_json) + + def _api_config_section(self, section): + """Glances API RESTful implementation. + + Return the JSON representation of the Glances configuration section + HTTP/200 if OK + HTTP/400 if item is not found + HTTP/404 if others error + """ + config_dict = self.config.as_dict() + if section not in config_dict: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown configuration item %s" % section + ) + + try: + # Get the RAW value of the config' dict + ret_section = config_dict[section] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get config section %s (%s)" % (section, str(e)) + ) + + return ORJSONResponse(ret_section) + + def _api_config_section_item(self, section, item): + """Glances API RESTful implementation. + + Return the JSON representation of the Glances configuration section/item + HTTP/200 if OK + HTTP/400 if item is not found + HTTP/404 if others error + """ + config_dict = self.config.as_dict() + if section not in config_dict: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown configuration item %s" % section + ) + + try: + # Get the RAW value of the config' dict section + ret_section = config_dict[section] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get config section %s (%s)" % (section, str(e)) + ) + + try: + # Get the RAW value of the config' dict item + ret_item = ret_section[item] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cannot get item %s in config section %s (%s)" % (item, section, str(e)), + ) + + return ORJSONResponse(ret_item) + + 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 + """ + try: + # Get the RAW value of the args' dict + # Use vars to convert namespace to dict + # Source: https://docs.python.org/%s/library/functions.html#vars + args_json = vars(self.args) + except Exception as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get args (%s)" % str(e)) + + return ORJSONResponse(args_json) + + 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 + """ + if item not in self.args: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown argument item %s" % item) + + try: + # Get the RAW value of the args' dict + # Use vars to convert namespace to dict + # Source: https://docs.python.org/%s/library/functions.html#vars + args_json = vars(self.args)[item] + except Exception as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cannot get args item (%s)" % str(e)) + + return ORJSONResponse(args_json) |