From 8a293985ca8854d3d7113e145bbe3401e11566c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Sch=C3=A4fer?= Date: Thu, 27 May 2021 15:01:16 +0200 Subject: [PATCH] Implement system status panel This offers system metrics and a way to send a broadcast message to all online or registered users. Requires prosody-modules cade5dac1003. --- snikket_web/admin.py | 141 ++++++++++++++- snikket_web/infra.py | 25 +++ snikket_web/prosodyclient.py | 38 ++++ snikket_web/static/img/icons.svg | 16 ++ snikket_web/templates/admin_home.html | 8 + snikket_web/templates/admin_system.html | 95 ++++++++++ snikket_web/translations/messages.pot | 222 +++++++++++++++++------- tools/icons.list | 3 + 8 files changed, 488 insertions(+), 60 deletions(-) create mode 100644 snikket_web/templates/admin_system.html 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 --> + + + + + + @@ -77,6 +83,11 @@ licensed under the terms of the Apache 2.0 License --> + + + + + @@ -142,4 +153,9 @@ licensed under the terms of the Apache 2.0 License --> + + + + + diff --git a/snikket_web/templates/admin_home.html b/snikket_web/templates/admin_home.html index 210591b..f76fc03 100644 --- a/snikket_web/templates/admin_home.html +++ b/snikket_web/templates/admin_home.html @@ -31,6 +31,14 @@
{% call standard_button("link", url_for(".invitations"), class="primary") %}{% trans %}Manage invitations{% endtrans %}{% endcall %}
{#- -#} +
  • +

    {% trans %}System health{% endtrans %}

    + {#- -#} +

    {% trans %}View the server status or send a broadcast message to all users.{% endtrans %}

    + {#- -#} +
    {% call standard_button("insights", url_for(".system"), class="primary") %}{% trans %}Manage system{% endtrans %}{% endcall %}
    + {#- -#} +
  • {#- -#}

    {% 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 %} +

    {% trans %}Manage system{% endtrans %}

    +

    {% trans %}Overall system status{% endtrans %}

    +
    +
    +
    {% trans %}System load (5 minute average){% endtrans %}
    +
    + {%- if metrics.load5 -%} + {{ metrics.load5 }} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    {% trans %}Memory use{% endtrans %}
    +
    + {%- if metrics.mem_total and metrics.mem_available -%} + {% trans percentage_global=((1 - (metrics.mem_available / metrics.mem_total)) | format_percent), percentage_snikket=((((metrics.prosody_rss | default(0)) + (metrics.portal_rss | default(0))) / metrics.mem_total) | format_percent), mem_available=(metrics.mem_total | format_bytes) %}{{ percentage_global }} of {{ mem_available }}. Of that, Snikket uses {{ percentage_snikket }}.{% endtrans %} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    +
    +

    {% trans %}Web portal status{% endtrans %}

    +
    +
    +
    {% trans %}Version{% endtrans %}
    +
    {{ version }} {% trans %}View all versions{% endtrans %}
    +
    {% trans %}Average CPU use{% endtrans %}
    +
    + {%- if metrics.portal_cpu -%} + {{ metrics.portal_cpu | format_percent }} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    {% trans %}Current memory use{% endtrans %}
    +
    + {%- if metrics.portal_rss -%} + {{ metrics.portal_rss | format_bytes }} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    +
    +

    {% trans %}Snikket server status{% endtrans %}

    +
    +
    +
    {% trans %}Version{% endtrans %}
    +
    {{ prosody_version }} {% trans %}View all versions{% endtrans %}
    +
    {% trans %}Average CPU use{% endtrans %}
    +
    + {%- if metrics.prosody_cpu -%} + {{ metrics.prosody_cpu | format_percent }} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    {% trans %}Current memory use{% endtrans %}
    +
    + {%- if metrics.prosody_rss -%} + {{ metrics.prosody_rss | format_bytes }} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    {% trans %}Connected devices{% endtrans %}
    +
    + {%- if metrics.prosody_devices | default(None) is not none -%} + {{ metrics.prosody_devices }} + {%- else -%} + {% trans %}unknown{% endtrans %} + {%- endif -%} +
    +
    +
    +

    {% trans %}Broadcast message{% endtrans %}

    +
    {{ form.csrf_token }}
    +

    {% trans %}This form allows you to send a message to all users currently online on your Snikket server. Use it wisely.{% endtrans %}

    +
    + {{ form.text.label }} + {{ form.text }} +
    +
    + {{ form.online_only }}{{ form.online_only.label }} +
    +
    + {%- call form_button("send", form.action_send_preview, class="primary") -%}{%- endcall -%} + {%- call form_button("broadcast", form.action_post_all, class="secondary accent") -%}{%- endcall -%} +
    +
    +{% endblock %} diff --git a/snikket_web/translations/messages.pot b/snikket_web/translations/messages.pot index c7482f4..a513ff1 100644 --- a/snikket_web/translations/messages.pot +++ b/snikket_web/translations/messages.pot @@ -8,186 +8,206 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-03-25 17:32+0100\n" +"POT-Creation-Date: 2021-05-27 17:21+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: snikket_web/admin.py:59 +#: snikket_web/admin.py:61 msgid "Limited" msgstr "" -#: snikket_web/admin.py:64 snikket_web/templates/admin_delete_user.html:10 +#: snikket_web/admin.py:66 snikket_web/templates/admin_delete_user.html:10 #: snikket_web/templates/admin_users.html:8 msgid "Login name" msgstr "" -#: snikket_web/admin.py:68 snikket_web/templates/admin_delete_user.html:12 +#: snikket_web/admin.py:70 snikket_web/templates/admin_delete_user.html:12 #: snikket_web/templates/admin_users.html:9 snikket_web/user.py:61 msgid "Display name" msgstr "" -#: snikket_web/admin.py:72 snikket_web/templates/admin_edit_user.html:33 +#: snikket_web/admin.py:74 snikket_web/templates/admin_edit_user.html:32 msgid "Access Level" msgstr "" -#: snikket_web/admin.py:77 +#: snikket_web/admin.py:79 msgid "Normal user" msgstr "" -#: snikket_web/admin.py:78 +#: snikket_web/admin.py:80 msgid "Administrator" msgstr "" -#: snikket_web/admin.py:83 +#: snikket_web/admin.py:85 msgid "Update user" msgstr "" -#: snikket_web/admin.py:87 +#: snikket_web/admin.py:89 msgid "Create password reset link" msgstr "" -#: snikket_web/admin.py:105 +#: snikket_web/admin.py:107 msgid "Password reset link created" msgstr "" -#: snikket_web/admin.py:120 +#: snikket_web/admin.py:122 msgid "User information updated." msgstr "" -#: snikket_web/admin.py:142 +#: snikket_web/admin.py:144 msgid "Delete user permanently" msgstr "" -#: snikket_web/admin.py:155 +#: snikket_web/admin.py:157 msgid "User deleted" msgstr "" -#: snikket_web/admin.py:193 +#: snikket_web/admin.py:195 msgid "Password reset link not found" msgstr "" -#: snikket_web/admin.py:205 +#: snikket_web/admin.py:207 msgid "Password reset link deleted" msgstr "" -#: snikket_web/admin.py:225 +#: snikket_web/admin.py:227 msgid "Invite to circle" msgstr "" -#: snikket_web/admin.py:231 +#: snikket_web/admin.py:233 msgid "At least one circle must be selected" msgstr "" -#: snikket_web/admin.py:236 +#: snikket_web/admin.py:238 msgid "Valid for" msgstr "" -#: snikket_web/admin.py:238 +#: snikket_web/admin.py:240 msgid "One hour" msgstr "" -#: snikket_web/admin.py:239 +#: snikket_web/admin.py:241 msgid "Twelve hours" msgstr "" -#: snikket_web/admin.py:240 +#: snikket_web/admin.py:242 msgid "One day" msgstr "" -#: snikket_web/admin.py:241 +#: snikket_web/admin.py:243 msgid "One week" msgstr "" -#: snikket_web/admin.py:242 +#: snikket_web/admin.py:244 msgid "Four weeks" msgstr "" -#: snikket_web/admin.py:248 snikket_web/templates/admin_edit_invite.html:17 +#: snikket_web/admin.py:250 snikket_web/templates/admin_edit_invite.html:17 msgid "Invitation type" msgstr "" -#: snikket_web/admin.py:250 snikket_web/templates/library.j2:116 +#: snikket_web/admin.py:252 snikket_web/templates/library.j2:116 msgid "Individual" msgstr "" -#: snikket_web/admin.py:251 snikket_web/templates/library.j2:114 +#: snikket_web/admin.py:253 snikket_web/templates/library.j2:114 msgid "Group" msgstr "" -#: snikket_web/admin.py:257 +#: snikket_web/admin.py:259 msgid "New invitation link" msgstr "" -#: snikket_web/admin.py:319 +#: snikket_web/admin.py:321 msgid "Revoke" msgstr "" -#: snikket_web/admin.py:343 +#: snikket_web/admin.py:345 msgid "Invitation created" msgstr "" -#: snikket_web/admin.py:359 +#: snikket_web/admin.py:361 msgid "No such invitation exists" msgstr "" -#: snikket_web/admin.py:374 +#: snikket_web/admin.py:376 msgid "Invitation revoked" msgstr "" -#: snikket_web/admin.py:391 snikket_web/admin.py:439 +#: snikket_web/admin.py:393 snikket_web/admin.py:441 msgid "Name" msgstr "" -#: snikket_web/admin.py:396 snikket_web/templates/admin_circles.html:47 +#: snikket_web/admin.py:398 snikket_web/templates/admin_circles.html:47 msgid "Create circle" msgstr "" -#: snikket_web/admin.py:426 +#: snikket_web/admin.py:428 msgid "Circle created" msgstr "" -#: snikket_web/admin.py:444 +#: snikket_web/admin.py:446 msgid "Select user" msgstr "" -#: snikket_web/admin.py:449 +#: snikket_web/admin.py:451 msgid "Update circle" msgstr "" -#: snikket_web/admin.py:453 +#: snikket_web/admin.py:455 msgid "Delete circle permanently" msgstr "" -#: snikket_web/admin.py:459 +#: snikket_web/admin.py:461 msgid "Add user" msgstr "" -#: snikket_web/admin.py:475 +#: snikket_web/admin.py:477 msgid "No such circle exists" msgstr "" -#: snikket_web/admin.py:512 +#: snikket_web/admin.py:514 msgid "Circle data updated" msgstr "" -#: snikket_web/admin.py:518 +#: snikket_web/admin.py:520 msgid "Circle deleted" msgstr "" -#: snikket_web/admin.py:529 +#: snikket_web/admin.py:531 msgid "User added to circle" msgstr "" -#: snikket_web/admin.py:538 +#: snikket_web/admin.py:540 msgid "User removed from circle" msgstr "" -#: snikket_web/infra.py:41 +#: snikket_web/admin.py:610 +msgid "Message contents" +msgstr "" + +#: snikket_web/admin.py:616 +msgid "Only send to online users" +msgstr "" + +#: snikket_web/admin.py:620 +msgid "Post to all users" +msgstr "" + +#: snikket_web/admin.py:624 +msgid "Send preview to yourself" +msgstr "" + +#: snikket_web/admin.py:646 +msgid "Announcement sent!" +msgstr "" + +#: snikket_web/infra.py:51 msgid "Main" msgstr "" @@ -497,7 +517,7 @@ msgid "Delete user %(user_name)s" msgstr "" #: snikket_web/templates/admin_delete_user.html:6 -#: snikket_web/templates/admin_edit_user.html:54 +#: snikket_web/templates/admin_edit_user.html:53 msgid "Delete user" msgstr "" @@ -664,56 +684,56 @@ msgstr "" msgid "Edit user %(user_name)s" msgstr "" -#: snikket_web/templates/admin_edit_user.html:23 +#: snikket_web/templates/admin_edit_user.html:22 msgid "Edit user" msgstr "" -#: snikket_web/templates/admin_edit_user.html:27 +#: snikket_web/templates/admin_edit_user.html:26 msgid "The login name cannot be changed." msgstr "" -#: snikket_web/templates/admin_edit_user.html:34 +#: snikket_web/templates/admin_edit_user.html:33 msgid "" "The access level of a user determines what interactions are allowed for " "them on your Snikket service." msgstr "" -#: snikket_web/templates/admin_edit_user.html:41 +#: snikket_web/templates/admin_edit_user.html:40 #, python-format msgid "%(title)s%(icon)s

    %(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