diff --git a/README b/README
index 83a6d05..be3defe 100644
--- a/README
+++ b/README
@@ -163,8 +163,8 @@ with others, there is no need to go through the landing page.
Recordings can be accessed under `/recordings/groupname`. This is only
available to the administrator of the group.
-Some statistics are available under `/stats.json`. This is only available
-to the server administrator.
+Some statistics are available under `/stats.json`, with a human-readable
+version at `/stats.html`. This is only available to the server administrator.
## Side menu
diff --git a/static/stats.html b/static/stats.html
new file mode 100644
index 0000000..02d8b3b
--- /dev/null
+++ b/static/stats.html
@@ -0,0 +1,17 @@
+
+
+
+ Galène statistics
+
+
+
+
+
+
+
+
+
Galène statistics
+
+
+
+
diff --git a/static/stats.js b/static/stats.js
new file mode 100644
index 0000000..31c5fa0
--- /dev/null
+++ b/static/stats.js
@@ -0,0 +1,118 @@
+// Copyright (c) 2021 by Juliusz Chroboczek.
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+'use strict';
+
+async function listStats() {
+ let table = document.getElementById('stats-table');
+
+ let l;
+ try {
+ l = await (await fetch('/stats.json')).json();
+ } catch(e) {
+ console.error(e);
+ l = [];
+ }
+
+ if(l.length === 0) {
+ table.textContent = '(No group found.)';
+ return;
+ }
+
+ for(let i = 0; i < l.length; i++)
+ table.appendChild(formatGroup(l[i]));
+}
+
+function formatGroup(group) {
+ let tr = document.createElement('tr');
+ let td = document.createElement('td');
+ td.textContent = group.name;
+ tr.appendChild(td);
+ if(group.clients) {
+ let td2 = document.createElement('td');
+ let table = document.createElement('table');
+ for(let i = 0; i < group.clients.length; i++) {
+ let client = group.clients[i];
+ let tr2 = document.createElement('tr');
+ let td3 = document.createElement('td');
+ td3.textContent = client.id;
+ tr2.appendChild(td3);
+ table.appendChild(tr2);
+ if(client.up)
+ for(let j = 0; j < client.up.length; j++)
+ table.appendChild(formatConn('↑', client.up[j]));
+ if(client.down)
+ for(let j = 0; j < client.down.length; j++)
+ table.appendChild(formatConn('↓', client.down[j]));
+ }
+ td2.appendChild(table);
+ tr.appendChild(td2);
+ }
+ return tr;
+}
+
+function formatConn(direction, conn) {
+ let tr = document.createElement('tr');
+ let td = document.createElement('td');
+ tr.appendChild(td);
+ let td2 = document.createElement('td');
+ td2.textContent = conn.id;
+ tr.appendChild(td2);
+ let td3 = document.createElement('td');
+ if(conn.maxBitrate)
+ td3.textContent = direction + ' ' + conn.maxBitrate;
+ else
+ td3.textContent = direction;
+ tr.appendChild(td3);
+ let td4 = document.createElement('td');
+ if(conn.tracks) {
+ let table = document.createElement('table');
+ for(let i = 0; i < conn.tracks.length; i++)
+ table.appendChild(formatTrack(conn.tracks[i]));
+ td4.appendChild(table);
+ }
+ tr.appendChild(td4);
+ return tr;
+}
+
+function formatTrack(track) {
+ let tr = document.createElement('tr');
+ let td = document.createElement('td');
+ if(track.maxBitrate)
+ td.textContent = `${track.bitrate||0}/${track.maxBitrate}`;
+ else
+ td.textContent = `${track.bitrate||0}`;
+ tr.appendChild(td);
+ let td2 = document.createElement('td');
+ td2.textContent = `${Math.round(track.loss * 100)}%`;
+ tr.appendChild(td2);
+ let td3 = document.createElement('td');
+ let text = '';
+ if(track.rtt) {
+ text = text + `${Math.round(track.rtt * 1000) / 1000}ms`;
+ }
+ if(track.jitter)
+ text = text + `±${Math.round(track.jitter * 1000) / 1000}ms`;
+ td3.textContent = text;
+ tr.appendChild(td3);
+ return tr;
+}
+
+listStats();