Skip to content

Commit

Permalink
Add VueJS UI (#9)
Browse files Browse the repository at this point in the history
* import basic scaffolding for a vue.js ui

* run "npm install" in the first stage of the build, then copy the files over

* add cache buster

* install bootstrap and jquery

* include hostname in title, remove target table data

* fix path

* auto-refresh the table every 30s

* add basic bootstrap design

* start with the template row hidden to prevent it from flashing up while the UI is loading

* implement searching 🎉

* store search term in localStorage; focus search field on ^f

* distinguish "list not loaded" from "search result is empty"

* add page title

* no need for the button to be this dark

* blur the field on ESC

* sort targets by numeric IP
  • Loading branch information
Svedrin authored Mar 22, 2020
1 parent bdce87a commit 23c3235
Show file tree
Hide file tree
Showing 7 changed files with 567 additions and 69 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ python-ping/
oping.c
oping.so
build/

ui/node_modules/
11 changes: 10 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

FROM alpine:latest

RUN apk add --no-cache python3 python3-dev musl-dev liboping-dev make gcc bash
RUN apk add --no-cache python3 python3-dev musl-dev liboping-dev make gcc bash nodejs npm
RUN pip3 install Cython

COPY ui/package*.json /opt/meshping/ui/
RUN cd /opt/meshping/ui && npm install

WORKDIR /opt/meshping
COPY build.sh /opt/meshping/build.sh
COPY oping-py /opt/meshping/oping-py
Expand All @@ -19,8 +22,14 @@ COPY requirements.txt /opt/meshping/requirements.txt
RUN pip3 install -r /opt/meshping/requirements.txt

WORKDIR /opt/meshping
COPY --from=0 /opt/meshping/ui/node_modules/jquery/dist/jquery.slim.min.js /opt/meshping/ui/node_modules/jquery/dist/
COPY --from=0 /opt/meshping/ui/node_modules/bootstrap/dist/css/bootstrap.min.css /opt/meshping/ui/node_modules/bootstrap/dist/css/
COPY --from=0 /opt/meshping/ui/node_modules/bootstrap/dist/js/bootstrap.min.js /opt/meshping/ui/node_modules/bootstrap/dist/js/
COPY --from=0 /opt/meshping/ui/node_modules/vue/dist/vue.min.js /opt/meshping/ui/node_modules/vue/dist/
COPY --from=0 /opt/meshping/ui/node_modules/vue-resource/dist/vue-resource.min.js /opt/meshping/ui/node_modules/vue-resource/dist/
COPY --from=0 /usr/lib/python3.8/site-packages/oping.*.so /usr/lib/python3.8/site-packages
COPY cli.py /usr/local/bin/mpcli
COPY src /opt/meshping/src
COPY ui/src /opt/meshping/ui/src
ENTRYPOINT ["dumb-init", "--"]
CMD ["/usr/bin/python3", "--", "/opt/meshping/src/meshping.py"]
81 changes: 49 additions & 32 deletions src/prom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,21 @@

from __future__ import division

import socket

from uuid import uuid4
from quart import Response, render_template, request, jsonify
from quart import Response, render_template, request, jsonify, send_from_directory
from quart_trio import QuartTrio

from ifaces import Ifaces4

def run_prom(mp):
app = QuartTrio(__name__)
app = QuartTrio(__name__, static_url_path="")
app.config["TEMPLATES_AUTO_RELOAD"] = True

@app.route("/")
async def index():
targets = []

def ip_as_int(tgt):
import socket
import struct
if ":" not in tgt["addr"]:
return struct.unpack("!I", socket.inet_aton( tgt["addr"] ))[0]
else:
ret = 0
for intpart in struct.unpack("!IIII", socket.inet_pton(socket.AF_INET6, tgt["addr"] )):
ret = ret<<32 | intpart
return ret

for targetinfo in sorted(mp.targets.values(), key=ip_as_int):
loss = 0
if targetinfo["sent"]:
loss = (targetinfo["sent"] - targetinfo["recv"]) / targetinfo["sent"] * 100
targets.append(
dict(
targetinfo,
name=targetinfo["name"][:24],
succ=100 - loss,
loss=loss,
avg15m=targetinfo.get("avg15m", 0),
avg6h =targetinfo.get("avg6h", 0),
avg24h=targetinfo.get("avg24h", 0),
)
)

return await render_template("index.html", Targets=targets)
return await render_template("index.html", Hostname=socket.gethostname())

@app.route("/metrics")
async def metrics():
Expand Down Expand Up @@ -148,6 +122,49 @@ async def peer():

return jsonify(success=True, targets=stats)

@app.route('/ui/<path:path>')
async def send_js(path):
resp = await send_from_directory('ui', path)
# Cache bust XXL
resp.cache_control.no_cache = True
resp.cache_control.no_store = True
resp.cache_control.max_age = None
resp.cache_control.must_revalidate = True
return resp

@app.route("/api/targets")
async def targets():
targets = []

def ip_as_int(addr):
import socket
import struct
if ":" not in addr:
return struct.unpack("!I", socket.inet_aton(addr))[0]
else:
ret = 0
for intpart in struct.unpack("!IIII", socket.inet_pton(socket.AF_INET6, addr)):
ret = ret<<32 | intpart
return ret

for targetinfo in mp.targets.values():
loss = 0
if targetinfo["sent"]:
loss = (targetinfo["sent"] - targetinfo["recv"]) / targetinfo["sent"] * 100
targets.append(
dict(targetinfo,
name=targetinfo["name"][:24],
addr_as_int=ip_as_int(targetinfo["addr"]),
succ=100 - loss,
loss=loss,
avg15m=targetinfo.get("avg15m", 0),
avg6h =targetinfo.get("avg6h", 0),
avg24h=targetinfo.get("avg24h", 0),
)
)

return jsonify(success=True, targets=targets)

app.secret_key = str(uuid4())
app.debug = False

Expand Down
107 changes: 71 additions & 36 deletions src/templates/index.html
Original file line number Diff line number Diff line change
@@ -1,36 +1,71 @@
<h1>Meshping</h1>
<meta http-equiv="refresh" content="30">
<a href="/metrics">metrics</a>

<table cellspacing="10px">
<tr>
<th>Target</th>
<th>Address</th>
<th>Sent</th>
<th>Recv</th>
<th>Succ</th>
<th>Loss</th>
<th>Min</th>
<th>Avg15m</th>
<th>Avg6h</th>
<th>Avg24h</th>
<th>Max</th>
<th>Last</th>
</tr>
{% for target in Targets %}
<tr>
<td>{{ target["name"] }}</td>
<td>{{ target["addr"] }}</td>
<td align="right">{{ target["sent"] }}</td>
<td align="right">{{ target["recv"] }}</td>
<td align="right">{{ "%7.2f"|format(target["succ"] ) }}</td>
<td align="right">{{ "%7.2f"|format(target["loss"] ) }}</td>
<td align="right">{{ "%7.2f"|format(target["min"] ) }}</td>
<td align="right">{{ "%7.2f"|format(target["avg15m"]) }}</td>
<td align="right">{{ "%7.2f"|format(target["avg6h"] ) }}</td>
<td align="right">{{ "%7.2f"|format(target["avg24h"]) }}</td>
<td align="right">{{ "%7.2f"|format(target["max"] ) }}</td>
<td align="right">{{ "%7.2f"|format(target["last"] ) }}</td>
</tr>
{% endfor %}
</table>
<!DOCTYPE html>
<html>
<head>
<title>{{ Hostname }} &mdash; Meshping</title>
<link rel="stylesheet" href="/ui/node_modules/bootstrap/dist/css/bootstrap.min.css"></script>
<script type="text/javascript" src="/ui/node_modules/jquery/dist/jquery.slim.min.js"></script>
<script type="text/javascript" src="/ui/node_modules/bootstrap/dist/js/bootstrap.min.js"></script>
<script type="text/javascript" src="/ui/node_modules/vue/dist/vue.min.js"></script>
<script type="text/javascript" src="/ui/node_modules/vue-resource/dist/vue-resource.min.js"></script>
</head>
<body>
<div class="container mt-sm-3">
<h1>Meshping: {{ Hostname }}</h1>
<div id="app">
<div class="btn-toolbar justify-content-between py-sm-3" role="toolbar" aria-label="Toolbar with button groups">
<div class="btn-group" role="group" aria-label="Links">
<a type="button" class="btn btn-light" href="/metrics">Metrics</a>
</div>
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text" id="btnGroupSearch">Search</div>
</div>
<input type="text" v-model="search" id="inpsearch" class="form-control" placeholder="Name or IP" aria-label="Name or IP" aria-describedby="btnGroupSearch">
</div>
</div>
{% raw %}
<table class="table">
<tr>
<th>Target</th>
<th>Address</th>
<th class="text-right">Sent</th>
<th class="text-right">Recv</th>
<th class="text-right">Succ</th>
<th class="text-right">Loss</th>
<th class="text-right">Min</th>
<th class="text-right">Avg15m</th>
<th class="text-right">Avg6h</th>
<th class="text-right">Avg24h</th>
<th class="text-right">Max</th>
<th class="text-right">Last</th>
</tr>
<tr v-if="targets_all.length == 0">
<td colspan="12"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading</td>
</tr>
<tr v-if="targets_filtered.length == 0 &amp;&amp; targets_all.length > 0">
<td colspan="12">No targets match your search</td>
</tr>
<tr
v-show="targets_filtered.length > 0" style="display: none"
v-for="target in targets_filtered" :key="target.addr"
>
<td>{{ target.name }}</td>
<td>{{ target.addr }}</td>
<td class="text-right">{{ target.sent }}</td>
<td class="text-right">{{ target.recv }}</td>
<td class="text-right">{{ target.succ.toFixed(2) }}</td>
<td class="text-right">{{ target.loss.toFixed(2) }}</td>
<td class="text-right">{{ target.min.toFixed(2) }}</td>
<td class="text-right">{{ target.avg15m.toFixed(2) }}</td>
<td class="text-right">{{ target.avg6h.toFixed(2) }}</td>
<td class="text-right">{{ target.avg24h.toFixed(2) }}</td>
<td class="text-right">{{ target.max.toFixed(2) }}</td>
<td class="text-right">{{ target.last.toFixed(2) }}</td>
</tr>
</table>
{% endraw %}
</div>
</div>
<script type="text/javascript" src="/ui/src/main.js"></script>
</body>
</html>
Loading

0 comments on commit 23c3235

Please sign in to comment.