Merge branch 'issue2181' into develop

This commit is contained in:
nicolargo 2023-12-16 14:50:57 +01:00
commit 32b33b5883
41 changed files with 1442 additions and 1135 deletions

View File

@ -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/bin/python ./unitest-restful.py
# ===================================================================
# Linters, profilers and cyber security
# ===================================================================

View File

@ -92,17 +92,19 @@ 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)
- ``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)
@ -207,10 +209,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 +321,7 @@ Start Termux on your device and enter:
$ apt update
$ apt upgrade
$ apt install clang python
$ pip install bottle
$ pip install fastapi uvicorn orjson jinja2
$ pip install glances
And start Glances:

View File

@ -23,7 +23,7 @@ history_size=1200
# Theme name for the Curses interface: black or white
curse_theme=black
# Limit the number of processes to display (for the WebUI)
max_processes_display=30
max_processes_display=25
# Set the URL prefix (for the WebUI and the API)
# Example: url_prefix=/glances/ => http://localhost/glances/
# The final / is mandatory

View File

@ -23,7 +23,7 @@ history_size=1200
# Theme name for the Curses interface: black or white
curse_theme=black
# Limit the number of processes to display (for the WebUI)
max_processes_display=30
max_processes_display=25
# Set the URL prefix (for the WebUI and the API)
# Example: url_prefix=/glances/ => http://localhost/glances/
# The final / is mandatory

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -30,7 +30,7 @@ Available tags (all images are based on both Alpine and Ubuntu Operating Systems
* - `latest`
- Alpine
- Latest Release
- Minimal + (Bottle & Docker)
- Minimal + (FastAPI & Docker)
* - `dev`
- Alpine
- develop
@ -42,7 +42,7 @@ Available tags (all images are based on both Alpine and Ubuntu Operating Systems
* - `ubuntu-latest`
- Ubuntu
- Latest Release
- Minimal + (Bottle & Docker)
- Minimal + (FastAPI & Docker)
* - `ubuntu-dev`
- Ubuntu
- develop

View File

@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
..
.TH "GLANCES" "1" "Dec 10, 2023" "4.0.0_beta01" "Glances"
.TH "GLANCES" "1" "Dec 16, 2023" "4.0.0_beta01" "Glances"
.SH NAME
glances \- An eye on your system
.SH SYNOPSIS
@ -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

View File

@ -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

View File

@ -36,6 +36,7 @@ class GlancesExport(object):
'processlist',
'psutilversion',
'quicklook',
'version'
]
def __init__(self, config=None, args=None):

View File

@ -25,6 +25,8 @@ import subprocess
from datetime import datetime
import re
import base64
import functools
import weakref
import queue
from configparser import ConfigParser, NoOptionError, NoSectionError
@ -315,10 +317,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 +328,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:
@ -398,3 +408,21 @@ def folder_size(path, errno=0):
except OSError as e:
ret_err = e.errno
return ret_size, ret_err
def weak_lru_cache(maxsize=128, typed=False):
"""LRU Cache decorator that keeps a weak reference to self
Source: https://stackoverflow.com/a/55990799"""
def wrapper(func):
@functools.lru_cache(maxsize, typed)
def _func(_self, *args, **kwargs):
return func(_self(), *args, **kwargs)
@functools.wraps(func)
def inner(self, *args, **kwargs):
return _func(weakref.ref(self), *args, **kwargs)
return inner
return wrapper

View File

@ -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, Uvicorn, Jinja2 and OrJsonLib needed)',
)
parser.add_argument(
'--cached-time',

View File

@ -1,668 +0,0 @@
# -*- 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 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/<item>' % 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/<item>' % 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/<plugin>' % self.API_VERSION, method="GET", callback=self._api)
self._app.route('/api/%s/<plugin>/history' % self.API_VERSION, method="GET", callback=self._api_history)
self._app.route(
'/api/%s/<plugin>/history/<nb:int>' % self.API_VERSION, method="GET", callback=self._api_history
)
self._app.route('/api/%s/<plugin>/top/<nb:int>' % self.API_VERSION, method="GET", callback=self._api_top)
self._app.route('/api/%s/<plugin>/limits' % self.API_VERSION, method="GET", callback=self._api_limits)
self._app.route('/api/%s/<plugin>/views' % self.API_VERSION, method="GET", callback=self._api_views)
self._app.route('/api/%s/<plugin>/<item>' % self.API_VERSION, method="GET", callback=self._api_item)
self._app.route(
'/api/%s/<plugin>/<item>/history' % self.API_VERSION, method="GET", callback=self._api_item_history
)
self._app.route(
'/api/%s/<plugin>/<item>/history/<nb:int>' % self.API_VERSION, method="GET", callback=self._api_item_history
)
self._app.route('/api/%s/<plugin>/<item>/<value>' % self.API_VERSION, method="GET", callback=self._api_value)
self._app.route(
'/api/%s/<plugin>/<item>/<value:path>' % 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('/<refresh_time:int>', method=["GET"], callback=self._index)
self._app.route('/<filepath:path>', 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

View File

@ -0,0 +1,754 @@
# -*- 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 typing_extensions import Annotated
from glances import __version__
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 = '4'
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)

View File

@ -2,7 +2,7 @@
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2022 Nicolas Hennion <nicolas@nicolargo.com>
# SPDX-FileCopyrightText: 2023 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
@ -13,10 +13,12 @@ from pprint import pformat
import json
import time
from glances.outputs.glances_restful_api import GlancesRestfulApi
from glances.logger import logger
from glances.globals import iteritems
API_URL = "http://localhost:61208/api/3"
API_URL = "http://localhost:61208/api/{api_version}".format(api_version=GlancesRestfulApi.API_VERSION)
APIDOC_HEADER = """\
.. _api:
@ -33,12 +35,14 @@ The Glances Restfull/API server could be ran using the following command line:
API URL
-------
The default root API URL is ``http://localhost:61208/api/3``.
The default root API URL is ``http://localhost:61208/api/{api_version}``.
The bind address and port could be changed using the ``--bind`` and ``--port`` command line options.
It is also possible to define an URL prefix using the ``url_prefix`` option from the [outputs] section
of the Glances configuration file. The url_prefix should always end with a slash (``/``).
of the Glances configuration file.
Note: The url_prefix should always end with a slash (``/``).
For example:
@ -46,10 +50,21 @@ For example:
[outputs]
url_prefix = /glances/
will change the root API URL to ``http://localhost:61208/glances/api/3`` and the Web UI URL to
will change the root API URL to ``http://localhost:61208/glances/api/{api_version}`` and the Web UI URL to
``http://localhost:61208/glances/``
"""
API documentation
-----------------
The API documentation is available at the following URL: ``http://localhost:61208/docs#/``.
WebUI refresh
-------------
It is possible to change the Web UI refresh rate (default is 2 seconds) using the following option in the URL:
``http://localhost:61208/glances/?refresh=5``
""".format(api_version=GlancesRestfulApi.API_VERSION)
def indent_stat(stat, indent=' '):
@ -67,7 +82,7 @@ def print_api_status():
print('-' * len(sub_title))
print('')
print('This entry point should be used to check the API status.')
print('It will return nothing but a 200 return code if everything is OK.')
print('It will the Glances version and a 200 return code if everything is OK.')
print('')
print('Get the Rest API status::')
print('')

View File

@ -65,7 +65,7 @@ static
|
|--- public # path where builds are put
|
|--- templates (bottle)
|--- templates
```
## Data

View File

@ -19,6 +19,15 @@ body {
display: table-cell;
text-align: right;
}
.width-50 {
width: 50px;
}
.width-75 {
width: 75px;
}
.width-100 {
width: 100px;
}
.plugin {
margin-bottom: 20px;
@ -116,6 +125,7 @@ body {
}
/* Plugins */
#processlist-plugin .table-cell {
padding: 0px 5px 0px 5px;
white-space: nowrap;

View File

@ -266,7 +266,7 @@ export default {
};
},
mounted() {
fetch('api/3/help', { method: 'GET' })
fetch('api/4/help', { method: 'GET' })
.then((response) => response.json())
.then((response) => (this.help = response));
}

View File

@ -64,13 +64,13 @@ export default {
}
function getColumnLabel(value) {
const labels = {
io_counters: 'disk IO',
cpu_percent: 'CPU consumption',
memory_percent: 'memory consumption',
cpu_times: 'process time',
username: 'user name',
name: 'process name',
timemillis: 'process time',
cpu_times: 'process time',
io_counters: 'disk IO',
name: 'process name',
None: 'None'
};
return labels[value] || value;

View File

@ -3,112 +3,88 @@
<section id="processlist-plugin" class="plugin">
<div class="table">
<div class="table-row">
<div
class="table-cell"
:class="['sortable', sorter.column === 'cpu_percent' && 'sort']"
@click="$emit('update:sorter', 'cpu_percent')"
>
<div class="table-cell width-50" :class="['sortable', sorter.column === 'cpu_percent' && 'sort']"
@click="$emit('update:sorter', 'cpu_percent')">
CPU%
</div>
<div
class="table-cell"
:class="['sortable', sorter.column === 'memory_percent' && 'sort']"
@click="$emit('update:sorter', 'memory_percent')"
>
<div class="table-cell width-50" :class="['sortable', sorter.column === 'memory_percent' && 'sort']"
@click="$emit('update:sorter', 'memory_percent')">
MEM%
</div>
<div class="table-cell hidden-xs hidden-sm">VIRT</div>
<div class="table-cell hidden-xs hidden-sm">RES</div>
<div class="table-cell">PID</div>
<div
class="table-cell text-left"
:class="['sortable', sorter.column === 'username' && 'sort']"
@click="$emit('update:sorter', 'username')"
>
<div class="table-cell width-75 hidden-xs hidden-sm">VIRT</div>
<div class="table-cell width-75 hidden-xs hidden-sm">RES</div>
<div class="table-cell width-75">PID</div>
<div class="table-cell width-100 text-left" :class="['sortable', sorter.column === 'username' && 'sort']"
@click="$emit('update:sorter', 'username')">
USER
</div>
<div
class="table-cell hidden-xs hidden-sm"
<div class="table-cell width-100 hidden-xs hidden-sm"
:class="['sortable', sorter.column === 'timemillis' && 'sort']"
@click="$emit('update:sorter', 'timemillis')"
>
@click="$emit('update:sorter', 'timemillis')">
TIME+
</div>
<div
class="table-cell text-left hidden-xs hidden-sm"
<div class="table-cell width-75 text-left hidden-xs hidden-sm"
:class="['sortable', sorter.column === 'num_threads' && 'sort']"
@click="$emit('update:sorter', 'num_threads')"
>
@click="$emit('update:sorter', 'num_threads')">
THR
</div>
<div class="table-cell">NI</div>
<div class="table-cell">S</div>
<div
v-show="ioReadWritePresent"
class="table-cell hidden-xs hidden-sm"
<div class="table-cell width-50">NI</div>
<div class="table-cell width-50">S</div>
<div v-show="ioReadWritePresent" class="table-cell width-75 hidden-xs hidden-sm"
:class="['sortable', sorter.column === 'io_counters' && 'sort']"
@click="$emit('update:sorter', 'io_counters')"
>
@click="$emit('update:sorter', 'io_counters')">
IOR/s
</div>
<div
v-show="ioReadWritePresent"
class="table-cell text-left hidden-xs hidden-sm"
<div v-show="ioReadWritePresent" class="table-cell width-75 text-left hidden-xs hidden-sm"
:class="['sortable', sorter.column === 'io_counters' && 'sort']"
@click="$emit('update:sorter', 'io_counters')"
>
@click="$emit('update:sorter', 'io_counters')">
IOW/s
</div>
<div
class="table-cell text-left"
:class="['sortable', sorter.column === 'name' && 'sort']"
@click="$emit('update:sorter', 'name')"
>
<div class="table-cell text-left" :class="['sortable', sorter.column === 'name' && 'sort']"
@click="$emit('update:sorter', 'name')">
Command
</div>
</div>
<div
class="table-row"
v-for="(process, processId) in processes"
:key="processId"
>
<div class="table-cell" :class="getCpuPercentAlert(process)">
<div class="table-row" v-for="(process, processId) in processes" :key="processId">
<div class="table-cell width-50" :class="getCpuPercentAlert(process)">
{{ process.cpu_percent == -1 ? '?' : $filters.number(process.cpu_percent, 1) }}
</div>
<div class="table-cell" :class="getMemoryPercentAlert(process)">
<div class="table-cell width-50" :class="getMemoryPercentAlert(process)">
{{ process.memory_percent == -1 ? '?' : $filters.number(process.memory_percent, 1) }}
</div>
<div class="table-cell hidden-xs hidden-sm">
<div class="table-cell width-75">
{{ $filters.bytes(process.memvirt) }}
</div>
<div class="table-cell hidden-xs hidden-sm">
<div class="table-cell width-75">
{{ $filters.bytes(process.memres) }}
</div>
<div class="table-cell">
<div class="table-cell width-75">
{{ process.pid }}
</div>
<div class="table-cell text-left">
<div class="table-cell width-100 text-left">
{{ process.username }}
</div>
<div class="table-cell hidden-xs hidden-sm" v-if="process.timeplus != '?'">
<div class="table-cell width-100 hidden-xs hidden-sm" v-if="process.timeplus != '?'">
<span v-show="process.timeplus.hours > 0" class="highlight">{{ process.timeplus.hours }}h</span>
{{ $filters.leftPad(process.timeplus.minutes, 2, '0') }}:{{ $filters.leftPad(process.timeplus.seconds, 2, '0') }}
<span v-show="process.timeplus.hours <= 0">.{{ $filters.leftPad(process.timeplus.milliseconds, 2, '0') }}</span>
{{ $filters.leftPad(process.timeplus.minutes, 2, '0') }}:{{ $filters.leftPad(process.timeplus.seconds,
2, '0') }}
<span v-show="process.timeplus.hours <= 0">.{{ $filters.leftPad(process.timeplus.milliseconds, 2, '0')
}}</span>
</div>
<div class="table-cell hidden-xs hidden-sm" v-if="process.timeplus == '?'">?</div>
<div class="table-cell text-left hidden-xs hidden-sm">
<div class="table-cell width-75 hidden-xs hidden-sm" v-if="process.timeplus == '?'">?</div>
<div class="table-cell width-75 text-left hidden-xs hidden-sm">
{{ process.num_threads == -1 ? '?' : process.num_threads }}
</div>
<div class="table-cell" :class="{ nice: process.isNice }">
<div class="table-cell width-50" :class="{ nice: process.isNice }">
{{ $filters.exclamation(process.nice) }}
</div>
<div class="table-cell" :class="{ status: process.status == 'R' }">
<div class="table-cell width-50" :class="{ status: process.status == 'R' }">
{{ process.status }}
</div>
<div class="table-cell hidden-xs hidden-sm" v-show="ioReadWritePresent">
<div class="table-cell width-75 hidden-xs hidden-sm" v-show="ioReadWritePresent">
{{ $filters.bytes(process.io_read) }}
</div>
<div class="table-cell text-left hidden-xs hidden-sm" v-show="ioReadWritePresent">
<div class="table-cell width-75 text-left hidden-xs hidden-sm" v-show="ioReadWritePresent">
{{ $filters.bytes(process.io_write) }}
</div>
<div class="table-cell text-left" v-show="args.process_short_name">
@ -159,8 +135,8 @@ export default {
process.memvirt = '?';
process.memres = '?';
if (process.memory_info) {
process.memvirt = process.memory_info[1];
process.memres = process.memory_info[0];
process.memvirt = process.memory_info.vms;
process.memres = process.memory_info.rss;
}
process.timeplus = '?';

View File

@ -2,15 +2,15 @@ import { store } from './store.js';
import Favico from 'favico.js';
// prettier-ignore
const fetchAll = () => fetch('api/3/all', { method: 'GET' }).then((response) => response.json());
const fetchAll = () => fetch('api/4/all', { method: 'GET' }).then((response) => response.json());
// prettier-ignore
const fetchAllViews = () => fetch('api/3/all/views', { method: 'GET' }).then((response) => response.json());
const fetchAllViews = () => fetch('api/4/all/views', { method: 'GET' }).then((response) => response.json());
// prettier-ignore
const fetchAllLimits = () => fetch('api/3/all/limits', { method: 'GET' }).then((response) => response.json());
const fetchAllLimits = () => fetch('api/4/all/limits', { method: 'GET' }).then((response) => response.json());
// prettier-ignore
const fetchArgs = () => fetch('api/3/args', { method: 'GET' }).then((response) => response.json());
const fetchArgs = () => fetch('api/4/args', { method: 'GET' }).then((response) => response.json());
// prettier-ignore
const fetchConfig = () => fetch('api/3/config', { method: 'GET' }).then((response) => response.json());
const fetchConfig = () => fetch('api/4/config', { method: 'GET' }).then((response) => response.json());
class GlancesHelperService {
limits = {};

Binary file not shown.

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Glances</title>
<link rel="icon" type="image/x-icon" href="static/favicon.ico" />
<script>
window.__GLANCES__ = {
'refresh-time': '{{ refresh_time }}'
}
</script>
<script src="static/glances.js" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Glances</title>
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script>
window.__GLANCES__ = {
'refresh-time': '{{ refresh_time }}'
}
</script>
<script src="glances.js" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -2,7 +2,7 @@
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2022 Nicolas Hennion <nicolas@nicolargo.com>
# SPDX-FileCopyrightText: 2023 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
@ -16,7 +16,7 @@ import sys
import uuid
from io import open
from glances.globals import b, safe_makedirs
from glances.globals import b, safe_makedirs, weak_lru_cache
from glances.config import user_config_dir
from glances.logger import logger
@ -42,21 +42,25 @@ class GlancesPassword(object):
else:
return self.config.get_value('passwords', 'local_password_path', default=user_config_dir())
@weak_lru_cache(maxsize=32)
def get_hash(self, plain_password, salt=''):
"""Return the hashed password, salt + pbkdf2_hmac."""
return hashlib.pbkdf2_hmac('sha256', plain_password.encode(), salt.encode(), 100000, dklen=128).hex()
@weak_lru_cache(maxsize=32)
def hash_password(self, plain_password):
"""Hash password with a salt based on UUID (universally unique identifier)."""
salt = uuid.uuid4().hex
encrypted_password = self.get_hash(plain_password, salt=salt)
return salt + '$' + encrypted_password
@weak_lru_cache(maxsize=32)
def check_password(self, hashed_password, plain_password):
"""Encode the plain_password with the salt of the hashed_password.
Return the comparison with the encrypted_password.
"""
logger.info("Check password")
salt, encrypted_password = hashed_password.split('$')
re_encrypted_password = self.get_hash(plain_password, salt=salt)
return encrypted_password == re_encrypted_password

View File

@ -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

View File

@ -153,7 +153,7 @@ class PluginModel(GlancesPluginModel):
# No SNMP grab for processes
pass
# Update the stats
# Update the stats and transform all namedtuples to dict
self.stats = stats
# Get the max values (dict)
@ -220,8 +220,8 @@ class PluginModel(GlancesPluginModel):
def _get_process_curses_vms(self, p, selected, args):
"""Return process VMS curses"""
if key_exist_value_not_none_not_v('memory_info', p, '', 1):
msg = self.layout_stat['virt'].format(self.auto_unit(p['memory_info'][1], low_precision=False))
if key_exist_value_not_none_not_v('memory_info', p, '', 1) and 'vms' in p['memory_info']:
msg = self.layout_stat['virt'].format(self.auto_unit(p['memory_info']['vms'], low_precision=False))
ret = self.curse_add_line(msg, optional=True)
else:
msg = self.layout_header['virt'].format('?')
@ -230,8 +230,8 @@ class PluginModel(GlancesPluginModel):
def _get_process_curses_rss(self, p, selected, args):
"""Return process RSS curses"""
if key_exist_value_not_none_not_v('memory_info', p, '', 0):
msg = self.layout_stat['res'].format(self.auto_unit(p['memory_info'][0], low_precision=False))
if key_exist_value_not_none_not_v('memory_info', p, '', 0) and 'rss' in p['memory_info']:
msg = self.layout_stat['res'].format(self.auto_unit(p['memory_info']['rss'], low_precision=False))
ret = self.curse_add_line(msg, optional=True)
else:
msg = self.layout_header['res'].format('?')
@ -254,7 +254,7 @@ class PluginModel(GlancesPluginModel):
"""Return process time curses"""
try:
# Sum user and system time
user_system_time = p['cpu_times'][0] + p['cpu_times'][1]
user_system_time = p['cpu_times']['user'] + p['cpu_times']['system']
except (OverflowError, TypeError):
# Catch OverflowError on some Amazon EC2 server
# See https://github.com/nicolargo/glances/issues/87
@ -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))
@ -493,16 +493,17 @@ class PluginModel(GlancesPluginModel):
Input p is a dict with the following keys:
{'status': 'S',
'memory_info': pmem(rss=466890752, vms=3365347328, shared=68153344,
text=659456, lib=0, data=774647808, dirty=0),
'memory_info': {'rss': 466890752, 'vms': 3365347328, 'shared': 68153344,
'text': 659456, 'lib': 0, 'data': 774647808, 'dirty': 0],
'pid': 4980,
'io_counters': [165385216, 0, 165385216, 0, 1],
'num_threads': 20,
'nice': 0,
'memory_percent': 5.958135664449709,
'cpu_percent': 0.0,
'gids': pgids(real=1000, effective=1000, saved=1000),
'cpu_times': pcputimes(user=696.38, system=119.98, children_user=0.0, children_system=0.0, iowait=0.0),
'gids': {'real': 1000, 'effective': 1000, 'saved': 1000},
'cpu_times': {'user': 696.38, 'system': 119.98, 'children_user': 0.0,
'children_system': 0.0, 'iowait': 0.0),
'name': 'WebExtensions',
'key': 'pid',
'time_since_update': 2.1997854709625244,
@ -579,10 +580,11 @@ class PluginModel(GlancesPluginModel):
ret.append(self.curse_add_line(msg, decoration='INFO'))
if 'memory_info' in p and p['memory_info'] is not None:
ret.append(self.curse_add_line(' Memory info: '))
for k in p['memory_info']._asdict():
for k, v in p['memory_info'].items():
ret.append(
self.curse_add_line(
self.auto_unit(p['memory_info']._asdict()[k], low_precision=False),
self.auto_unit(v,
low_precision=False),
decoration='INFO',
splittable=True,
)
@ -591,7 +593,10 @@ class PluginModel(GlancesPluginModel):
if 'memory_swap' in p and p['memory_swap'] is not None:
ret.append(
self.curse_add_line(
self.auto_unit(p['memory_swap'], low_precision=False), decoration='INFO', splittable=True
self.auto_unit(p['memory_swap'],
low_precision=False),
decoration='INFO',
splittable=True
)
)
ret.append(self.curse_add_line(' swap ', splittable=True))
@ -690,12 +695,12 @@ class PluginModel(GlancesPluginModel):
):
# VMS
msg = self.layout_stat['virt'].format(
self.auto_unit(self.__sum_stats('memory_info', indice=1, mmm=mmm), low_precision=False)
self.auto_unit(self.__sum_stats('memory_info', sub_key='vms', mmm=mmm), low_precision=False)
)
ret.append(self.curse_add_line(msg, decoration=self.__mmm_deco(mmm), optional=True))
# RSS
msg = self.layout_stat['res'].format(
self.auto_unit(self.__sum_stats('memory_info', indice=0, mmm=mmm), low_precision=False)
self.auto_unit(self.__sum_stats('memory_info', sub_key='rss', mmm=mmm), low_precision=False)
)
ret.append(self.curse_add_line(msg, decoration=self.__mmm_deco(mmm), optional=True))
else:
@ -725,7 +730,7 @@ class PluginModel(GlancesPluginModel):
if 'io_counters' in self.stats[0] and mmm is None:
# IO read
io_rs = int(
(self.__sum_stats('io_counters', 0) - self.__sum_stats('io_counters', indice=2, mmm=mmm))
(self.__sum_stats('io_counters', 0) - self.__sum_stats('io_counters', sub_key=2, mmm=mmm))
/ self.stats[0]['time_since_update']
)
if io_rs == 0:
@ -735,7 +740,7 @@ class PluginModel(GlancesPluginModel):
ret.append(self.curse_add_line(msg, decoration=self.__mmm_deco(mmm), optional=True, additional=True))
# IO write
io_ws = int(
(self.__sum_stats('io_counters', 1) - self.__sum_stats('io_counters', indice=3, mmm=mmm))
(self.__sum_stats('io_counters', 1) - self.__sum_stats('io_counters', sub_key=3, mmm=mmm))
/ self.stats[0]['time_since_update']
)
if io_ws == 0:
@ -769,10 +774,10 @@ class PluginModel(GlancesPluginModel):
self.mmm_min = {}
self.mmm_max = {}
def __sum_stats(self, key, indice=None, mmm=None):
def __sum_stats(self, key, sub_key=None, mmm=None):
"""Return the sum of the stats value for the given key.
:param indice: If indice is set, get the p[key][indice]
:param sub_key: If sub_key is set, get the p[key][sub_key]
:param mmm: display min, max, mean or current (if mmm=None)
"""
# Compute stats summary
@ -784,13 +789,13 @@ class PluginModel(GlancesPluginModel):
if p[key] is None:
# Correct https://github.com/nicolargo/glances/issues/1105#issuecomment-363553788
continue
if indice is None:
if sub_key is None:
ret += p[key]
else:
ret += p[key][indice]
ret += p[key][sub_key]
# Manage Min/Max/Mean
mmm_key = self.__mmm_key(key, indice)
mmm_key = self.__mmm_key(key, sub_key)
if mmm == 'min':
try:
if self.mmm_min[mmm_key] > ret:
@ -814,10 +819,10 @@ class PluginModel(GlancesPluginModel):
return ret
def __mmm_key(self, key, indice):
def __mmm_key(self, key, sub_key):
ret = key
if indice is not None:
ret += str(indice)
if sub_key is not None:
ret += str(sub_key)
return ret
def __sort_stats(self, sorted_by=None):

View File

@ -7,16 +7,17 @@
# SPDX-License-Identifier: LGPL-3.0-only
#
"""psutil plugin."""
"""psutil plugin.
Just a simple plugin to get the Psutil version."""
from glances import psutil_version_info
from glances.plugins.plugin.model import GlancesPluginModel
class PluginModel(GlancesPluginModel):
"""Get the psutil version for client/server purposes.
"""Get the Psutil version.
stats is a tuple
stats is a string
"""
def __init__(self, args=None, config=None):
@ -40,7 +41,7 @@ class PluginModel(GlancesPluginModel):
if self.input_method == 'local':
# psutil version only available in local
try:
self.stats = psutil_version_info
self.stats = '.'.join([str(i) for i in psutil_version_info])
except NameError:
pass
else:

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2023 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""version plugin.
Just a simple plugin to get the Glances version.
"""
from glances import __version__ as glances_version
from glances.plugins.plugin.model import GlancesPluginModel
class PluginModel(GlancesPluginModel):
"""Get the Glances versions.
stats is a string
"""
def __init__(self, args=None, config=None):
"""Init the plugin."""
super(PluginModel, self).__init__(args=args, config=config)
self.reset()
def reset(self):
"""Reset/init the stats."""
self.stats = None
@GlancesPluginModel._check_decorator
@GlancesPluginModel._log_result_decorator
def update(self):
"""Update the stats."""
# Reset stats
self.reset()
# Return psutil version as a tuple
if self.input_method == 'local':
# psutil version only available in local
try:
self.stats = glances_version
except NameError:
pass
else:
pass
return self.stats

View File

@ -353,9 +353,8 @@ class GlancesProcesses(object):
def update(self):
"""Update the processes stats."""
# Reset the stats
self.processlist = []
self.reset_processcount()
# Init new processes stats
processlist = []
# Do not process if disable tag is set
if self.disable_tag:
@ -392,7 +391,7 @@ class GlancesProcesses(object):
# Build the processes stats list (it is why we need psutil>=5.3.0)
# This is one of the main bottleneck of Glances (see flame graph)
# Filter processes
self.processlist = list(
processlist = list(
filter(
lambda p: not (BSD and p.info['name'] == 'idle')
and not (WINDOWS and p.info['name'] == 'System Idle Process')
@ -402,17 +401,17 @@ class GlancesProcesses(object):
)
)
# Only get the info key
self.processlist = [p.info for p in self.processlist]
processlist = [p.info for p in processlist]
# Sort the processes list by the current sort_key
self.processlist = sort_stats(self.processlist, sorted_by=self.sort_key, reverse=True)
processlist = sort_stats(processlist, sorted_by=self.sort_key, reverse=True)
# Update the processcount
self.update_processcount(self.processlist)
self.update_processcount(processlist)
# Loop over processes and :
# - add extended stats for selected process
# - add metadata
for position, proc in enumerate(self.processlist):
for position, proc in enumerate(processlist):
# Extended stats
################
@ -443,7 +442,7 @@ class GlancesProcesses(object):
# If io_tag = 0 > Access denied or first time (display "?")
# If io_tag = 1 > No access denied (display the IO rate)
if 'io_counters' in proc and proc['io_counters'] is not None:
io_new = [proc['io_counters'].read_bytes, proc['io_counters'].write_bytes]
io_new = [proc['io_counters'][2], proc['io_counters'][3]]
# For IO rate computation
# Append saved IO r/w bytes
try:
@ -480,7 +479,11 @@ class GlancesProcesses(object):
self.processlist_cache[proc['pid']] = {cached: proc[cached] for cached in cached_attrs}
# Apply user filter
self.processlist = list(filter(lambda p: not self._filter.is_filtered(p), self.processlist))
processlist = list(filter(lambda p: not self._filter.is_filtered(p), processlist))
# Save the new processlist and transform all namedtuples to dict
self.processlist = [{k: (v._asdict() if hasattr(v, '_asdict') else v)
for k, v in p.items()} for p in processlist]
# Compute the maximum value for keys in self._max_values_list: CPU, MEM
# Useful to highlight the processes with maximum values

View File

@ -7,6 +7,8 @@
# SPDX-License-Identifier: LGPL-3.0-only
#
from collections import Counter
# from glances.logger import logger
# This constant defines the list of available processes sort key
@ -21,9 +23,9 @@ def create_program_dict(p):
'num_threads': p['num_threads'] or 0,
'cpu_percent': p['cpu_percent'] or 0,
'memory_percent': p['memory_percent'] or 0,
'cpu_times': p['cpu_times'] or (),
'memory_info': p['memory_info'] or (),
'io_counters': p['io_counters'] or (),
'cpu_times': p['cpu_times'] or {},
'memory_info': p['memory_info'] or {},
'io_counters': p['io_counters'] or {},
'childrens': [p['pid']],
# Others keys are not used
# but should be set to be compliant with the existing process_list
@ -35,14 +37,17 @@ def create_program_dict(p):
'status': p['status'],
}
def update_program_dict(program, p):
"""Update an existing entry in the dict (existing program)"""
# some values can be None, e.g. macOS system processes
program['num_threads'] += p['num_threads'] or 0
program['cpu_percent'] += p['cpu_percent'] or 0
program['memory_percent'] += p['memory_percent'] or 0
program['cpu_times'] += p['cpu_times'] or ()
program['memory_info'] += p['memory_info'] or ()
program['cpu_times'] = dict(Counter(program['cpu_times'] or {}) +
Counter(p['cpu_times'] or {}))
program['memory_info'] = dict(Counter(program['memory_info'] or {}) +
Counter(p['memory_info'] or {}))
program['io_counters'] += p['io_counters']
program['childrens'].append(p['pid'])
@ -51,6 +56,7 @@ def update_program_dict(program, p):
program['nice'] = p['nice'] if p['nice'] == program['nice'] else '_'
program['status'] = p['status'] if p['status'] == program['status'] else '_'
def processes_to_programs(processes):
"""Convert a list of processes to a list of programs."""
# Start to build a dict of programs (key is program name)

View File

@ -136,7 +136,7 @@ class GlancesStats(object):
# Log plugins list
logger.debug("Active plugins list: {}".format(self.getPluginsList()))
def load_additional_plugins(self, args=None, config=None):
""" Load additional plugins if defined """
def get_addl_plugins(self, plugin_path):
@ -162,7 +162,7 @@ class GlancesStats(object):
if args and 'plugin_dir' in args and args.plugin_dir:
path = args.plugin_dir
if path:
# Get list before starting the counter
_sys_path = sys.path
@ -190,7 +190,7 @@ class GlancesStats(object):
sys.path = _sys_path
# Log plugins list
logger.debug("Active additional plugins list: {}".format(self.getPluginsList()))
def load_exports(self, args=None):
"""Load all exporters in the 'exports' folder."""
start_duration = Counter()

View File

@ -2,17 +2,17 @@
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2022 Nicolas Hennion <nicolas@nicolargo.com>
# SPDX-FileCopyrightText: 2023 Nicolas Hennion <nicolas@nicolargo.com>
#
# 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."""

View File

@ -3,17 +3,19 @@
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
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
@ -34,6 +36,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"

View File

@ -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

View File

@ -44,7 +44,10 @@ def get_install_requires():
'ujson>=5.4.0',
]
if sys.platform.startswith('win'):
requires.append('bottle')
requires.append('fastapi')
requires.append('uvicorn')
requires.append('orjson')
requires.append('jinja2')
requires.append('requests')
return requires
@ -67,7 +70,7 @@ def get_install_extras_require():
'smart': ['pySMART.smartx'],
'snmp': ['pysnmp'],
'sparklines': ['sparklines'],
'web': ['bottle', 'requests'],
'web': ['fastapi', 'uvicorn', 'jinja2', 'orjson', 'requests'],
'wifi': ['wifi']
}
if sys.platform.startswith('linux'):
@ -123,7 +126,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',

