diff --git a/snikket_web/admin.py b/snikket_web/admin.py
index 5dd9e48..d7ca471 100644
--- a/snikket_web/admin.py
+++ b/snikket_web/admin.py
@@ -1,4 +1,6 @@
import json
+import resource
+import time
import typing
from datetime import datetime
@@ -22,7 +24,7 @@ from quart import (
from flask_babel import lazy_gettext as _l, _
-from . import prosodyclient
+from . import prosodyclient, _version
from .infra import client, circle_name, BaseForm
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -550,3 +552,140 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
circle_members=circle_members,
invite_form=invite_form,
)
+
+
+_CPU_EPOCH = time.process_time()
+_MONOTONIC_EPOCH = time.monotonic()
+
+
+def get_system_stats() -> typing.MutableMapping[
+ str,
+ typing.Optional[typing.Union[int, float]]]:
+ pagesize = resource.getpagesize()
+ my_rss: typing.Optional[int] = None
+ try:
+ with open("/proc/self/statm") as f:
+ stats = f.read().split()
+ my_rss = int(stats[1]) * pagesize
+ except (ValueError, IndexError, TypeError, OSError):
+ pass
+
+ my_cpu = (
+ (time.process_time() - _CPU_EPOCH) /
+ (time.monotonic() - _MONOTONIC_EPOCH)
+ )
+
+ load5: typing.Optional[float] = None
+ try:
+ with open("/proc/loadavg") as f:
+ stats = f.read().split()
+ load5 = float(stats[1])
+ except (ValueError, IndexError, TypeError, OSError):
+ pass
+
+ mem_total, mem_available = None, None
+ try:
+ with open("/proc/meminfo") as f:
+ for line in f:
+ if line.startswith("MemTotal"):
+ mem_total = int(line.split()[1]) * 1024
+ elif line.startswith("MemAvailable"):
+ mem_available = int(line.split()[1]) * 1024
+ if mem_total is not None and mem_available is not None:
+ break
+ except (ValueError, TypeError, IndexError, OSError):
+ pass
+
+ return {
+ "portal_rss": my_rss,
+ "portal_cpu": my_cpu,
+ "load5": load5,
+ "mem_total": mem_total,
+ "mem_available": mem_available,
+ }
+
+
+class AnnouncementForm(BaseForm):
+ text = wtforms.StringField(
+ _("Message contents"),
+ widget=wtforms.widgets.TextArea(),
+ validators=[wtforms.validators.DataRequired()],
+ )
+
+ online_only = wtforms.BooleanField(
+ _("Only send to online users"),
+ )
+
+ action_post_all = wtforms.SubmitField(
+ _("Post to all users"),
+ )
+
+ action_send_preview = wtforms.SubmitField(
+ _("Send preview to yourself"),
+ )
+
+
+@bp.route("/system/", methods=["GET", "POST"])
+@client.require_admin_session()
+async def system() -> typing.Union[str, quart.Response]:
+ form = AnnouncementForm()
+
+ if form.validate_on_submit():
+ recipients = "self"
+ if form.action_post_all.data:
+ if form.online_only.data:
+ recipients = "online"
+ else:
+ recipients = "all"
+
+ await client.post_announcement(
+ form.text.data,
+ recipients=recipients,
+ )
+ await flash(
+ _("Announcement sent!"),
+ "success",
+ )
+ if recipients != "self":
+ # redirect only if not previewing
+ return redirect(url_for(".system"))
+
+ version = await client.get_server_version()
+ now = time.time()
+ try:
+ prosody_metrics = await client.get_system_metrics()
+ except quart.exceptions.NotFound:
+ # server does not offer the endpoint for whatever reason -- ignore
+ prosody_metrics = {}
+
+ metrics = get_system_stats()
+ try:
+ prosody_cpu_metrics = prosody_metrics["cpu"]
+ except KeyError:
+ pass
+ else:
+ metrics["prosody_cpu"] = (prosody_cpu_metrics["value"] /
+ (now - prosody_cpu_metrics["since"]))
+
+ try:
+ metrics["prosody_rss"] = prosody_metrics["memory"]
+ except KeyError:
+ pass
+
+ try:
+ metrics["prosody_devices"] = prosody_metrics["c2s"]
+ except KeyError:
+ pass
+
+ for k in list(metrics.keys()):
+ if metrics[k] is None:
+ # so that defaulting in jinja works
+ del metrics[k]
+
+ return await render_template(
+ "admin_system.html",
+ metrics=metrics,
+ version=_version.version,
+ prosody_version=version,
+ form=form,
+ )
diff --git a/snikket_web/infra.py b/snikket_web/infra.py
index dc58456..e31c480 100644
--- a/snikket_web/infra.py
+++ b/snikket_web/infra.py
@@ -1,5 +1,6 @@
import base64
import itertools
+import math
import secrets
import typing
@@ -22,6 +23,15 @@ client.default_login_redirect = "main.login"
babel = flask_babel.Babel()
+BYTE_UNIT_SCALE_MAP = [
+ "B",
+ "kiB",
+ "MiB",
+ "GiB",
+ "TiB",
+]
+
+
@babel.localeselector # type:ignore
def selected_locale() -> str:
selected = request.accept_languages.best_match(
@@ -42,12 +52,27 @@ def circle_name(c: typing.Any) -> str:
return c.name
+def format_bytes(n: float) -> str:
+ scale = math.floor(math.log(n, 1024))
+ try:
+ unit = BYTE_UNIT_SCALE_MAP[scale]
+ factor = 1024**scale
+ except ValueError:
+ unit = "TiB"
+ factor = 1024**4
+ if factor > 1:
+ return "{:.1f} {}".format(n / factor, unit)
+ return "{} {}".format(n, unit)
+
+
def init_templating(app: quart.Quart) -> None:
app.template_filter("repr")(repr)
app.template_filter("format_datetime")(flask_babel.format_datetime)
app.template_filter("format_date")(flask_babel.format_date)
app.template_filter("format_time")(flask_babel.format_time)
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
+ app.template_filter("format_percent")(flask_babel.format_percent)
+ app.template_filter("format_bytes")(format_bytes)
app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name)
diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py
index c962bd3..ab6346f 100644
--- a/snikket_web/prosodyclient.py
+++ b/snikket_web/prosodyclient.py
@@ -1175,3 +1175,41 @@ class ProsodyClient:
json=payload) as resp:
resp.raise_for_status()
return (await resp.json())["jid"]
+
+ @autosession
+ async def get_system_metrics(
+ self,
+ *,
+ session: aiohttp.ClientSession) -> typing.Mapping:
+ async with session.get(
+ self._admin_v1_endpoint("/server/metrics"),
+ ) as resp:
+ if resp.status == 404:
+ return {}
+ self._raise_error_from_response(resp)
+ resp.raise_for_status()
+ return await resp.json()
+
+ @autosession
+ async def post_announcement(
+ self,
+ body: str,
+ recipients: str,
+ *,
+ session: aiohttp.ClientSession) -> None:
+ recipients_payload: typing.Union[str, typing.Sequence[str]]
+ if recipients == "self":
+ recipients_payload = [self.session_address]
+ else:
+ recipients_payload = recipients
+
+ payload = {
+ "recipients": recipients_payload,
+ "body": body,
+ }
+
+ async with session.post(
+ self._admin_v1_endpoint("/server/announcement"),
+ json=payload) as resp:
+ self._raise_error_from_response(resp)
+ resp.raise_for_status()
diff --git a/snikket_web/static/img/icons.svg b/snikket_web/static/img/icons.svg
index 1d241ec..dd6a38d 100644
--- a/snikket_web/static/img/icons.svg
+++ b/snikket_web/static/img/icons.svg
@@ -52,6 +52,12 @@ licensed under the terms of the Apache 2.0 License -->
{% trans %}View the server status or send a broadcast message to all users.{% endtrans %}
+ {#- -#} +{% trans %}Go back to your user's web portal page.{% endtrans %}
diff --git a/snikket_web/templates/admin_system.html b/snikket_web/templates/admin_system.html new file mode 100644 index 0000000..6d57c87 --- /dev/null +++ b/snikket_web/templates/admin_system.html @@ -0,0 +1,95 @@ +{% extends "admin_app.html" %} +{% from "library.j2" import form_button %} +{% block content %} +%(description)s
" msgstr "" -#: snikket_web/templates/admin_edit_user.html:51 +#: snikket_web/templates/admin_edit_user.html:50 msgid "Return to user list" msgstr "" -#: snikket_web/templates/admin_edit_user.html:59 +#: snikket_web/templates/admin_edit_user.html:58 msgid "Further actions" msgstr "" -#: snikket_web/templates/admin_edit_user.html:61 +#: snikket_web/templates/admin_edit_user.html:60 msgid "Reset password" msgstr "" -#: snikket_web/templates/admin_edit_user.html:64 +#: snikket_web/templates/admin_edit_user.html:63 msgid "" "If the user has lost their password, you can use the button below to " "create a special link which allows to change the password of the account," " once." msgstr "" -#: snikket_web/templates/admin_edit_user.html:69 +#: snikket_web/templates/admin_edit_user.html:68 msgid "Debug information" msgstr "" -#: snikket_web/templates/admin_edit_user.html:71 +#: snikket_web/templates/admin_edit_user.html:70 msgid "" "In some cases, extended information about the user account and the " "connected devices is necessary to troubleshoot issues. The button below " "reveals this (sensitive) information." msgstr "" -#: snikket_web/templates/admin_edit_user.html:75 +#: snikket_web/templates/admin_edit_user.html:74 msgid "Show debug information" msgstr "" @@ -756,11 +776,24 @@ msgstr "" msgid "Manage invitations" msgstr "" -#: snikket_web/templates/admin_home.html:36 +#: snikket_web/templates/admin_home.html:35 +msgid "System health" +msgstr "" + +#: snikket_web/templates/admin_home.html:37 +msgid "View the server status or send a broadcast message to all users." +msgstr "" + +#: snikket_web/templates/admin_home.html:39 +#: snikket_web/templates/admin_system.html:4 +msgid "Manage system" +msgstr "" + +#: snikket_web/templates/admin_home.html:44 msgid "Go back to your user's web portal page." msgstr "" -#: snikket_web/templates/admin_home.html:38 +#: snikket_web/templates/admin_home.html:46 msgid "Exit admin panel" msgstr "" @@ -811,6 +844,77 @@ msgstr "" msgid "Destroy link" msgstr "" +#: snikket_web/templates/admin_system.html:5 +msgid "Overall system status" +msgstr "" + +#: snikket_web/templates/admin_system.html:8 +msgid "System load (5 minute average)" +msgstr "" + +#: snikket_web/templates/admin_system.html:13 +#: snikket_web/templates/admin_system.html:21 +#: snikket_web/templates/admin_system.html:36 +#: snikket_web/templates/admin_system.html:44 +#: snikket_web/templates/admin_system.html:59 +#: snikket_web/templates/admin_system.html:67 +#: snikket_web/templates/admin_system.html:75 +msgid "unknown" +msgstr "" + +#: snikket_web/templates/admin_system.html:16 +msgid "Memory use" +msgstr "" + +#: snikket_web/templates/admin_system.html:19 +#, python-format +msgid "" +"%(percentage_global)s of %(mem_available)s. Of that, Snikket uses " +"%(percentage_snikket)s." +msgstr "" + +#: snikket_web/templates/admin_system.html:26 +msgid "Web portal status" +msgstr "" + +#: snikket_web/templates/admin_system.html:29 +#: snikket_web/templates/admin_system.html:52 +msgid "Version" +msgstr "" + +#: snikket_web/templates/admin_system.html:30 +#: snikket_web/templates/admin_system.html:53 +msgid "View all versions" +msgstr "" + +#: snikket_web/templates/admin_system.html:31 +#: snikket_web/templates/admin_system.html:54 +msgid "Average CPU use" +msgstr "" + +#: snikket_web/templates/admin_system.html:39 +#: snikket_web/templates/admin_system.html:62 +msgid "Current memory use" +msgstr "" + +#: snikket_web/templates/admin_system.html:49 +msgid "Snikket server status" +msgstr "" + +#: snikket_web/templates/admin_system.html:70 +msgid "Connected devices" +msgstr "" + +#: snikket_web/templates/admin_system.html:80 +msgid "Broadcast message" +msgstr "" + +#: snikket_web/templates/admin_system.html:82 +msgid "" +"This form allows you to send a message to all users currently online on " +"your Snikket server. Use it wisely." +msgstr "" + #: snikket_web/templates/admin_users.html:19 msgid "The user is an administrator." msgstr "" diff --git a/tools/icons.list b/tools/icons.list index 47bc0a4..da134b8 100644 --- a/tools/icons.list +++ b/tools/icons.list @@ -8,11 +8,13 @@ action/exit_to_app:exit_to_app action/lock:lock communication/qr_code:qrcode communication/vpn_key:passwd +communication/rss_feed:broadcast content/add_circle_outline:add content/add_link:create_link content/remove_circle_outline:remove content/content_copy:copy content/link_off:remove_link +content/send:send navigation/arrow_back:back navigation/arrow_forward:forward navigation/cancel:cancel @@ -26,3 +28,4 @@ navigation/close:close image/edit:edit action/admin_panel_settings:admin content/link:link +content/insights:insights