"""Display some system info
========================
A class-based monitoring view, allowing to display some info.
Also define several widgets (:class:`MonitoringCheck`) that compose this view.
You should install the :mod:`psutil` module to add server info (like the CPU usage).
"""
import datetime
import logging
import os
import re
import pkg_resources
from django.conf import settings
from django.contrib import messages
from django.contrib.admin import site
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.checks import Info, Warning
from django.http import Http404
from django.http.response import HttpResponseRedirect
from django.template.loader import get_template
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from pkg_resources import parse_requirements, Distribution
from djangofloor.celery import app
from djangofloor.checks import settings_check_results
from djangofloor.conf.settings import merger
from djangofloor.forms import LogNameForm
from djangofloor.tasks import (
set_websocket_topics,
import_signals_and_functions,
get_expected_queues,
)
from djangofloor.views.admin import admin_context
try:
# noinspection PyPackageRequirements
import psutil
psutil.cpu_percent()
except ImportError:
psutil = None
__author__ = "Matthieu Gallet"
logger = logging.getLogger("django.request")
stdlib_pkgs = ("python", "wsgiref", "argparse")
[docs]def get_installed_distributions():
"""
Return a list of installed Distribution objects.
Simplified version of the version provided by pip.
"""
return [
d
for d in pkg_resources.working_set
if d.key not in ("python", "wsgiref", "argparse")
]
[docs]class MonitoringCheck:
"""Base widget of the monitoring view."""
template = None
"""name of the template used by this widget"""
frequency = None
"""update frequency (currently unused)."""
[docs] def render(self, request):
"""render the widget as HTML"""
template = get_template(self.template)
context = self.get_context(request)
content = template.render(context, request)
return mark_safe(content)
[docs] def get_context(self, request):
""" provide the context required to render the widget"""
return {}
[docs] def check_commandline(self):
pass
[docs]class Packages(MonitoringCheck):
"""Check a list of given packages given by `settings.DF_CHECKED_REQUIREMENTS`.
Each element is a requirement as listed by `pip freeze`.
"""
template = "djangofloor/django/monitoring/packages.html"
"""base template """
[docs] def get_context(self, request):
"""provide the installed distributions to the template"""
distributions = self.get_installed_distributions(
get_installed_distributions(), settings.DF_CHECKED_REQUIREMENTS
)
return {"installed_distributions": distributions}
[docs] @staticmethod
def get_installed_distributions(raw_installed_distributions, raw_requirements):
"""return a list of lists, each sublist having 6 elements:
* the name of the package,
* the installed version (or `None` if not installed),
* the state ("danger"/"warning"/"success") as a CSS class,
* the icon ("remove"/"ok"/"warning-sign")
* list of specs as strings ['>= 1', '< 1.8.1']
* list of parse_requirements(r) for the package
"""
if raw_requirements:
requirements = {} # requirements[key] = [key, state="danger/warning/success", [specs_str], [parsed_req]]
for r in raw_requirements:
for p in parse_requirements(r):
requirements.setdefault(
p.key, [p.key, None, "danger", "remove", [], []]
)
requirements[p.key][4] += [" ".join(y) for y in p.specs]
requirements[p.key][5].append(p)
for r in raw_installed_distributions:
if r.key not in requirements:
continue
requirements[r.key][1] = r.version
d = Distribution(project_name=r.key, version=r.version)
if requirements[r.key][2] == "danger":
requirements[r.key][2] = "success"
requirements[r.key][3] = "ok"
for p in requirements[r.key][5]:
if d not in p:
requirements[r.key][2] = "warning"
requirements[r.key][3] = "warning-sign"
installed_distributions = list(
sorted(requirements.values(), key=lambda k: k[0].lower())
)
else:
installed_distributions = [
[y.key, y.version, "success", "ok", ["== %s" % y.version, [], []], []]
for y in raw_installed_distributions
]
return installed_distributions
[docs]class System(MonitoringCheck):
template = "djangofloor/django/monitoring/system.html"
excluded_mountpoints = {"/dev"}
[docs] def get_context(self, request):
if psutil is None:
return {
"cpu_count": None,
"memory": None,
"cpu_average_usage": None,
"cpu_current_usage": None,
"swap": None,
"disks": None,
}
y = psutil.cpu_times()
cpu_average_usage = int(
(y.user + y.system) / (y.idle + y.user + y.system) * 100.
)
cpu_current_usage = int(psutil.cpu_percent(interval=0.1))
cpu_count = psutil.cpu_count(logical=True), psutil.cpu_count(logical=False)
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
disks = []
for y in psutil.disk_partitions(all=True):
if y.mountpoint in self.excluded_mountpoints:
continue
# noinspection PyBroadException
try:
disk_data = (y.mountpoint, psutil.disk_usage(y.mountpoint))
if disk_data[1].total > 0:
disks.append(disk_data)
except Exception:
pass
# disks = [(y.mountpoint, psutil.disk_usage(y.mountpoint)) for y in psutil.disk_partitions(all=True)]
# disks = [x for x in disks if x[1].total > 0]
return {
"cpu_count": cpu_count,
"memory": memory,
"cpu_average_usage": cpu_average_usage,
"cpu_current_usage": cpu_current_usage,
"swap": swap,
"disks": disks,
}
[docs] @staticmethod
def check_pid(pid: str):
try:
os.kill(int(pid), 0)
except OSError:
return False
else:
return True
[docs] def check_commandline(self):
if psutil is None:
return
for y in psutil.disk_partitions(all=True):
if y.mountpoint in self.excluded_mountpoints:
continue
# noinspection PyBroadException
try:
usage = psutil.disk_usage(y.mountpoint)
if usage.total > 0 and usage.percent > 95:
msg = "%s is almost full (%s %%)." % (y.mountpoint, usage.percent)
settings_check_results.append(Info(msg, obj="system"))
except Exception:
pass
# if settings.PID_DIRECTORY:
# processes = self.get_expected_processes()
# for filename in glob.glob('%s/*.pid' % settings.PID_DIRECTORY):
# # list all PID files and read them
# data = self.read_pid_file(filename)
# self.analyse_pid_file(processes, data)
# for process, pids in processes.items():
# if not pids:
# settings_check_results.append(Warning('%s: no such process' % process, obj='system'))
# valid_pids = {pid for pid in pids if self.check_pid(pid)}
# invalid_pids = {pid for pid in pids if pid not in valid_pids}
# for pid in invalid_pids:
# msg = '%s: stale PID %s (in \'%s%s.pid\')' % (process, pid, settings.PID_DIRECTORY, pid)
# settings_check_results.append(Warning(msg, obj='system'))
[docs] @staticmethod
def analyse_pid_file(processes, data):
command, pid = data.get("COMMAND"), data.get("PID")
if command == "worker" and data.get("QUEUES"):
for queue in data["QUEUES"].split("|"):
command = "worker (queue '%s')" % queue
if command and pid and re.match(r"^\d+$", pid):
processes.setdefault(command, []).append(pid)
elif command and pid and re.match(r"^\d+$", pid):
processes.setdefault(command, []).append(pid)
[docs] @staticmethod
def read_pid_file(filename):
with open(filename) as fd:
data = {}
for line in fd:
key, sep, value = line.strip().partition("=")
if sep == "=" and key == key.upper():
data[key] = value
return data
[docs] @staticmethod
def get_expected_processes():
processes = {"server": []}
if settings.USE_CELERY:
processes.update(
{"worker (queue '%s')" % x: [] for x in get_expected_queues()}
)
return processes
[docs]class CeleryStats(MonitoringCheck):
template = "djangofloor/django/monitoring/celery_stats.html"
[docs] def get_context(self, request):
if not settings.USE_CELERY:
return {"celery_required": False}
celery_stats = app.control.inspect().stats()
import_signals_and_functions()
expected_queues = {x: ("danger", "remove") for x in get_expected_queues()}
queue_stats = app.control.inspect().active_queues()
if queue_stats is None:
queue_stats = {}
for stats in queue_stats.values():
for queue_data in stats:
# noinspection PyTypeChecker
if queue_data["name"] in expected_queues:
# noinspection PyTypeChecker
expected_queues[queue_data["name"]] = ("success", "ok")
missing_queues = {x for (x, y) in expected_queues.items() if y[0] == "danger"}
if len(missing_queues) == 1:
messages.error(
request,
_('There is no worker for the "%(name)s" Celery queue.')
% {"name": missing_queues.pop()},
)
elif missing_queues:
messages.error(
request,
_("There is no worker for the the following Celery queues: %(name)s.")
% {"name": ", ".join(sorted(missing_queues))},
)
workers = []
if celery_stats is None:
celery_stats = {}
for key in sorted(celery_stats.keys(), key=lambda y: y.lower()):
worker = {"name": key}
infos = celery_stats[key]
url = "%s://%s" % (
infos["broker"]["transport"],
infos["broker"]["hostname"],
)
if infos["broker"].get("port"):
url += ":%s" % infos["broker"]["port"]
url += "/"
if infos["broker"].get("virtual_host"):
url += infos["broker"]["virtual_host"]
worker["broker"] = url
pids = [str(infos["pid"])] + [str(y) for y in infos["pool"]["processes"]]
worker["pid"] = ", ".join(pids)
worker["threads"] = infos["pool"]["max-concurrency"]
worker["timeouts"] = sum(infos["pool"]["timeouts"])
worker["state"] = ("success", "check")
if worker["timeouts"] > 0:
worker["state"] = ("danger", "remove")
# noinspection PyTypeChecker
worker["queues"] = list({y["name"] for y in queue_stats.get(key, [])})
worker["queues"].sort()
workers.append(worker)
return {
"workers": workers,
"expected_queues": expected_queues,
"celery_required": True,
}
[docs]class RequestCheck(MonitoringCheck):
template = "djangofloor/django/monitoring/request_check.html"
common_headers = {
"HTTP_ACCEPT": "Media type(s) that is(/are) acceptable for the response.",
"HTTP_ACCEPT_CHARSET": "Character sets that are acceptable.",
"HTTP_ACCEPT_ENCODING": "List of acceptable encodings. See HTTP compression.",
"HTTP_ACCEPT_LANGUAGE": "List of acceptable human languages for response.",
"HTTP_ACCEPT_DATETIME": "Acceptable version in time.",
"HTTP_AUTHORIZATION": "Authentication credentials for HTTP authentication.",
"HTTP_CACHE_CONTROL": "Used to specify directives that must be obeyed by caching mechanisms.",
"HTTP_CONNECTION": "Control options for the current connection and list of hop-by-hop request fields.",
"HTTP_COOKIE": "An HTTP cookie previously sent by the server with Set-Cookie (below).",
"HTTP_CONTENT_LENGTH": "The length of the request body in octets (8-bit bytes).",
"HTTP_CONTENT_MD5": "A Base64-encoded binary MD5 sum of the content of the request body.",
"HTTP_CONTENT_TYPE": "The Media type of the body of the request (used with POST and PUT requests).",
"HTTP_DATE": "The date and time that the message was originated",
"HTTP_EXPECT": "Indicates that particular server behaviors are required by the client.",
"HTTP_FORWARDED": "Disclose original information of a client connecting to a web server through an HTTP proxy.",
"HTTP_FROM": "The email address of the user making the request.",
"HTTP_HOST": "The domain name of the server (for virtual hosting), and the TCP port number.",
"HTTP_IF_MATCH": "Only perform the action if the client supplied entity matches the same entity on the server.",
"HTTP_IF_MODIFIED_SINCE": "Allows a 304 Not Modified to be returned if content is unchanged.",
"HTTP_IF_NONE_MATCH": "Allows a 304 Not Modified to be returned if content is unchanged, see HTTP ETag.",
"HTTP_IF_RANGE": "If the entity is unchanged, send me the part(s) that I am missing or send me the entity.",
"HTTP_IF_UNMODIFIED_SINCE": "Only send the response if the entity has not been modified since a specific time.",
"HTTP_MAX_FORWARDS": "Limit the number of times the message can be forwarded through proxies or gateways.",
"HTTP_ORIGIN": "Initiates a request for cross-origin resource sharing.",
"HTTP_PRAGMA": "Implementation-specific fields that may have various effects.",
"HTTP_PROXY_AUTHORIZATION": "Authorization credentials for connecting to a proxy.",
"HTTP_RANGE": "Request only part of an entity.",
"HTTP_REFERER": "This is the address of the previous web page.",
"HTTP_TE": "The transfer encodings the user agent is willing to accept.",
"HTTP_USER_AGENT": "The user agent string of the user agent.",
"HTTP_UPGRADE": "Ask the server to upgrade to another protocol.",
"HTTP_VIA": "Informs the server of proxies through which the request was sent.",
"HTTP_WARNING": "A general warning about possible problems with the entity body.",
"HTTP_X_REQUESTED_WITH": "Mainly used to identify Ajax requests.",
"HTTP_DNT": "Requests a web application to disable their tracking of a user.",
"HTTP_X_FORWARDED_FOR": "A de facto standard for identifying the originating IP address.",
"HTTP_X_FORWARDED_HOST": "A de facto standard for identifying the original host.",
"HTTP_X_FORWARDED_PROTO": "A de facto standard for identifying the originating protocol",
"HTTP_FRONT_END_HTTPS": "Non-standard header field used by Microsoft applications",
"HTTP_PROXY_CONNECTION": "Implemented as a misunderstanding of the HTTP specifications.",
"HTTP_X_CSRF_TOKEN": "Used to prevent cross-site request forgery.",
"HTTP_X_REQUEST_ID": "Correlates HTTP requests between a client and server.",
"HTTP_X_CORRELATION_ID": "Correlates HTTP requests between a client and server.",
}
[docs] def get_context(self, request):
def django_fmt(y):
return y.upper().replace("-", "_")
def http_fmt(y):
return y.upper().replace("_", "-")
context = {
"remote_user": None,
"remote_address": request.META["REMOTE_ADDR"],
"use_x_forwarded_for": None,
"secure_proxy_ssl_header": None,
}
header = settings.DF_REMOTE_USER_HEADER
if header:
context["remote_user"] = (
http_fmt(header),
request.META.get(django_fmt(header)),
)
header = settings.USE_X_FORWARDED_FOR and "HTTP_X_FORWARDED_FOR"
if header:
context["use_x_forwarded_for"] = (
http_fmt(header),
request.META.get(django_fmt(header)),
)
context["secure_proxy_ssl_header"] = None
if settings.SECURE_PROXY_SSL_HEADER:
header, value = settings.SECURE_PROXY_SSL_HEADER
context["secure_proxy_ssl_header"] = (
http_fmt(header),
request.META.get(django_fmt(header)),
request.META.get(django_fmt(header)) == value,
)
host, sep, port = request.get_host().partition(":")
context["allowed_hosts"] = settings.ALLOWED_HOSTS
context["allowed_host"] = host in settings.ALLOWED_HOSTS
context["request_host"] = host
context["request_site"] = None
context["cache_redis"] = settings.USE_REDIS_CACHE
context["use_ssl"] = settings.USE_SSL
context["session_redis"] = settings.USE_REDIS_SESSIONS
context["websockets_required"] = settings.WEBSOCKET_URL is not None
context["celery_required"] = settings.USE_CELERY
# noinspection PyTypeChecker
context["fake_username"] = getattr(
settings, "DF_FAKE_AUTHENTICATION_USERNAME", None
)
# noinspection PyTypeChecker
if hasattr(request, "site"):
context["request_site"] = request.site
context["request_site_valid"] = request.site == host
context["server_name"] = settings.SERVER_NAME
context["server_name_valid"] = settings.SERVER_NAME == host
context["debug"] = settings.DEBUG
context["request_headers"] = [
(x, y, self.common_headers.get(x))
for (x, y) in sorted(request.META.items())
]
if settings.DEBUG:
messages.warning(
request,
_(
"The DEBUG mode is activated. You should disable it for a production website."
),
)
if context["fake_username"]:
messages.warning(
request,
_(
"DF_FAKE_AUTHENTICATION_USERNAME is set. "
"You should disable it for a production website."
),
)
context["settings_providers"] = [p for p in merger.providers]
return context
[docs] def check_commandline(self):
if settings.DEBUG:
settings_check_results.append(
Warning(
"The DEBUG mode is activated. You should disable it in production",
obj="configuration",
)
)
[docs]class LogLastLines(MonitoringCheck):
template = "djangofloor/django/monitoring/log_last_lines.html"
[docs] def get_context(self, request):
contents = []
for filename in self.get_log_filenames():
try:
size = os.path.getsize(filename)
with open(filename, "r", encoding="utf-8") as fd:
fd.seek(max(0, size - 4096))
content = fd.read(4096)
contents.append((filename, "default", content))
except Exception as e:
contents.append(
(
filename,
"danger",
_("Unable to read %(filename)s") % {"filename": filename},
)
)
logger.exception(e)
return {"contents": contents}
[docs] @staticmethod
def get_log_filenames():
"""Return the list of filenames used in all :class:`logging.FileHandler`.
"""
handlers = [x for x in logging.root.handlers]
for name, logger_obj in logging.root.manager.loggerDict.items():
if not isinstance(logger_obj, logging.Logger):
continue
handlers += logger_obj.handlers
handlers = [x for x in handlers if isinstance(x, logging.FileHandler)]
filenames = {x.baseFilename for x in handlers}
if settings.LOG_DIRECTORY:
filenames |= {
os.path.join(settings.LOG_DIRECTORY, x)
for x in os.listdir(settings.LOG_DIRECTORY)
if x.endswith(".log")
}
filenames = list(filenames)
filenames.sort()
return filenames
[docs]class LogAndExceptionCheck(MonitoringCheck):
template = "djangofloor/django/monitoring/errors.html"
[docs] def get_context(self, request):
form = LogNameForm()
return {"logname_form": form, "celery_required": settings.USE_CELERY}
[docs]class AuthenticationCheck(MonitoringCheck):
"""Presents all activated authentication methods
"""
template = "djangofloor/django/monitoring/authentication.html"
[docs] def get_context(self, request):
context = {
"ldap": bool(settings.AUTH_LDAP_SERVER_URI),
"allow_user_creation": settings.DF_ALLOW_USER_CREATION,
"session_age": datetime.timedelta(seconds=settings.SESSION_COOKIE_AGE),
"basic_auth": settings.USE_HTTP_BASIC_AUTH,
"remote_user": settings.DF_REMOTE_USER_HEADER,
"remote_user_groups": settings.DF_DEFAULT_GROUPS,
"allauth": settings.ALLAUTH_PROVIDER_APPS,
"pam": settings.USE_PAM_AUTHENTICATION,
"local_users": settings.DF_ALLOW_LOCAL_USERS,
}
return context
system_checks = [import_string(x)() for x in settings.DF_SYSTEM_CHECKS]
[docs]@never_cache
@login_required(login_url="df:login")
def system_state(request):
if not request.user or not request.user.is_superuser:
raise Http404
components_values = [y.render(request) for y in system_checks]
template_values = admin_context(
{
"components": components_values,
"site_header": site.site_header,
"site_title": site.site_title,
"title": _("System state"),
"has_permission": request.user.is_active and request.user.is_staff,
}
)
set_websocket_topics(request)
# noinspection PyUnresolvedReferences
return TemplateResponse(
request,
template="djangofloor/django/system_state.html",
context=template_values,
)
[docs]@never_cache
@user_passes_test(lambda x: x.is_superuser)
def raise_exception(request):
if not request.user.is_superuser:
raise Http404
messages.warning(
request,
_("An exception (division by zero) has been raised in a Django HTTP request"),
)
# noinspection PyStatementEffect
1 / 0
[docs]@never_cache
@user_passes_test(lambda x: x.is_superuser)
def generate_log(request):
if not request.user.is_superuser:
raise Http404
form = LogNameForm(request.POST)
if form.is_valid():
logname = (
form.cleaned_data["other_log_name"]
or form.cleaned_data["log_name"]
or "django.request"
)
level = form.cleaned_data["level"]
message = form.cleaned_data["message"]
logger_ = logging.getLogger(logname)
logger_.log(int(level), message)
messages.success(
request,
_('message "%(message)s" logged to "%(logname)s" at level %(level)s.')
% {"message": message, "level": level, "logname": logname},
)
else:
messages.error(
request, _("please specify a message, a logger name and a log level.")
)
return HttpResponseRedirect(redirect_to=reverse("df:system_state"))