View File

@ -53,16 +53,50 @@ parts:
override-pull: |
snapcraftctl pull
"$SNAPCRAFT_STAGE"/scriptlets/selective-checkout
bottle:
fastapi:
plugin: python
source: https://github.com/bottlepy/bottle.git
source-branch: release-0.12
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/bottle-dist
cp -r $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/fastapi-dist
organize:
bottle-dist: bottle/dist
fastapi-dist: fastapi/dist
uvicorn:
plugin: python
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/uvicorn-dist
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

View File

@ -19,7 +19,10 @@ deps =
defusedxml
packaging
ujson
bottle
fastapi
uvicorn
orjson
jinja2
requests
commands =
python unitest.py

View File

@ -17,13 +17,14 @@ import time
import numbers
import unittest
from glances.outputs.glances_restful_api import GlancesRestfulApi
from glances import __version__
from glances.globals import text_type
import requests
SERVER_PORT = 61234
API_VERSION = 3
API_VERSION = GlancesRestfulApi.API_VERSION
URL = "http://localhost:{}/api/{}".format(SERVER_PORT, API_VERSION)
pid = None
@ -39,12 +40,12 @@ class TestGlances(unittest.TestCase):
"""The function is called *every time* before test_*."""
print('\n' + '=' * 78)
def http_get(self, url, deflate=False):
def http_get(self, url, gzip=False):
"""Make the request"""
if deflate:
if gzip:
ret = requests.get(url,
stream=True,
headers={'Accept-encoding': 'deflate'})
headers={'Accept-encoding': 'gzip'})
else:
ret = requests.get(url,
headers={'Accept-encoding': 'identity'})
@ -54,12 +55,12 @@ class TestGlances(unittest.TestCase):
"""Start the Glances Web Server."""
global pid
print('INFO: [TEST_000] Start the Glances Web Server')
print('INFO: [TEST_000] Start the Glances Web Server API')
if os.path.isfile('./venv/bin/python'):
cmdline = "./venv/bin/python"
else:
cmdline = "python"
cmdline += " -m glances -B localhost -w -p %s" % SERVER_PORT
cmdline += " -m glances -B 0.0.0.0 -w -p %s --disable-webui -C ./conf/glances.conf" % SERVER_PORT
print("Run the Glances Web Server on port %s" % SERVER_PORT)
args = shlex.split(cmdline)
pid = subprocess.Popen(args)
@ -76,16 +77,7 @@ class TestGlances(unittest.TestCase):
req = self.http_get("%s/%s" % (URL, method))
self.assertTrue(req.ok)
def test_001a_all_deflate(self):
"""All."""
method = "all"
print('INFO: [TEST_001a] Get all stats (with Deflate compression)')
print("HTTP RESTful request: %s/%s" % (URL, method))
req = self.http_get("%s/%s" % (URL, method), deflate=True)
self.assertTrue(req.ok)
self.assertTrue(req.headers['Content-Encoding'] == 'deflate')
self.assertTrue(req.json(), dict)
def test_002_pluginslist(self):
"""Plugins list."""
@ -108,14 +100,12 @@ class TestGlances(unittest.TestCase):
print("HTTP RESTful request: %s/%s" % (URL, p))
req = self.http_get("%s/%s" % (URL, p))
self.assertTrue(req.ok)
if p in ('uptime', 'now'):
if p in ('uptime', 'now', 'version', 'psutilversion'):
self.assertIsInstance(req.json(), text_type)
elif p in ('fs', 'percpu', 'sensors', 'alert', 'processlist', 'diskio',
'hddtemp', 'batpercent', 'network', 'folders', 'amps', 'ports',
'irq', 'wifi', 'gpu'):
self.assertIsInstance(req.json(), list)
elif p in ('psutilversion', 'help'):
pass
else:
self.assertIsInstance(req.json(), dict)
@ -203,14 +193,12 @@ class TestGlances(unittest.TestCase):
self.assertTrue(len(req.json()['user']) > 1)
print("HTTP RESTful request: %s/cpu/system/%s" % (URL, method))
req = self.http_get("%s/cpu/system/%s" % (URL, method))
self.assertIsInstance(req.json(), dict)
self.assertIsInstance(req.json()['system'], list)
self.assertTrue(len(req.json()['system']) > 0)
self.assertIsInstance(req.json(), list)
self.assertIsInstance(req.json()[0], list)
print("HTTP RESTful request: %s/cpu/system/%s/3" % (URL, method))
req = self.http_get("%s/cpu/system/%s/3" % (URL, method))
self.assertIsInstance(req.json(), dict)
self.assertIsInstance(req.json()['system'], list)
self.assertTrue(len(req.json()['system']) > 1)
self.assertIsInstance(req.json(), list)
self.assertIsInstance(req.json()[0], list)
def test_011_issue1401(self):
"""Check issue #1401."""
@ -229,7 +217,7 @@ class TestGlances(unittest.TestCase):
req = self.http_get("%s/%s" % (URL, method))
self.assertTrue(req.ok)
self.assertEqual(req.text, "Active")
self.assertEqual(req.json()['version'], __version__)
def test_013_top(self):
"""Values."""
@ -243,6 +231,30 @@ class TestGlances(unittest.TestCase):
self.assertIsInstance(req.json(), list)
self.assertEqual(len(req.json()), 2)
def test_014_config(self):
"""Test API request to get Glances configuration."""
method = "config"
print('INFO: [TEST_014] Get config')
req = self.http_get("%s/%s" % (URL, method))
self.assertTrue(req.ok)
self.assertIsInstance(req.json(), dict)
req = self.http_get("%s/%s/global/refresh" % (URL, method))
self.assertTrue(req.ok)
self.assertEqual(req.json(), "2")
def test_015_all_gzip(self):
"""All with Gzip."""
method = "all"
print('INFO: [TEST_015] Get all stats (with GZip compression)')
print("HTTP RESTful request: %s/%s" % (URL, method))
req = self.http_get("%s/%s" % (URL, method), gzip=True)
self.assertTrue(req.ok)
self.assertTrue(req.headers['Content-Encoding'] == 'gzip')
self.assertTrue(req.json(), dict)
def test_999_stop_server(self):
"""Stop the Glances Web Server."""
print('INFO: [TEST_999] Stop the Glances Web Server')

View File

@ -280,10 +280,8 @@ class TestGlances(unittest.TestCase):
# stats_to_check = [ ]
print('INFO: [TEST_017] Check PROGRAM stats')
stats_grab = processes_to_programs(stats.get_plugin('processlist').get_raw())
self.assertTrue(type(stats_grab) is list, msg='Programs stats is not a list')
print('INFO: PROGRAM list stats: %s items in the list' % len(stats_grab))
# Check if number of processes in the list equal counter
# self.assertEqual(total, len(stats_grab))
self.assertIsInstance(stats_grab, list, msg='Programs stats list is not a list')
self.assertIsInstance(stats_grab[0], dict, msg='First item should be a dict')
def test_018_string_value_to_float(self):
"""Check string_value_to_float function"""

View File

@ -1,4 +1,7 @@
# install with base requirements file
-r requirements.txt
bottle
fastapi; python_version >= "3.8"
uvicorn; python_version >= "3.8"
orjson; python_version >= "3.8"
jinja2