Deploy msgboard
Browse files- src/msgboard/analytics.py +99 -0
- src/msgboard/client.py +9 -3
- src/msgboard/dashboard.html +301 -41
- src/msgboard/db.py +14 -1
- src/msgboard/presence.py +27 -0
- src/msgboard/server.py +22 -6
- src/msgboard/store.py +151 -24
src/msgboard/analytics.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Units, range validation and aggregation helpers for board analytics. SQL stays in store."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
from .errors import bad_request
|
| 9 |
+
|
| 10 |
+
DAY = 86400
|
| 11 |
+
MAX_TIMESTAMP = 253402300800 # exclusive end of UTC year 9999
|
| 12 |
+
WORD = re.compile(r"\b\w+(?:['’]\w+)*\b", re.UNICODE)
|
| 13 |
+
RESPONSE_BOUNDS = (0, 60, 300, 900, 3600, 14400, 86400)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def bounds(now: float, days: int = 7, start: float | None = None,
|
| 17 |
+
end: float | None = None) -> tuple[float, float]:
|
| 18 |
+
end = now if end is None else end
|
| 19 |
+
if not math.isfinite(end) or not 0 < end <= MAX_TIMESTAMP:
|
| 20 |
+
raise bad_request("to must be a finite positive Unix timestamp through UTC year 9999")
|
| 21 |
+
if start is None:
|
| 22 |
+
start = max(0, (math.ceil(end / DAY) - days) * DAY)
|
| 23 |
+
if not math.isfinite(start) or start < 0 or start >= end:
|
| 24 |
+
raise bad_request("from must be a finite nonnegative Unix timestamp before to")
|
| 25 |
+
if end - start > 365 * DAY:
|
| 26 |
+
raise bad_request("choose at most 365 days")
|
| 27 |
+
return start, end
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def percentile(values: list[float], fraction: float) -> float | None:
|
| 31 |
+
if not values:
|
| 32 |
+
return None
|
| 33 |
+
ordered = sorted(values)
|
| 34 |
+
index = (len(ordered) - 1) * fraction
|
| 35 |
+
low = math.floor(index)
|
| 36 |
+
high = math.ceil(index)
|
| 37 |
+
return ordered[low] + (ordered[high] - ordered[low]) * (index - low)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def response_summary(requests: int, latencies: list[float]) -> dict:
|
| 41 |
+
return {"requests": requests, "answered": len(latencies), "unanswered": requests - len(latencies),
|
| 42 |
+
"median_seconds": percentile(latencies, .5), "p90_seconds": percentile(latencies, .9)}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def response_histogram(latencies: list[float]) -> list[dict]:
|
| 46 |
+
bins = [{"from_seconds": low, "to_seconds": high, "count": 0}
|
| 47 |
+
for low, high in zip(RESPONSE_BOUNDS, (*RESPONSE_BOUNDS[1:], None))]
|
| 48 |
+
for value in latencies:
|
| 49 |
+
for bucket in bins:
|
| 50 |
+
if bucket["to_seconds"] is None or value < bucket["to_seconds"]:
|
| 51 |
+
bucket["count"] += 1
|
| 52 |
+
break
|
| 53 |
+
return bins
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def chord_graph(edges: list[dict], identities: dict[int, dict], top_k: int) -> dict:
|
| 57 |
+
weights: dict[int, int] = {}
|
| 58 |
+
for edge in edges:
|
| 59 |
+
for pid in (edge["source"], edge["target"]):
|
| 60 |
+
weights[pid] = weights.get(pid, 0) + edge["total"]
|
| 61 |
+
ranked = sorted(weights, key=lambda pid: (-weights[pid], pid))
|
| 62 |
+
selected = set(ranked[:top_k])
|
| 63 |
+
nodes = [dict(identities[pid], total=weights[pid], members=1) for pid in ranked[:top_k]]
|
| 64 |
+
hidden = set(ranked) - selected
|
| 65 |
+
if hidden:
|
| 66 |
+
nodes.append({"id": "other", "display_name": "Other", "external_id": "other", "identity_type": "other",
|
| 67 |
+
"total": sum(weights[pid] for pid in hidden), "members": len(hidden)})
|
| 68 |
+
links = {}
|
| 69 |
+
for edge in edges:
|
| 70 |
+
source = edge["source"] if edge["source"] in selected else "other"
|
| 71 |
+
target = edge["target"] if edge["target"] in selected else "other"
|
| 72 |
+
key = tuple(sorted((source, target), key=lambda pid: (pid == "other", str(pid))))
|
| 73 |
+
link = links.setdefault(key, {"source": key[0], "target": key[1], "total": 0,
|
| 74 |
+
"mentions": 0, "replies": 0, "dms": 0})
|
| 75 |
+
for field in ("total", "mentions", "replies", "dms"):
|
| 76 |
+
link[field] += edge[field]
|
| 77 |
+
result = sorted(links.values(), key=lambda link: (-link["total"], str(link["source"]), str(link["target"])))
|
| 78 |
+
return {"top_k": top_k, "total": sum(edge["total"] for edge in edges), "nodes": nodes, "links": result,
|
| 79 |
+
"other_internal": next((dict(link) for link in result if link["source"] == link["target"] == "other"),
|
| 80 |
+
{"source": "other", "target": "other", "total": 0, "mentions": 0, "replies": 0, "dms": 0})}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def listening_series(start: float, end: float, records: dict[int, tuple[float, int]],
|
| 84 |
+
started_at: float | None) -> dict:
|
| 85 |
+
width = max(60, math.ceil((end - start) / 1500 / 60) * 60)
|
| 86 |
+
first = math.floor(start / width) * width
|
| 87 |
+
buckets = {at: [] for at in range(first, math.ceil(end / width) * width, width)}
|
| 88 |
+
for minute, (observed_at, count) in records.items():
|
| 89 |
+
at = (minute // width) * width
|
| 90 |
+
if start <= observed_at < end and at in buckets:
|
| 91 |
+
buckets[at].append(count)
|
| 92 |
+
series = []
|
| 93 |
+
for at, counts in buckets.items():
|
| 94 |
+
lo, hi = max(at, start), min(at + width, end)
|
| 95 |
+
expected = math.ceil(hi / 60) - math.floor(lo / 60)
|
| 96 |
+
series.append({"at": at, "count": sum(counts) / len(counts) if counts else None,
|
| 97 |
+
"min": min(counts) if counts else None, "max": max(counts) if counts else None,
|
| 98 |
+
"samples": len(counts), "expected_samples": expected})
|
| 99 |
+
return {"sample_seconds": 60, "bucket_seconds": width, "started_at": started_at, "series": series}
|
src/msgboard/client.py
CHANGED
|
@@ -328,9 +328,15 @@ class Client:
|
|
| 328 |
"""How to use this board, as markdown, ending with the workspace guidelines its admins wrote."""
|
| 329 |
return self._request("GET", "/guide").text
|
| 330 |
|
| 331 |
-
def analytics(self, days: int =
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
def open_dm(self, principal_id: int) -> dict:
|
| 336 |
"""The direct-message channel with someone (created on first use); post to its `id`."""
|
|
|
|
| 328 |
"""How to use this board, as markdown, ending with the workspace guidelines its admins wrote."""
|
| 329 |
return self._request("GET", "/guide").text
|
| 330 |
|
| 331 |
+
def analytics(self, days: int = 7, *, start: float | None = None, end: float | None = None,
|
| 332 |
+
top_k: int = 10) -> dict:
|
| 333 |
+
"""Analytics for admins/readers. Optional Unix-second bounds select [start, end)."""
|
| 334 |
+
params = {"days": days, "top_k": top_k}
|
| 335 |
+
if start is not None:
|
| 336 |
+
params["from"] = start
|
| 337 |
+
if end is not None:
|
| 338 |
+
params["to"] = end
|
| 339 |
+
return self._request("GET", "/analytics", params=params).json()
|
| 340 |
|
| 341 |
def open_dm(self, principal_id: int) -> dict:
|
| 342 |
"""The direct-message channel with someone (created on first use); post to its `id`."""
|
src/msgboard/dashboard.html
CHANGED
|
@@ -612,6 +612,31 @@ button.fchip:hover { border-color: var(--ink); }
|
|
| 612 |
.sbody p.lead a { color: var(--ink); }
|
| 613 |
#guide-rendered { border: 1px solid var(--rule); padding: 4px 14px; min-height: 120px; }
|
| 614 |
.an-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
.seg { display: inline-flex; border: 1px solid var(--rule-2); }
|
| 616 |
.seg button { background: none; border: 0; padding: 4px 11px; font: 11.5px var(--mono); color: var(--ink-2); }
|
| 617 |
.seg button + button { border-left: 1px solid var(--rule-2); }
|
|
@@ -755,6 +780,8 @@ dialog input[type=text] { width: 100%; margin: 4px 0 12px; }
|
|
| 755 |
<button class="info-btn hidden" id="ch-doc" aria-label="Channel doc" title="Channel doc"></button>
|
| 756 |
<button class="info-btn hidden" id="ch-info" aria-label="Channel info" aria-haspopup="dialog" aria-expanded="false" title="Channel info">
|
| 757 |
<svg viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="7.2"/><path d="M9 8v5"/><path d="M9 5.2h.01" stroke-width="2.2" stroke-linecap="round"/></svg></button>
|
|
|
|
|
|
|
| 758 |
</div>
|
| 759 |
<div id="chat-view" style="display:contents">
|
| 760 |
<div class="scroll" id="scroll"><div id="messages"></div></div>
|
|
@@ -764,6 +791,7 @@ dialog input[type=text] { width: 100%; margin: 4px 0 12px; }
|
|
| 764 |
<div id="settings" class="hidden"></div>
|
| 765 |
<div id="activity" class="scroll hidden" style="padding:0"></div>
|
| 766 |
<div id="search" class="scroll hidden"></div>
|
|
|
|
| 767 |
</main>
|
| 768 |
<section id="panel" class="hidden">
|
| 769 |
<div class="resizer" id="panel-resize" role="separator" aria-orientation="vertical" aria-label="Resize side panel" tabindex="0" title="Drag to resize · double-click to reset"></div>
|
|
@@ -987,7 +1015,7 @@ function decorate(html, mentions) {
|
|
| 987 |
if (h) { // a link to a message, file or channel on this board: open it here, with a readable label
|
| 988 |
a.dataset.internal = h; a.removeAttribute("target");
|
| 989 |
if (a.textContent.trim() === a.getAttribute("href") || a.textContent.trim() === a.href) {
|
| 990 |
-
a.textContent = h.startsWith("msg/") ? "↪ message" : h.startsWith("file/") ? "📎 file" : /^(settings|activity|search)$/.test(h) ? h : "#" + h;
|
| 991 |
}
|
| 992 |
} else { a.target = "_blank"; a.rel = "noopener noreferrer"; }
|
| 993 |
});
|
|
@@ -1465,11 +1493,13 @@ async function boot() {
|
|
| 1465 |
$("new-dm").classList.toggle("hidden", !can("post"));
|
| 1466 |
$("settings-open").classList.toggle("hidden", !(can("invite_agents") || can("manage")));
|
| 1467 |
$("add-agents").classList.toggle("hidden", !can("invite_agents"));
|
|
|
|
| 1468 |
$("show-private-row").classList.toggle("hidden", !can("manage"));
|
| 1469 |
$("show-private").checked = !!(state.me.prefs && state.me.prefs.show_private);
|
| 1470 |
await Promise.all([refreshChannels(), renderLibs]);
|
| 1471 |
if (location.hash === "#settings") return openSettings();
|
| 1472 |
if (location.hash === "#activity") return openActivity();
|
|
|
|
| 1473 |
if (/^#(msg|file)\//.test(location.hash)) { // a shared link: open a channel first, then the message or file
|
| 1474 |
const first = state.channels.find((c) => c.name === store.get("channel")) || state.channels.find((c) => c.kind !== "dm") || state.channels[0];
|
| 1475 |
const target = location.hash;
|
|
@@ -1614,7 +1644,7 @@ function updateMeta(live) {
|
|
| 1614 |
}
|
| 1615 |
function showChat() {
|
| 1616 |
state.view = "chat";
|
| 1617 |
-
$("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.add("hidden"); $("chat-view").style.display = "contents";
|
| 1618 |
}
|
| 1619 |
async function openChannel(id) {
|
| 1620 |
const c = state.channels.find((x) => x.id === id);
|
|
@@ -2632,7 +2662,7 @@ function internalHash(href) {
|
|
| 2632 |
let u; try { u = new URL(href, location.href); } catch { return null; }
|
| 2633 |
if (u.origin !== location.origin || u.pathname !== location.pathname || !u.hash) return null;
|
| 2634 |
const h = decodeURIComponent(u.hash.slice(1));
|
| 2635 |
-
return /^(msg|file)\/[\w-]+$/.test(h) || /^(settings|activity|search)$/.test(h) || state.chanIndex.has(h.toLowerCase()) ? h : null;
|
| 2636 |
}
|
| 2637 |
async function routeHash(h) {
|
| 2638 |
h = decodeURIComponent((h || "").replace(/^#/, ""));
|
|
@@ -2645,6 +2675,7 @@ async function routeHash(h) {
|
|
| 2645 |
if ((m = /^file\/([\w-]+)$/.exec(h))) { openViewer([m[1]], 0); return true; }
|
| 2646 |
if (h === "settings") { openSettings(); return true; }
|
| 2647 |
if (h === "activity") { openActivity(); return true; }
|
|
|
|
| 2648 |
if (h === "search") { openSearch(""); return true; }
|
| 2649 |
const c = state.chanIndex.get(h.toLowerCase()) || state.channels.find((x) => x.name === h);
|
| 2650 |
if (c) { closePanel(); openChannel(c.id); return true; }
|
|
@@ -3184,7 +3215,7 @@ async function openActivity() {
|
|
| 3184 |
if (state.poll) state.poll.abort();
|
| 3185 |
state.current = null; closePanel(); renderChannels();
|
| 3186 |
history.replaceState(null, "", location.pathname + "#activity");
|
| 3187 |
-
$("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("search").classList.add("hidden"); $("activity").classList.remove("hidden");
|
| 3188 |
$("ch-title").innerHTML = "Activity"; $("ch-desc").textContent = "";
|
| 3189 |
$("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3190 |
// show the last list right away and swap in the fresh one when it arrives
|
|
@@ -3370,7 +3401,7 @@ async function openSearch(q, opts = {}) {
|
|
| 3370 |
if (state.poll) state.poll.abort();
|
| 3371 |
state.current = null; closePanel(); renderChannels();
|
| 3372 |
history.replaceState(null, "", location.pathname + "#search");
|
| 3373 |
-
$("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.remove("hidden");
|
| 3374 |
$("ch-title").innerHTML = "Search"; $("ch-desc").textContent = "";
|
| 3375 |
$("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3376 |
const f = state.searchFilters = Object.assign({ channel: "", sender: "", when: "", has: "", by: "", sort: "newest" }, opts.keep ? state.searchFilters : {}, opts.filters || {});
|
|
@@ -3464,19 +3495,19 @@ function openSettings(tab) {
|
|
| 3464 |
if (state.poll) state.poll.abort();
|
| 3465 |
state.current = null; renderChannels(); closePanel();
|
| 3466 |
history.replaceState(null, "", location.pathname + "#settings");
|
| 3467 |
-
$("chat-view").style.display = "none"; $("activity").classList.add("hidden"); $("search").classList.add("hidden"); $("settings").classList.remove("hidden");
|
| 3468 |
$("ch-title").innerHTML = "Settings"; $("ch-desc").textContent = ""; $("ch-meta").textContent = "";
|
| 3469 |
$("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3470 |
const tabs = [["invites", "Invites"]];
|
| 3471 |
if (can("manage")) tabs.push(["people", "People"]);
|
| 3472 |
tabs.push(["agents", can("manage") ? "Agents" : "My agents"], ["listening", "Who's listening"]);
|
| 3473 |
-
if (can("manage")) tabs.push(["suspended", "Suspended"], ["board", "Board"]
|
| 3474 |
tab = tab || state.settingsTab || "invites";
|
| 3475 |
if (!tabs.some(([k]) => k === tab)) tab = "invites";
|
| 3476 |
state.settingsTab = tab;
|
| 3477 |
$("settings").innerHTML = `<div class="tabs">${tabs.map(([k, l]) => `<button data-tab="${k}" class="${k === tab ? "on" : ""}">${l}</button>`).join("")}</div><div class="sbody" id="sbody"><div class="empty">Loading…</div></div>`;
|
| 3478 |
$("settings").querySelectorAll("[data-tab]").forEach((b) => b.onclick = () => openSettings(b.dataset.tab));
|
| 3479 |
-
({ invites: settingsInvites, people: settingsPeople, agents: settingsAgents, listening: settingsListening, suspended: settingsSuspended, board: settingsBoard
|
| 3480 |
.catch((e) => { $("sbody").innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
| 3481 |
}
|
| 3482 |
// A2 look on trial: a per-browser switch, applied before first paint by the snippet in <head>
|
|
@@ -3778,48 +3809,274 @@ async function settingsListening() {
|
|
| 3778 |
hydrate($("sbody"));
|
| 3779 |
$("sbody").querySelectorAll("[data-lf]").forEach((b) => b.onclick = () => { state.listenFilter = b.dataset.lf; settingsListening(); });
|
| 3780 |
}
|
| 3781 |
-
|
| 3782 |
-
|
| 3783 |
-
|
| 3784 |
-
|
| 3785 |
-
|
| 3786 |
-
const
|
| 3787 |
-
|
| 3788 |
-
const
|
| 3789 |
-
|
| 3790 |
-
|
| 3791 |
-
|
| 3792 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3793 |
<div class="tiles">
|
| 3794 |
-
${tile("Messages",
|
| 3795 |
-
${tile("
|
| 3796 |
-
${tile("
|
| 3797 |
-
${tile("
|
| 3798 |
-
${tile("Files",
|
| 3799 |
-
${tile("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3800 |
</div>
|
| 3801 |
-
<div class="card
|
| 3802 |
-
<div class="legend"><span><i class="sw agents"></i>agents</span><span><i class="sw people"></i>people</span></div></div>
|
| 3803 |
-
<div id="an-chart" class="an-chart"></div>
|
| 3804 |
-
<details class="small"><summary>Show as table</summary><table class="an-table"><thead><tr><th>Day</th><th>Agents</th><th>People</th><th>Total</th></tr></thead><tbody>
|
| 3805 |
-
${a.daily.slice().reverse().map((d) => `<tr><td>${esc(dayLabel(d.day, true))}</td><td>${d.agents}</td><td>${d.people}</td><td>${d.agents + d.people}</td></tr>`).join("")}</tbody></table></details></div>
|
| 3806 |
<div class="card"><h4>Channels</h4>
|
| 3807 |
-
<table class="an-table wide"><thead><tr><th>Channel</th><th class="bar-col">Messages
|
| 3808 |
-
${a.channels.map((c) => `<tr class="${c.archived ? "muted" : ""}"><td>${c.id ? `<span class="chan-link" data-channel="${c.id}" role="link" tabindex="0">${c.kind === "private" ? LOCK : "#"}${esc(c.name)}</span>` : `<i>${esc(c.name)}</i>`}${c.archived ? ' <span class="label">archived</span>' : ""}</td>
|
| 3809 |
<td class="bar-col"><span class="hbar"><span style="width:${(100 * c.messages / maxC).toFixed(1)}%"></span></span><b>${c.messages}</b></td>
|
| 3810 |
<td>${c.posters}</td><td class="hide-sm">${c.total}</td><td class="small hide-sm">${c.last_at ? esc(ago(c.last_at)) : "never"}</td></tr>`).join("")}</tbody></table></div>
|
| 3811 |
-
<div class="card"><div class="chart-top"><h4>Who posts</h4><div class="seg small-seg" role="group" aria-label="Show">${[["all", "Everyone"], ["agents", "Agents"], ["people", "People"]].map(([
|
| 3812 |
-
`<button data-who="${
|
| 3813 |
${posters.length ? `<table class="an-table wide"><thead><tr><th>Name</th><th class="hide-sm">Owner</th><th class="bar-col">Messages</th><th class="hide-sm">Daily</th><th class="hide-sm">Channels</th><th>Last post</th></tr></thead><tbody>
|
| 3814 |
${posters.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span><span class="label">${esc(kindLabel(x))}</span></span></td>
|
| 3815 |
<td class="small hide-sm">${x.owner ? esc(nameOf(x.owner)) : ""}</td>
|
| 3816 |
<td class="bar-col"><span class="hbar"><span style="width:${(100 * x.messages / maxP).toFixed(1)}%"></span></span><b>${x.messages}</b></td>
|
| 3817 |
<td class="hide-sm">${spark(x.daily, a.daily)}</td><td class="hide-sm">${x.channels}</td><td class="small">${esc(ago(x.last_at))}</td></tr>`).join("")}</tbody></table>`
|
| 3818 |
-
: `<div class="empty">Nobody in this group posted in
|
| 3819 |
-
|
| 3820 |
-
$("
|
| 3821 |
-
|
| 3822 |
-
drawDaily($("an-chart"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3823 |
}
|
| 3824 |
function spark(values, daily) {
|
| 3825 |
const w = 84, h = 18, max = Math.max(1, ...values), bw = w / values.length;
|
|
@@ -3869,10 +4126,12 @@ function barPath(x, y0, w, h, roundTop) {
|
|
| 3869 |
const r = roundTop ? Math.min(4, w / 2, h) : 0; // rounded only on the data end
|
| 3870 |
return `M${x},${y0 + h}V${y0 + r}${r ? `Q${x},${y0} ${x + r},${y0}` : ""}H${x + w - r}${r ? `Q${x + w},${y0} ${x + w},${y0 + r}` : ""}V${y0 + h}Z`;
|
| 3871 |
}
|
| 3872 |
-
|
|
|
|
| 3873 |
|
| 3874 |
// ------------------------------------------------------------------ misc
|
| 3875 |
$("me-who").onclick = () => state.me && openProfile(state.me.id);
|
|
|
|
| 3876 |
$("menu").onclick = () => $("app").classList.toggle("nav");
|
| 3877 |
$("app").addEventListener("click", (e) => {
|
| 3878 |
if (!$("app").classList.contains("nav") || e.target.closest("aside, #menu")) return;
|
|
@@ -3935,6 +4194,7 @@ $("channel-form").addEventListener("submit", async (e) => {
|
|
| 3935 |
window.addEventListener("hashchange", () => {
|
| 3936 |
if (location.hash === "#settings") { if (state.view !== "settings") openSettings(); return; }
|
| 3937 |
if (location.hash === "#activity") { if (state.view !== "activity") openActivity(); return; }
|
|
|
|
| 3938 |
const c = state.channels.find((x) => x.name === decodeURIComponent(location.hash.slice(1)));
|
| 3939 |
if (c && (!state.current || c.id !== state.current.id)) openChannel(c.id);
|
| 3940 |
});
|
|
|
|
| 612 |
.sbody p.lead a { color: var(--ink); }
|
| 613 |
#guide-rendered { border: 1px solid var(--rule); padding: 4px 14px; min-height: 120px; }
|
| 614 |
.an-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
| 615 |
+
#ch-info:not(.hidden) + #analytics-open, #ch-doc:not(.hidden) ~ #analytics-open { margin-left: 4px; }
|
| 616 |
+
.an-page { max-width: 1400px; }
|
| 617 |
+
.an-page .an-head { justify-content: flex-start; margin-bottom: 4px; }
|
| 618 |
+
.an-custom { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 6px; border: 1px solid transparent; }
|
| 619 |
+
.an-custom.on { border-color: var(--ink); }
|
| 620 |
+
.an-custom input[type=date] { font: 12px var(--mono); border: 1px solid var(--rule-2); background: var(--bg); color: var(--ink); padding: 2px 4px; }
|
| 621 |
+
.an-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 18px; margin-bottom: 18px; }
|
| 622 |
+
.an-grid .card { margin: 0; min-width: 0; }
|
| 623 |
+
.an-rule { margin: 2px 0 6px; color: var(--ink-3); }
|
| 624 |
+
.an-k { display: inline-flex; align-items: center; gap: 6px; } .an-k input { width: 140px; accent-color: var(--ink); }
|
| 625 |
+
.an-chart .line { fill: none; stroke: var(--ink); stroke-width: 2; stroke-linejoin: round; }
|
| 626 |
+
.an-chart .dotm { fill: var(--ink); stroke: var(--bg); stroke-width: 2; }
|
| 627 |
+
.an-chart .mark { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; }
|
| 628 |
+
.an-chart .tick.strong { fill: var(--ink); font-weight: 600; }
|
| 629 |
+
.an-chord svg { max-width: 560px; margin: 0 auto; }
|
| 630 |
+
.an-chord .ribbon { fill: var(--ink); fill-opacity: .16; stroke: var(--bg); stroke-width: 1; transition: fill-opacity .1s; }
|
| 631 |
+
.an-chord .ribbon.dim { fill-opacity: .05; } .an-chord .ribbon:not(.dim):hover { fill-opacity: .6; }
|
| 632 |
+
.an-chord .arc { stroke: var(--ink); stroke-width: 1; cursor: default; }
|
| 633 |
+
.an-chord .arc.agents { fill: var(--ink); } .an-chord .arc.people { fill: var(--ink-3); } .an-chord .arc.other { fill: var(--bg); stroke-dasharray: 2 2; }
|
| 634 |
+
.an-chord .node { fill: var(--ink); font: 11.5px var(--sans); }
|
| 635 |
+
.sw.other { background: var(--bg); border: 1px dashed var(--ink); }
|
| 636 |
+
.an-files .hbar { width: calc(100% - 76px); } .an-files .bar-col b { white-space: nowrap; }
|
| 637 |
+
.an-health { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); border: 1px solid var(--rule-2); }
|
| 638 |
+
.an-health div { padding: 10px 12px; border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); margin: 0 -1px -1px 0; }
|
| 639 |
+
.an-health b { display: block; font-size: 17px; font-variant-numeric: tabular-nums; } .an-health span { font-size: 12px; color: var(--ink-3); }
|
| 640 |
.seg { display: inline-flex; border: 1px solid var(--rule-2); }
|
| 641 |
.seg button { background: none; border: 0; padding: 4px 11px; font: 11.5px var(--mono); color: var(--ink-2); }
|
| 642 |
.seg button + button { border-left: 1px solid var(--rule-2); }
|
|
|
|
| 780 |
<button class="info-btn hidden" id="ch-doc" aria-label="Channel doc" title="Channel doc"></button>
|
| 781 |
<button class="info-btn hidden" id="ch-info" aria-label="Channel info" aria-haspopup="dialog" aria-expanded="false" title="Channel info">
|
| 782 |
<svg viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="7.2"/><path d="M9 8v5"/><path d="M9 5.2h.01" stroke-width="2.2" stroke-linecap="round"/></svg></button>
|
| 783 |
+
<button class="info-btn hidden" id="analytics-open" aria-label="Analytics" title="Analytics">
|
| 784 |
+
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M2.5 15.5h13M5 13V9M9 13V4.5M13 13V7"/></svg></button>
|
| 785 |
</div>
|
| 786 |
<div id="chat-view" style="display:contents">
|
| 787 |
<div class="scroll" id="scroll"><div id="messages"></div></div>
|
|
|
|
| 791 |
<div id="settings" class="hidden"></div>
|
| 792 |
<div id="activity" class="scroll hidden" style="padding:0"></div>
|
| 793 |
<div id="search" class="scroll hidden"></div>
|
| 794 |
+
<div id="analytics" class="scroll hidden"></div>
|
| 795 |
</main>
|
| 796 |
<section id="panel" class="hidden">
|
| 797 |
<div class="resizer" id="panel-resize" role="separator" aria-orientation="vertical" aria-label="Resize side panel" tabindex="0" title="Drag to resize · double-click to reset"></div>
|
|
|
|
| 1015 |
if (h) { // a link to a message, file or channel on this board: open it here, with a readable label
|
| 1016 |
a.dataset.internal = h; a.removeAttribute("target");
|
| 1017 |
if (a.textContent.trim() === a.getAttribute("href") || a.textContent.trim() === a.href) {
|
| 1018 |
+
a.textContent = h.startsWith("msg/") ? "↪ message" : h.startsWith("file/") ? "📎 file" : /^(settings|activity|search|analytics)$/.test(h) ? h : "#" + h;
|
| 1019 |
}
|
| 1020 |
} else { a.target = "_blank"; a.rel = "noopener noreferrer"; }
|
| 1021 |
});
|
|
|
|
| 1493 |
$("new-dm").classList.toggle("hidden", !can("post"));
|
| 1494 |
$("settings-open").classList.toggle("hidden", !(can("invite_agents") || can("manage")));
|
| 1495 |
$("add-agents").classList.toggle("hidden", !can("invite_agents"));
|
| 1496 |
+
$("analytics-open").classList.toggle("hidden", !canAnalytics());
|
| 1497 |
$("show-private-row").classList.toggle("hidden", !can("manage"));
|
| 1498 |
$("show-private").checked = !!(state.me.prefs && state.me.prefs.show_private);
|
| 1499 |
await Promise.all([refreshChannels(), renderLibs]);
|
| 1500 |
if (location.hash === "#settings") return openSettings();
|
| 1501 |
if (location.hash === "#activity") return openActivity();
|
| 1502 |
+
if (location.hash === "#analytics" && canAnalytics()) return openAnalytics();
|
| 1503 |
if (/^#(msg|file)\//.test(location.hash)) { // a shared link: open a channel first, then the message or file
|
| 1504 |
const first = state.channels.find((c) => c.name === store.get("channel")) || state.channels.find((c) => c.kind !== "dm") || state.channels[0];
|
| 1505 |
const target = location.hash;
|
|
|
|
| 1644 |
}
|
| 1645 |
function showChat() {
|
| 1646 |
state.view = "chat";
|
| 1647 |
+
$("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.add("hidden"); leaveAnalytics(); $("chat-view").style.display = "contents";
|
| 1648 |
}
|
| 1649 |
async function openChannel(id) {
|
| 1650 |
const c = state.channels.find((x) => x.id === id);
|
|
|
|
| 2662 |
let u; try { u = new URL(href, location.href); } catch { return null; }
|
| 2663 |
if (u.origin !== location.origin || u.pathname !== location.pathname || !u.hash) return null;
|
| 2664 |
const h = decodeURIComponent(u.hash.slice(1));
|
| 2665 |
+
return /^(msg|file)\/[\w-]+$/.test(h) || /^(settings|activity|search|analytics)$/.test(h) || state.chanIndex.has(h.toLowerCase()) ? h : null;
|
| 2666 |
}
|
| 2667 |
async function routeHash(h) {
|
| 2668 |
h = decodeURIComponent((h || "").replace(/^#/, ""));
|
|
|
|
| 2675 |
if ((m = /^file\/([\w-]+)$/.exec(h))) { openViewer([m[1]], 0); return true; }
|
| 2676 |
if (h === "settings") { openSettings(); return true; }
|
| 2677 |
if (h === "activity") { openActivity(); return true; }
|
| 2678 |
+
if (h === "analytics") { openAnalytics(); return true; }
|
| 2679 |
if (h === "search") { openSearch(""); return true; }
|
| 2680 |
const c = state.chanIndex.get(h.toLowerCase()) || state.channels.find((x) => x.name === h);
|
| 2681 |
if (c) { closePanel(); openChannel(c.id); return true; }
|
|
|
|
| 3215 |
if (state.poll) state.poll.abort();
|
| 3216 |
state.current = null; closePanel(); renderChannels();
|
| 3217 |
history.replaceState(null, "", location.pathname + "#activity");
|
| 3218 |
+
$("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("search").classList.add("hidden"); leaveAnalytics(); $("activity").classList.remove("hidden");
|
| 3219 |
$("ch-title").innerHTML = "Activity"; $("ch-desc").textContent = "";
|
| 3220 |
$("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3221 |
// show the last list right away and swap in the fresh one when it arrives
|
|
|
|
| 3401 |
if (state.poll) state.poll.abort();
|
| 3402 |
state.current = null; closePanel(); renderChannels();
|
| 3403 |
history.replaceState(null, "", location.pathname + "#search");
|
| 3404 |
+
$("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); leaveAnalytics(); $("search").classList.remove("hidden");
|
| 3405 |
$("ch-title").innerHTML = "Search"; $("ch-desc").textContent = "";
|
| 3406 |
$("ch-meta").textContent = ""; $("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3407 |
const f = state.searchFilters = Object.assign({ channel: "", sender: "", when: "", has: "", by: "", sort: "newest" }, opts.keep ? state.searchFilters : {}, opts.filters || {});
|
|
|
|
| 3495 |
if (state.poll) state.poll.abort();
|
| 3496 |
state.current = null; renderChannels(); closePanel();
|
| 3497 |
history.replaceState(null, "", location.pathname + "#settings");
|
| 3498 |
+
$("chat-view").style.display = "none"; $("activity").classList.add("hidden"); $("search").classList.add("hidden"); leaveAnalytics(); $("settings").classList.remove("hidden");
|
| 3499 |
$("ch-title").innerHTML = "Settings"; $("ch-desc").textContent = ""; $("ch-meta").textContent = "";
|
| 3500 |
$("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3501 |
const tabs = [["invites", "Invites"]];
|
| 3502 |
if (can("manage")) tabs.push(["people", "People"]);
|
| 3503 |
tabs.push(["agents", can("manage") ? "Agents" : "My agents"], ["listening", "Who's listening"]);
|
| 3504 |
+
if (can("manage")) tabs.push(["suspended", "Suspended"], ["board", "Board"]);
|
| 3505 |
tab = tab || state.settingsTab || "invites";
|
| 3506 |
if (!tabs.some(([k]) => k === tab)) tab = "invites";
|
| 3507 |
state.settingsTab = tab;
|
| 3508 |
$("settings").innerHTML = `<div class="tabs">${tabs.map(([k, l]) => `<button data-tab="${k}" class="${k === tab ? "on" : ""}">${l}</button>`).join("")}</div><div class="sbody" id="sbody"><div class="empty">Loading…</div></div>`;
|
| 3509 |
$("settings").querySelectorAll("[data-tab]").forEach((b) => b.onclick = () => openSettings(b.dataset.tab));
|
| 3510 |
+
({ invites: settingsInvites, people: settingsPeople, agents: settingsAgents, listening: settingsListening, suspended: settingsSuspended, board: settingsBoard })[tab]()
|
| 3511 |
.catch((e) => { $("sbody").innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
| 3512 |
}
|
| 3513 |
// A2 look on trial: a per-browser switch, applied before first paint by the snippet in <head>
|
|
|
|
| 3809 |
hydrate($("sbody"));
|
| 3810 |
$("sbody").querySelectorAll("[data-lf]").forEach((b) => b.onclick = () => { state.listenFilter = b.dataset.lf; settingsListening(); });
|
| 3811 |
}
|
| 3812 |
+
// ------------------------------------------------------------------ analytics (its own page; admins and readers)
|
| 3813 |
+
// GET /v1/analytics?from=&to=&top_k= : message stats, response times, who talks to whom, files, listening, health.
|
| 3814 |
+
const DAY_S = 86400;
|
| 3815 |
+
const canAnalytics = () => !!(state.me && (state.me.role === "admin" || state.me.role === "reader"));
|
| 3816 |
+
function analyticsRange() {
|
| 3817 |
+
const r = state.anRange || { days: 7 };
|
| 3818 |
+
if (r.custom) return r;
|
| 3819 |
+
const to = (Math.floor(Date.now() / 1000 / DAY_S) + 1) * DAY_S; // the end of today (UTC), exclusive
|
| 3820 |
+
return { days: r.days, from: to - r.days * DAY_S, to };
|
| 3821 |
+
}
|
| 3822 |
+
function leaveAnalytics() { $("analytics").classList.add("hidden"); $("analytics-open").classList.remove("on"); }
|
| 3823 |
+
async function openAnalytics() {
|
| 3824 |
+
if (!canAnalytics()) return;
|
| 3825 |
+
state.view = "analytics";
|
| 3826 |
+
if (state.poll) state.poll.abort();
|
| 3827 |
+
state.current = null; closePanel(); renderChannels();
|
| 3828 |
+
history.replaceState(null, "", location.pathname + "#analytics");
|
| 3829 |
+
$("chat-view").style.display = "none"; $("settings").classList.add("hidden"); $("activity").classList.add("hidden"); $("search").classList.add("hidden");
|
| 3830 |
+
$("analytics").classList.remove("hidden"); $("analytics-open").classList.add("on");
|
| 3831 |
+
$("ch-title").innerHTML = "Analytics"; $("ch-desc").textContent = ""; $("ch-meta").textContent = "";
|
| 3832 |
+
$("members-open").classList.add("hidden"); $("ch-info").classList.add("hidden"); $("ch-doc").classList.add("hidden"); $("ch-join").classList.add("hidden"); closeInfo();
|
| 3833 |
+
$("analytics").innerHTML = `<div class="empty">Loading…</div>`;
|
| 3834 |
+
await renderAnalytics();
|
| 3835 |
+
}
|
| 3836 |
+
const fmtDur = (s) => s == null ? "—" : s < 1 ? "under 1 s" : s < 60 ? `${Math.round(s)} s` : s < 3600 ? `${(s / 60).toFixed(s < 600 ? 1 : 0)} min` : s < 86400 ? `${(s / 3600).toFixed(s < 36000 ? 1 : 0)} h` : `${(s / 86400).toFixed(1)} days`;
|
| 3837 |
+
const fmtBytes = (n) => n == null ? "—" : n < 1024 ** 3 ? fmtSize(n) : (n / 1024 ** 3).toFixed(1) + " GB";
|
| 3838 |
+
const anDate = (t) => new Date(t * 1000).toISOString().slice(0, 10);
|
| 3839 |
+
async function renderAnalytics() {
|
| 3840 |
+
const r = analyticsRange(), k = state.anTopK || 8, days = Math.round((r.to - r.from) / DAY_S);
|
| 3841 |
+
let a;
|
| 3842 |
+
try { a = await api("GET", `/analytics?from=${r.from}&to=${r.to}&top_k=${k}`); }
|
| 3843 |
+
catch (e) { if (state.view === "analytics") $("analytics").innerHTML = `<div class="empty">${esc(e.message)}</div>`; return; }
|
| 3844 |
+
if (state.view !== "analytics") return;
|
| 3845 |
+
const ms = a.message_stats || {}, rt = a.response_times || {}, fi = a.files || {}, li = a.listening || {}, t = a.totals || {};
|
| 3846 |
+
const filter = state.analyticsWho || "all";
|
| 3847 |
+
const tile = (label, value, sub) => `<div class="tile"><div class="label">${label}</div><div class="num">${value}</div><div class="small">${sub}</div></div>`;
|
| 3848 |
+
const posters = (a.posters || []).filter((x) => filter === "all" || (filter === "agents") === (x.identity_type !== "user"));
|
| 3849 |
+
const maxP = Math.max(1, ...(a.posters || []).map((x) => x.messages)), maxC = Math.max(1, ...(a.channels || []).map((c) => c.messages));
|
| 3850 |
+
const fmax = Math.max(1, ...(fi.by_principal || []).map((x) => x.bytes));
|
| 3851 |
+
const span = r.custom ? `${anDate(r.from)} → ${anDate(r.to - DAY_S)}` : `last ${days} days`;
|
| 3852 |
+
$("analytics").innerHTML = `<div class="sbody an-page">
|
| 3853 |
+
<div class="an-head"><div class="seg" role="group" aria-label="Time range">${[7, 30, 90].map((d) =>
|
| 3854 |
+
`<button data-days="${d}" class="${!r.custom && d === r.days ? "on" : ""}">${d} days</button>`).join("")}</div>
|
| 3855 |
+
<form class="an-custom${r.custom ? " on" : ""}" id="an-custom"><span class="label">Custom</span>
|
| 3856 |
+
<input type="date" id="an-from" value="${anDate(r.from)}" max="${anDate(Date.now() / 1000)}" aria-label="From">
|
| 3857 |
+
<span>→</span><input type="date" id="an-to" value="${anDate(r.to - DAY_S)}" max="${anDate(Date.now() / 1000)}" aria-label="To">
|
| 3858 |
+
<button class="btn ghost small">Apply</button></form></div>
|
| 3859 |
+
<p class="lead">Everything on the board in the chosen days (UTC), private channels and DMs included; DMs are only counted together.</p>
|
| 3860 |
<div class="tiles">
|
| 3861 |
+
${tile("Messages", fmtN(ms.messages ?? t.messages_window ?? 0), span)}
|
| 3862 |
+
${tile("Words", fmtN(ms.words ?? 0), days ? `about ${fmtN(Math.round((ms.words || 0) / days))} a day` : "")}
|
| 3863 |
+
${tile("Avg length", ms.average_length == null ? "—" : `${Math.round(ms.average_length)}`, "characters per message")}
|
| 3864 |
+
${tile("Median response", fmtDur(rt.median_seconds), rt.p90_seconds == null ? "no answered requests" : `90% within ${fmtDur(rt.p90_seconds)}`)}
|
| 3865 |
+
${tile("Files", fmtN(fi.count ?? 0), `${fmtBytes(fi.bytes || 0)} attached`)}
|
| 3866 |
+
${tile("Listening now", li.now ?? "—", `<a class="linkish" href="#settings" id="an-listen-link">who's listening</a>`)}
|
| 3867 |
+
</div>
|
| 3868 |
+
<div class="an-grid">
|
| 3869 |
+
<div class="card"><div class="chart-top"><h4>Messages per day</h4>
|
| 3870 |
+
<div class="legend"><span><i class="sw agents"></i>agents</span><span><i class="sw people"></i>people</span></div></div>
|
| 3871 |
+
<div id="an-chart" class="an-chart"></div></div>
|
| 3872 |
+
<div class="card"><div class="chart-top"><h4>Words per day</h4></div><div id="an-words" class="an-chart"></div>
|
| 3873 |
+
<div class="chart-top"><h4>Average length, characters</h4></div><div id="an-len" class="an-chart"></div></div>
|
| 3874 |
+
<div class="card"><div class="chart-top"><h4>Time to response</h4><span class="small">${fmtN(rt.requests || 0)} requests · ${fmtN(rt.unanswered || 0)} unanswered</span></div>
|
| 3875 |
+
<p class="small an-rule">${esc(rt.rule || "An agent is mentioned or DMed; its first later message in that thread or DM answers it.")}</p>
|
| 3876 |
+
<div id="an-hist" class="an-chart"></div>
|
| 3877 |
+
${(rt.agents || []).length ? `<table class="an-table"><thead><tr><th>Agent</th><th>Asked</th><th class="hide-sm">Unanswered</th><th>Median</th><th>90% within</th></tr></thead><tbody>
|
| 3878 |
+
${rt.agents.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span></span></td><td>${x.requests}</td><td class="hide-sm">${x.unanswered}</td><td>${fmtDur(x.median_seconds)}</td><td>${fmtDur(x.p90_seconds)}</td></tr>`).join("")}</tbody></table>` : ""}</div>
|
| 3879 |
+
<div class="card"><div class="chart-top"><h4>Who talks to whom</h4>
|
| 3880 |
+
<label class="small an-k">Top <input type="range" id="an-k" min="3" max="30" value="${k}"> <b id="an-k-n">${k}</b></label></div>
|
| 3881 |
+
<p class="small an-rule">People and agents; a ribbon is the messages between the two: DMs, replies and mentions, both directions.</p>
|
| 3882 |
+
<div id="an-chord" class="an-chart an-chord"></div>
|
| 3883 |
+
<div class="legend"><span><i class="sw agents"></i>agent</span><span><i class="sw people"></i>person</span><span><i class="sw other"></i>other</span></div></div>
|
| 3884 |
+
<div class="card"><div class="chart-top"><h4>Agents listening</h4><span class="small">${li.started_at ? `collected since ${esc(fmtDate(li.started_at))}` : "not collected yet"}</span></div>
|
| 3885 |
+
<div id="an-listen" class="an-chart"></div><p class="small">Gaps are times with no data, not zero.</p></div>
|
| 3886 |
+
<div class="card"><div class="chart-top"><h4>Files</h4><span class="small">${fmtN(fi.count || 0)} files · ${fmtBytes(fi.bytes || 0)}</span></div>
|
| 3887 |
+
${(fi.by_principal || []).length ? `<table class="an-table an-files"><thead><tr><th>Who</th><th>Files</th><th class="bar-col">Size</th></tr></thead><tbody>
|
| 3888 |
+
${fi.by_principal.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span></span></td><td>${x.count}</td>
|
| 3889 |
+
<td class="bar-col"><span class="hbar"><span style="width:${(100 * x.bytes / fmax).toFixed(1)}%"></span></span><b>${fmtBytes(x.bytes)}</b></td></tr>`).join("")}</tbody></table>`
|
| 3890 |
+
: `<div class="empty">No files in these days.</div>`}</div>
|
| 3891 |
</div>
|
| 3892 |
+
${a.health ? `<div class="card"><h4>Board health</h4><p class="small">Right now, whatever days are selected.</p><div class="an-health">${healthCells(a.health)}</div></div>` : ""}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3893 |
<div class="card"><h4>Channels</h4>
|
| 3894 |
+
<table class="an-table wide"><thead><tr><th>Channel</th><th class="bar-col">Messages</th><th>Posters</th><th class="hide-sm">All time</th><th class="hide-sm">Last message</th></tr></thead><tbody>
|
| 3895 |
+
${(a.channels || []).map((c) => `<tr class="${c.archived ? "muted" : ""}"><td>${c.id ? `<span class="chan-link" data-channel="${c.id}" role="link" tabindex="0">${c.kind === "private" ? LOCK : "#"}${esc(c.name)}</span>` : `<i>${esc(c.name)}</i>`}${c.archived ? ' <span class="label">archived</span>' : ""}</td>
|
| 3896 |
<td class="bar-col"><span class="hbar"><span style="width:${(100 * c.messages / maxC).toFixed(1)}%"></span></span><b>${c.messages}</b></td>
|
| 3897 |
<td>${c.posters}</td><td class="hide-sm">${c.total}</td><td class="small hide-sm">${c.last_at ? esc(ago(c.last_at)) : "never"}</td></tr>`).join("")}</tbody></table></div>
|
| 3898 |
+
<div class="card"><div class="chart-top"><h4>Who posts</h4><div class="seg small-seg" role="group" aria-label="Show">${[["all", "Everyone"], ["agents", "Agents"], ["people", "People"]].map(([key, l]) =>
|
| 3899 |
+
`<button data-who="${key}" class="${key === filter ? "on" : ""}">${l}</button>`).join("")}</div></div>
|
| 3900 |
${posters.length ? `<table class="an-table wide"><thead><tr><th>Name</th><th class="hide-sm">Owner</th><th class="bar-col">Messages</th><th class="hide-sm">Daily</th><th class="hide-sm">Channels</th><th>Last post</th></tr></thead><tbody>
|
| 3901 |
${posters.map((x) => `<tr><td><span class="who" data-profile="${x.id}">${avatar(x, "xs", false)}<span>${esc(nameOf(x))}</span><span class="label">${esc(kindLabel(x))}</span></span></td>
|
| 3902 |
<td class="small hide-sm">${x.owner ? esc(nameOf(x.owner)) : ""}</td>
|
| 3903 |
<td class="bar-col"><span class="hbar"><span style="width:${(100 * x.messages / maxP).toFixed(1)}%"></span></span><b>${x.messages}</b></td>
|
| 3904 |
<td class="hide-sm">${spark(x.daily, a.daily)}</td><td class="hide-sm">${x.channels}</td><td class="small">${esc(ago(x.last_at))}</td></tr>`).join("")}</tbody></table>`
|
| 3905 |
+
: `<div class="empty">Nobody in this group posted in these days.</div>`}</div>
|
| 3906 |
+
</div>`;
|
| 3907 |
+
hydrate($("analytics"));
|
| 3908 |
+
const daily = ms.daily && ms.daily.length ? ms.daily : (a.daily || []);
|
| 3909 |
+
drawDaily($("an-chart"), daily);
|
| 3910 |
+
drawSeries($("an-words"), daily.map((d) => ({ t: d.day, v: d.words ?? null })), { label: "words", fmt: fmtN });
|
| 3911 |
+
drawSeries($("an-len"), daily.map((d) => ({ t: d.day, v: d.average_length ?? null })), { label: "characters", fmt: (v) => String(Math.round(v)) });
|
| 3912 |
+
drawHist($("an-hist"), rt);
|
| 3913 |
+
drawChord($("an-chord"), a.interactions);
|
| 3914 |
+
drawSeries($("an-listen"), ((li.series) || []).map((p) => ({ t: p.at, v: p.count, note: p.samples != null && p.expected_samples ? `${p.samples} of ${p.expected_samples} samples` : "" })),
|
| 3915 |
+
{ label: "agents", fmt: (v) => (Math.round(v * 10) / 10).toString(), time: (li.bucket_seconds || DAY_S) < DAY_S });
|
| 3916 |
+
const root = $("analytics");
|
| 3917 |
+
root.querySelectorAll("[data-days]").forEach((b) => b.onclick = () => { state.anRange = { days: Number(b.dataset.days) }; renderAnalytics(); });
|
| 3918 |
+
root.querySelectorAll("[data-who]").forEach((b) => b.onclick = () => { state.analyticsWho = b.dataset.who; renderAnalytics(); });
|
| 3919 |
+
$("an-custom").onsubmit = (e) => {
|
| 3920 |
+
e.preventDefault();
|
| 3921 |
+
const f = Date.parse($("an-from").value + "T00:00:00Z") / 1000, to = Date.parse($("an-to").value + "T00:00:00Z") / 1000 + DAY_S;
|
| 3922 |
+
if (!(f < to)) return;
|
| 3923 |
+
state.anRange = { custom: true, from: f, to, days: Math.round((to - f) / DAY_S) }; renderAnalytics();
|
| 3924 |
+
};
|
| 3925 |
+
$("an-listen-link").onclick = (e) => { e.preventDefault(); openSettings("listening"); };
|
| 3926 |
+
// the slider refetches only the chord, and waits until dragging pauses
|
| 3927 |
+
let timer = 0;
|
| 3928 |
+
$("an-k").oninput = (e) => {
|
| 3929 |
+
$("an-k-n").textContent = e.target.value; clearTimeout(timer);
|
| 3930 |
+
timer = setTimeout(async () => {
|
| 3931 |
+
state.anTopK = Number(e.target.value);
|
| 3932 |
+
try { const b = await api("GET", `/analytics?from=${r.from}&to=${r.to}&top_k=${state.anTopK}`); if (state.view === "analytics" && $("an-chord")) drawChord($("an-chord"), b.interactions); } catch {}
|
| 3933 |
+
}, 300);
|
| 3934 |
+
};
|
| 3935 |
+
}
|
| 3936 |
+
// health: whatever the server reports, formatted by the unit in its name
|
| 3937 |
+
const HEALTH_KEYS = ["db_bytes", "wal_bytes", "disk_free_bytes", "free_disk_bytes", "uptime_seconds", "open_polls", "pending_writes", "write_queue", "queued_reads", "errors_5xx", "replication"];
|
| 3938 |
+
function healthCells(hl) {
|
| 3939 |
+
hl = Object.fromEntries(HEALTH_KEYS.filter((k) => k in hl).map((k) => [k, hl[k]]));
|
| 3940 |
+
const label = (k) => k.replace(/_(bytes|seconds|at)$/, "").replace(/_/g, " ").replace(/\bwal\b/, "WAL").replace(/\bdb\b/, "database").replace(/^./, (c) => c.toUpperCase());
|
| 3941 |
+
const val = (k, v) => v == null ? "—" : typeof v === "boolean" ? (v ? "yes" : "no")
|
| 3942 |
+
: /bytes$/.test(k) ? fmtBytes(v) : /_at$/.test(k) ? ago(v) : /seconds$/.test(k) ? fmtDur(v) : typeof v === "number" ? fmtN(v) : String(v);
|
| 3943 |
+
const cells = [];
|
| 3944 |
+
for (const [k, v] of Object.entries(hl)) {
|
| 3945 |
+
if (v && typeof v === "object") for (const [k2, v2] of Object.entries(v)) { if (typeof v2 !== "object") cells.push([`${k}_${k2}`, val(k2, v2)]); }
|
| 3946 |
+
else cells.push([k, val(k, v)]);
|
| 3947 |
+
}
|
| 3948 |
+
return cells.map(([k, v]) => `<div><b>${esc(v)}</b><span>${esc(label(k))}</span></div>`).join("");
|
| 3949 |
+
}
|
| 3950 |
+
// a hover box beside the mark, kept inside the chart
|
| 3951 |
+
function anTip(host, mark, html) {
|
| 3952 |
+
let tip = host.querySelector(".an-tip"); if (!tip) { tip = document.createElement("div"); tip.className = "an-tip"; host.append(tip); }
|
| 3953 |
+
tip.innerHTML = html; tip.classList.remove("hidden");
|
| 3954 |
+
const box = host.getBoundingClientRect(), rb = mark.getBoundingClientRect();
|
| 3955 |
+
const right = rb.right - box.left + 6, left = rb.left - box.left - tip.offsetWidth - 6;
|
| 3956 |
+
tip.style.left = (right + tip.offsetWidth <= box.width ? right : Math.max(0, left)) + "px";
|
| 3957 |
+
tip.style.top = Math.max(0, Math.min(rb.top - box.top, box.height - tip.offsetHeight)) + "px";
|
| 3958 |
+
}
|
| 3959 |
+
const niceTop = (max) => {
|
| 3960 |
+
max = Math.max(1, max);
|
| 3961 |
+
const p = Math.pow(10, Math.floor(Math.log10(max / 4))), step = Math.max(1, p * ([1, 2, 5, 10].find((m) => p * m >= max / 4) || 10));
|
| 3962 |
+
return [Math.ceil(max / step) * step, step];
|
| 3963 |
+
};
|
| 3964 |
+
// one line over time; null values break the line (no data, not zero)
|
| 3965 |
+
function drawSeries(host, pts, { label, fmt, time } = {}) {
|
| 3966 |
+
if (!pts.length || pts.every((p) => p.v == null)) { host.innerHTML = `<div class="empty small">No data in these days.</div>`; return; }
|
| 3967 |
+
const W = Math.max(280, host.clientWidth || 500), H = 120, L = 40, R = 10, T = 10, B = 22;
|
| 3968 |
+
const [top, step] = niceTop(Math.max(...pts.map((p) => p.v || 0)));
|
| 3969 |
+
const x = (i) => L + (pts.length === 1 ? (W - L - R) / 2 : i * (W - L - R) / (pts.length - 1)), y = (v) => T + (H - T - B) * (1 - v / top);
|
| 3970 |
+
let d = "", pen = false;
|
| 3971 |
+
pts.forEach((p, i) => { if (p.v == null) { pen = false; return; } d += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(p.v).toFixed(1)}`; pen = true; });
|
| 3972 |
+
const every = Math.ceil(pts.length / Math.max(2, Math.floor((W - L - R) / 70)));
|
| 3973 |
+
let svg = `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img" aria-label="${esc(label || "")} over time">`;
|
| 3974 |
+
for (let i = 0; i * step <= top; i++) { const v = i * step; svg += `<line class="grid" x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}"/><text class="tick" x="${L - 6}" y="${y(v) + 3.5}" text-anchor="end">${fmtN(v)}</text>`; }
|
| 3975 |
+
if (time) {
|
| 3976 |
+
const starts = pts.map((p, i) => [p, i]).filter(([p], i) => i === 0 || Math.floor(p.t / DAY_S) !== Math.floor(pts[i - 1].t / DAY_S)), gap = Math.ceil(starts.length / Math.max(2, Math.floor((W - L - R) / 70)));
|
| 3977 |
+
starts.forEach(([p, i], j) => { if (j % gap === 0) svg += `<text class="tick" x="${x(i)}" y="${H - 6}" text-anchor="middle">${esc(dayLabel(p.t))}</text>`; });
|
| 3978 |
+
} else pts.forEach((p, i) => { if (i % every === 0 || i === pts.length - 1) svg += `<text class="tick" x="${x(i)}" y="${H - 6}" text-anchor="middle">${esc(dayLabel(p.t))}</text>`; });
|
| 3979 |
+
svg += `<path class="line" d="${d}"/>`;
|
| 3980 |
+
const last = [...pts].reverse().find((p) => p.v != null), li = pts.lastIndexOf(last);
|
| 3981 |
+
svg += `<circle class="dotm" cx="${x(li)}" cy="${y(last.v)}" r="3.5"/>`;
|
| 3982 |
+
const slot = (W - L - R) / Math.max(1, pts.length - 1);
|
| 3983 |
+
pts.forEach((p, i) => { svg += `<rect class="hit" data-i="${i}" x="${x(i) - slot / 2}" y="${T}" width="${slot}" height="${H - T - B}"/>`; });
|
| 3984 |
+
host.innerHTML = svg + `</svg><div class="an-tip hidden"></div>`;
|
| 3985 |
+
host.querySelectorAll(".hit").forEach((r) => r.addEventListener("mouseenter", () => {
|
| 3986 |
+
const p = pts[Number(r.dataset.i)];
|
| 3987 |
+
host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); r.classList.add("on");
|
| 3988 |
+
anTip(host, r, `<b>${esc(time ? new Date(p.t * 1000).toLocaleString() : dayLabel(p.t, true))}</b><span>${p.v == null ? "no data" : `${esc(fmt ? fmt(p.v) : String(p.v))} ${esc(label || "")}`}</span>${p.note ? `<span>${esc(p.note)}</span>` : ""}`);
|
| 3989 |
+
}));
|
| 3990 |
+
host.querySelector("svg").addEventListener("mouseleave", () => { host.querySelector(".an-tip").classList.add("hidden"); host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); });
|
| 3991 |
+
}
|
| 3992 |
+
// response-time histogram with median and 90% markers
|
| 3993 |
+
function drawHist(host, rt) {
|
| 3994 |
+
const rows = (rt && rt.histogram) || [];
|
| 3995 |
+
if (!rows.length || !rows.some((b) => b.count)) { host.innerHTML = `<div class="empty small">No requests to agents in these days.</div>`; return; }
|
| 3996 |
+
const W = Math.max(280, host.clientWidth || 500), H = 170, L = 40, R = 8, T = 18, B = 26;
|
| 3997 |
+
const [top, step] = niceTop(Math.max(...rows.map((b) => b.count)));
|
| 3998 |
+
const slot = (W - L - R) / rows.length, bw = Math.max(4, slot - 4), y = (v) => T + (H - T - B) * (1 - v / top);
|
| 3999 |
+
const unit = (s) => s < 60 ? "s" : s < 3600 ? "m" : "h", num = (s) => s < 60 ? s : s < 3600 ? s / 60 : s / 3600;
|
| 4000 |
+
const short = (s) => `${num(s)}${unit(s)}`;
|
| 4001 |
+
// compact axis labels ("2–5m"); the hover says it in full
|
| 4002 |
+
const name = (b, i) => i === 0 ? `<${short(b.to_seconds)}` : b.to_seconds == null ? `>${short(b.from_seconds)}`
|
| 4003 |
+
: unit(b.from_seconds) === unit(b.to_seconds) ? `${num(b.from_seconds)}–${short(b.to_seconds)}` : `${short(b.from_seconds)}–${short(b.to_seconds)}`;
|
| 4004 |
+
const full = (b, i) => i === 0 ? `under ${fmtDur(b.to_seconds)}` : b.to_seconds == null ? `over ${fmtDur(b.from_seconds)}` : `${fmtDur(b.from_seconds)} to ${fmtDur(b.to_seconds)}`;
|
| 4005 |
+
let svg = `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img" aria-label="Time to response, distribution">`;
|
| 4006 |
+
for (let i = 0; i * step <= top; i++) { const v = i * step; svg += `<line class="grid" x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}"/><text class="tick" x="${L - 6}" y="${y(v) + 3.5}" text-anchor="end">${fmtN(v)}</text>`; }
|
| 4007 |
+
rows.forEach((b, i) => {
|
| 4008 |
+
const x = L + i * slot + (slot - bw) / 2;
|
| 4009 |
+
if (b.count) svg += `<path class="bar agents" d="${barPath(x, y(b.count), bw, y(0) - y(b.count), true)}"/>`;
|
| 4010 |
+
svg += `<text class="tick" x="${x + bw / 2}" y="${H - 8}" text-anchor="middle">${esc(name(b, i))}</text>`;
|
| 4011 |
+
svg += `<rect class="hit" data-i="${i}" x="${L + i * slot}" y="${T}" width="${slot}" height="${H - T - B}"/>`;
|
| 4012 |
+
});
|
| 4013 |
+
// markers sit inside their bucket, placed linearly between its edges
|
| 4014 |
+
const at = (s) => { const i = rows.findIndex((b) => s >= b.from_seconds && (b.to_seconds == null || s < b.to_seconds)); if (i < 0) return null;
|
| 4015 |
+
const b = rows[i], f = b.to_seconds == null ? 0.5 : (s - b.from_seconds) / (b.to_seconds - b.from_seconds); return L + (i + f) * slot; };
|
| 4016 |
+
const m1 = rt.median_seconds == null ? null : at(rt.median_seconds), m2 = rt.p90_seconds == null ? null : at(rt.p90_seconds);
|
| 4017 |
+
if (m1 != null) svg += `<line class="mark" x1="${m1}" x2="${m1}" y1="${T - 6}" y2="${y(0)}"/>`;
|
| 4018 |
+
if (m2 != null && (m1 == null || Math.abs(m2 - m1) > 1)) svg += `<line class="mark" x1="${m2}" x2="${m2}" y1="${T - 6}" y2="${y(0)}"/>`;
|
| 4019 |
+
if (m1 != null && m2 != null && m2 - m1 < 110) svg += `<text class="tick strong" x="${m1 + 4}" y="${T - 2}">median ${esc(fmtDur(rt.median_seconds))} · 90% ${esc(fmtDur(rt.p90_seconds))}</text>`;
|
| 4020 |
+
else {
|
| 4021 |
+
if (m1 != null) svg += `<text class="tick strong" x="${m1 + 4}" y="${T - 2}">median ${esc(fmtDur(rt.median_seconds))}</text>`;
|
| 4022 |
+
if (m2 != null) svg += `<text class="tick strong" x="${m2 + 4}" y="${T - 2}">90% ${esc(fmtDur(rt.p90_seconds))}</text>`;
|
| 4023 |
+
}
|
| 4024 |
+
host.innerHTML = svg + `<line class="axis" x1="${L}" x2="${W - R}" y1="${y(0)}" y2="${y(0)}"/></svg><div class="an-tip hidden"></div>`;
|
| 4025 |
+
host.querySelectorAll(".hit").forEach((r) => r.addEventListener("mouseenter", () => {
|
| 4026 |
+
const i = Number(r.dataset.i), b = rows[i];
|
| 4027 |
+
host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); r.classList.add("on");
|
| 4028 |
+
anTip(host, r, `<b>${esc(full(b, i))}</b><span>${b.count} answered</span>`);
|
| 4029 |
+
}));
|
| 4030 |
+
host.querySelector("svg").addEventListener("mouseleave", () => { host.querySelector(".an-tip").classList.add("hidden"); host.querySelectorAll(".hit.on").forEach((h) => h.classList.remove("on")); });
|
| 4031 |
+
}
|
| 4032 |
+
// who talks to whom: a chord diagram drawn by hand (arcs per person or agent, ribbons per pair)
|
| 4033 |
+
function drawChord(host, inter) {
|
| 4034 |
+
const nodes = (inter && inter.nodes) || [], links = ((inter && inter.links) || []).filter((l) => l.source !== l.target && l.total > 0);
|
| 4035 |
+
if (!links.length) { host.innerHTML = `<div class="empty small">No messages between people and agents in these days.</div>`; return; }
|
| 4036 |
+
const W = Math.max(300, Math.min(host.clientWidth || 480, 560)), H = W, R = W / 2 - 92, r0 = R - 12, PAD = 0.03;
|
| 4037 |
+
const byId = new Map(nodes.map((n) => [String(n.id), n]));
|
| 4038 |
+
const vol = new Map(nodes.map((n) => [String(n.id), 0]));
|
| 4039 |
+
links.forEach((l) => { vol.set(String(l.source), (vol.get(String(l.source)) || 0) + l.total); vol.set(String(l.target), (vol.get(String(l.target)) || 0) + l.total); });
|
| 4040 |
+
// people first, then agents, then other; each group by volume
|
| 4041 |
+
const kind = (n) => n.id === "other" ? 2 : n.identity_type === "user" ? 0 : 1;
|
| 4042 |
+
const order = nodes.filter((n) => vol.get(String(n.id))).sort((p, q) => kind(p) - kind(q) || vol.get(String(q.id)) - vol.get(String(p.id)));
|
| 4043 |
+
const total = order.reduce((s, n) => s + vol.get(String(n.id)), 0), k = (2 * Math.PI - PAD * order.length) / total;
|
| 4044 |
+
const arcs = new Map(); let a0 = 0;
|
| 4045 |
+
order.forEach((n) => { const v = vol.get(String(n.id)); arcs.set(String(n.id), { n, a0, a1: a0 + v * k, cur: a0 }); a0 += v * k + PAD; });
|
| 4046 |
+
const P = (a, r) => [(r * Math.sin(a)).toFixed(2), (-r * Math.cos(a)).toFixed(2)];
|
| 4047 |
+
const arcPath = (s, e, ri, ro) => `M${P(s, ro)}A${ro},${ro} 0 ${e - s > Math.PI ? 1 : 0} 1 ${P(e, ro)}L${P(e, ri)}A${ri},${ri} 0 ${e - s > Math.PI ? 1 : 0} 0 ${P(s, ri)}Z`;
|
| 4048 |
+
// each arc is cut into one slice per link, biggest first
|
| 4049 |
+
const slices = links.slice().sort((p, q) => q.total - p.total).map((l) => {
|
| 4050 |
+
const s = arcs.get(String(l.source)), t = arcs.get(String(l.target)); if (!s || !t) return null;
|
| 4051 |
+
const ss = [s.cur, s.cur += l.total * k], ts = [t.cur, t.cur += l.total * k]; return { l, ss, ts };
|
| 4052 |
+
}).filter(Boolean);
|
| 4053 |
+
const big = (p) => p[1] - p[0] > Math.PI ? 1 : 0;
|
| 4054 |
+
const ribbon = ({ ss, ts }) => `M${P(ss[0], r0)}A${r0},${r0} 0 ${big(ss)} 1 ${P(ss[1], r0)}Q0,0 ${P(ts[0], r0)}A${r0},${r0} 0 ${big(ts)} 1 ${P(ts[1], r0)}Q0,0 ${P(ss[0], r0)}Z`;
|
| 4055 |
+
const cls = (n) => n.id === "other" ? "other" : n.identity_type === "user" ? "people" : "agents";
|
| 4056 |
+
const nm = (n) => n.id === "other" ? `other (${n.members})` : nameOf(n);
|
| 4057 |
+
let svg = `<svg viewBox="${-W / 2} ${-H / 2} ${W} ${H}" width="100%" height="${H}" role="img" aria-label="Who talks to whom">`;
|
| 4058 |
+
svg += slices.map((s, i) => `<path class="ribbon" data-i="${i}" data-a="${s.l.source}" data-b="${s.l.target}" d="${ribbon(s)}"/>`).join("");
|
| 4059 |
+
for (const { n, a0: s, a1: e } of arcs.values()) {
|
| 4060 |
+
const mid = (s + e) / 2, deg = mid * 180 / Math.PI - 90, flip = mid > Math.PI;
|
| 4061 |
+
svg += `<path class="arc ${cls(n)}" data-n="${n.id}" d="${arcPath(s, e, r0, R)}"/>`;
|
| 4062 |
+
svg += `<g transform="rotate(${deg.toFixed(1)}) translate(${R + 8},0)${flip ? " rotate(180)" : ""}"><text class="node" text-anchor="${flip ? "end" : "start"}" dy="-0.1em">${esc(nm(n))}</text>` +
|
| 4063 |
+
`<text class="tick" text-anchor="${flip ? "end" : "start"}" dy="1.1em">${fmtN(vol.get(String(n.id)))}</text></g>`;
|
| 4064 |
+
}
|
| 4065 |
+
host.innerHTML = svg + `</svg><div class="an-tip hidden"></div>`;
|
| 4066 |
+
const svgEl = host.querySelector("svg");
|
| 4067 |
+
const focus = (fn) => host.querySelectorAll(".ribbon").forEach((p) => p.classList.toggle("dim", !fn(p)));
|
| 4068 |
+
host.querySelectorAll(".ribbon").forEach((p) => p.addEventListener("mouseenter", () => {
|
| 4069 |
+
const l = slices[Number(p.dataset.i)].l, a = byId.get(String(l.source)), b = byId.get(String(l.target));
|
| 4070 |
+
focus((q) => q === p);
|
| 4071 |
+
anTip(host, p, `<b>${esc(nm(a))} ↔ ${esc(nm(b))}</b><span>${l.total} messages</span><span>${l.dms} DMs · ${l.replies} replies · ${l.mentions} mentions</span>`);
|
| 4072 |
+
}));
|
| 4073 |
+
host.querySelectorAll(".arc").forEach((p) => p.addEventListener("mouseenter", () => {
|
| 4074 |
+
const id = p.dataset.n, n = byId.get(id);
|
| 4075 |
+
focus((q) => q.dataset.a === id || q.dataset.b === id);
|
| 4076 |
+
const inner = n.id === "other" && inter.other_internal && inter.other_internal.total ? `<span>${inter.other_internal.total} among themselves</span>` : "";
|
| 4077 |
+
anTip(host, p, `<b>${esc(nm(n))}</b><span>${fmtN(vol.get(id))} messages with ${n.identity_type === "user" ? "agents" : "people"}</span>${inner}`);
|
| 4078 |
+
}));
|
| 4079 |
+
svgEl.addEventListener("mouseleave", () => { focus(() => true); host.querySelector(".an-tip").classList.add("hidden"); });
|
| 4080 |
}
|
| 4081 |
function spark(values, daily) {
|
| 4082 |
const w = 84, h = 18, max = Math.max(1, ...values), bw = w / values.length;
|
|
|
|
| 4126 |
const r = roundTop ? Math.min(4, w / 2, h) : 0; // rounded only on the data end
|
| 4127 |
return `M${x},${y0 + h}V${y0 + r}${r ? `Q${x},${y0} ${x + r},${y0}` : ""}H${x + w - r}${r ? `Q${x + w},${y0} ${x + w},${y0 + r}` : ""}V${y0 + h}Z`;
|
| 4128 |
}
|
| 4129 |
+
let anResize = 0;
|
| 4130 |
+
window.addEventListener("resize", () => { clearTimeout(anResize); anResize = setTimeout(() => { if (state.view === "analytics") renderAnalytics(); }, 250); });
|
| 4131 |
|
| 4132 |
// ------------------------------------------------------------------ misc
|
| 4133 |
$("me-who").onclick = () => state.me && openProfile(state.me.id);
|
| 4134 |
+
$("analytics-open").onclick = () => openAnalytics();
|
| 4135 |
$("menu").onclick = () => $("app").classList.toggle("nav");
|
| 4136 |
$("app").addEventListener("click", (e) => {
|
| 4137 |
if (!$("app").classList.contains("nav") || e.target.closest("aside, #menu")) return;
|
|
|
|
| 4194 |
window.addEventListener("hashchange", () => {
|
| 4195 |
if (location.hash === "#settings") { if (state.view !== "settings") openSettings(); return; }
|
| 4196 |
if (location.hash === "#activity") { if (state.view !== "activity") openActivity(); return; }
|
| 4197 |
+
if (location.hash === "#analytics") { if (state.view !== "analytics") openAnalytics(); return; }
|
| 4198 |
const c = state.channels.find((x) => x.name === decodeURIComponent(location.hash.slice(1)));
|
| 4199 |
if (c && (!state.current || c.id !== state.current.id)) openChannel(c.id);
|
| 4200 |
});
|
src/msgboard/db.py
CHANGED
|
@@ -445,10 +445,23 @@ CREATE TABLE principal_presence (
|
|
| 445 |
);
|
| 446 |
"""
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
# Index i holds the SQL that moves the schema from version i to i + 1.
|
| 449 |
MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
|
| 450 |
SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
|
| 451 |
-
SCHEMA_V23, SCHEMA_V24]
|
| 452 |
SCHEMA_VERSION = len(MIGRATIONS)
|
| 453 |
|
| 454 |
|
|
|
|
| 445 |
);
|
| 446 |
"""
|
| 447 |
|
| 448 |
+
# v25: sampled listening counts and bounded analytics lookups.
|
| 449 |
+
SCHEMA_V25 = """
|
| 450 |
+
CREATE TABLE presence_history (
|
| 451 |
+
minute INTEGER PRIMARY KEY,
|
| 452 |
+
sampled_at REAL NOT NULL,
|
| 453 |
+
agents INTEGER NOT NULL CHECK (agents >= 0)
|
| 454 |
+
);
|
| 455 |
+
CREATE INDEX messages_created_at ON messages(created_at);
|
| 456 |
+
CREATE INDEX messages_response_thread ON messages(sender_id, thread_id, created_at, id);
|
| 457 |
+
CREATE INDEX messages_response_dm ON messages(channel_id, sender_id, created_at, id);
|
| 458 |
+
CREATE INDEX attachments_published_at ON attachments(published, created_at);
|
| 459 |
+
"""
|
| 460 |
+
|
| 461 |
# Index i holds the SQL that moves the schema from version i to i + 1.
|
| 462 |
MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
|
| 463 |
SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
|
| 464 |
+
SCHEMA_V23, SCHEMA_V24, SCHEMA_V25]
|
| 465 |
SCHEMA_VERSION = len(MIGRATIONS)
|
| 466 |
|
| 467 |
|
src/msgboard/presence.py
CHANGED
|
@@ -20,6 +20,8 @@ class Presence:
|
|
| 20 |
self._version = 0
|
| 21 |
self._poll_sequence = 0
|
| 22 |
self._latest_poll: dict[int, int] = {}
|
|
|
|
|
|
|
| 23 |
self._lock = threading.Lock()
|
| 24 |
|
| 25 |
def _put(self, pid: int, record: dict) -> None:
|
|
@@ -95,3 +97,28 @@ class Presence:
|
|
| 95 |
for pid, version in versions.items():
|
| 96 |
if self._dirty.get(pid) == version:
|
| 97 |
del self._dirty[pid]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
self._version = 0
|
| 21 |
self._poll_sequence = 0
|
| 22 |
self._latest_poll: dict[int, int] = {}
|
| 23 |
+
self._history: dict[int, tuple[float, int]] = {}
|
| 24 |
+
self._last_sample_minute: int | None = None
|
| 25 |
self._lock = threading.Lock()
|
| 26 |
|
| 27 |
def _put(self, pid: int, record: dict) -> None:
|
|
|
|
| 97 |
for pid, version in versions.items():
|
| 98 |
if self._dirty.get(pid) == version:
|
| 99 |
del self._dirty[pid]
|
| 100 |
+
|
| 101 |
+
def listening_now(self, now: float) -> int:
|
| 102 |
+
with self._lock:
|
| 103 |
+
return self._listening_now(now)
|
| 104 |
+
|
| 105 |
+
def _listening_now(self, now: float) -> int:
|
| 106 |
+
return sum(record["last_poll_at"] is not None and 0 <= now - record["last_poll_at"] <= ACTIVE_SECONDS
|
| 107 |
+
for record in self._records.values())
|
| 108 |
+
|
| 109 |
+
def sample(self, now: float) -> None:
|
| 110 |
+
minute = int(now // 60) * 60
|
| 111 |
+
with self._lock:
|
| 112 |
+
if self._last_sample_minute is None or minute > self._last_sample_minute:
|
| 113 |
+
self._last_sample_minute = minute
|
| 114 |
+
self._history[minute] = (now, self._listening_now(now))
|
| 115 |
+
|
| 116 |
+
def pending_history(self) -> dict[int, tuple[float, int]]:
|
| 117 |
+
with self._lock:
|
| 118 |
+
return dict(self._history)
|
| 119 |
+
|
| 120 |
+
def history_saved(self, history: dict[int, tuple[float, int]]) -> None:
|
| 121 |
+
with self._lock:
|
| 122 |
+
for minute, sample in history.items():
|
| 123 |
+
if self._history.get(minute) == sample:
|
| 124 |
+
del self._history[minute]
|
src/msgboard/server.py
CHANGED
|
@@ -262,15 +262,18 @@ class Board:
|
|
| 262 |
|
| 263 |
async def flush_presence(self) -> None:
|
| 264 |
versions, records = self.presence.pending()
|
| 265 |
-
|
| 266 |
-
|
|
|
|
| 267 |
self.presence.saved(versions)
|
|
|
|
| 268 |
|
| 269 |
async def presence_loop(self) -> None:
|
| 270 |
while True:
|
| 271 |
await asyncio.sleep(PRESENCE_FLUSH_SECONDS)
|
| 272 |
if self.shutting_down:
|
| 273 |
return
|
|
|
|
| 274 |
try:
|
| 275 |
await self.flush_presence()
|
| 276 |
except Exception:
|
|
@@ -286,11 +289,13 @@ class Board:
|
|
| 286 |
# writes, before the replicator takes its final snapshot. This also works
|
| 287 |
# when begin_shutdown has closed the normal HTTP writer entry point.
|
| 288 |
versions, records = self.presence.pending()
|
| 289 |
-
|
|
|
|
| 290 |
try:
|
| 291 |
-
self.db._writer.submit(self.db._run_write, store.save_presence, (records,)).result()
|
| 292 |
self.db.generation += 1
|
| 293 |
self.presence.saved(versions)
|
|
|
|
| 294 |
except Exception:
|
| 295 |
# Ephemeral presence must never prevent the final snapshot of
|
| 296 |
# acknowledged messages or leave the database/lease open.
|
|
@@ -333,6 +338,7 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
|
|
| 333 |
async def lifespan(app):
|
| 334 |
board.notifier.loop = asyncio.get_running_loop()
|
| 335 |
board.commit_lock = asyncio.Lock()
|
|
|
|
| 336 |
task = asyncio.create_task(board.presence_loop())
|
| 337 |
try:
|
| 338 |
yield
|
|
@@ -391,6 +397,9 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
|
|
| 391 |
p = await authn(request)
|
| 392 |
if not p.is_admin:
|
| 393 |
raise forbidden("admin only")
|
|
|
|
|
|
|
|
|
|
| 394 |
db_path = board.data_dir / DB_NAME
|
| 395 |
wal = Path(str(db_path) + "-wal")
|
| 396 |
return {
|
|
@@ -686,9 +695,16 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
|
|
| 686 |
now=time.time()))
|
| 687 |
|
| 688 |
@app.get("/v1/analytics")
|
| 689 |
-
async def get_analytics(request: Request, days: int = Query(
|
|
|
|
|
|
|
|
|
|
| 690 |
p = await authn(request)
|
| 691 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
|
| 693 |
def _origin(request: Request) -> str:
|
| 694 |
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
|
|
| 262 |
|
| 263 |
async def flush_presence(self) -> None:
|
| 264 |
versions, records = self.presence.pending()
|
| 265 |
+
history = self.presence.pending_history()
|
| 266 |
+
if records or history:
|
| 267 |
+
await self.db.write(store.save_presence, records, history)
|
| 268 |
self.presence.saved(versions)
|
| 269 |
+
self.presence.history_saved(history)
|
| 270 |
|
| 271 |
async def presence_loop(self) -> None:
|
| 272 |
while True:
|
| 273 |
await asyncio.sleep(PRESENCE_FLUSH_SECONDS)
|
| 274 |
if self.shutting_down:
|
| 275 |
return
|
| 276 |
+
self.presence.sample(time.time())
|
| 277 |
try:
|
| 278 |
await self.flush_presence()
|
| 279 |
except Exception:
|
|
|
|
| 289 |
# writes, before the replicator takes its final snapshot. This also works
|
| 290 |
# when begin_shutdown has closed the normal HTTP writer entry point.
|
| 291 |
versions, records = self.presence.pending()
|
| 292 |
+
history = self.presence.pending_history()
|
| 293 |
+
if records or history:
|
| 294 |
try:
|
| 295 |
+
self.db._writer.submit(self.db._run_write, store.save_presence, (records, history)).result()
|
| 296 |
self.db.generation += 1
|
| 297 |
self.presence.saved(versions)
|
| 298 |
+
self.presence.history_saved(history)
|
| 299 |
except Exception:
|
| 300 |
# Ephemeral presence must never prevent the final snapshot of
|
| 301 |
# acknowledged messages or leave the database/lease open.
|
|
|
|
| 338 |
async def lifespan(app):
|
| 339 |
board.notifier.loop = asyncio.get_running_loop()
|
| 340 |
board.commit_lock = asyncio.Lock()
|
| 341 |
+
board.presence.sample(time.time())
|
| 342 |
task = asyncio.create_task(board.presence_loop())
|
| 343 |
try:
|
| 344 |
yield
|
|
|
|
| 397 |
p = await authn(request)
|
| 398 |
if not p.is_admin:
|
| 399 |
raise forbidden("admin only")
|
| 400 |
+
return health_summary()
|
| 401 |
+
|
| 402 |
+
def health_summary():
|
| 403 |
db_path = board.data_dir / DB_NAME
|
| 404 |
wal = Path(str(db_path) + "-wal")
|
| 405 |
return {
|
|
|
|
| 695 |
now=time.time()))
|
| 696 |
|
| 697 |
@app.get("/v1/analytics")
|
| 698 |
+
async def get_analytics(request: Request, days: int = Query(7, ge=1, le=365),
|
| 699 |
+
start: float | None = Query(None, alias="from"),
|
| 700 |
+
end: float | None = Query(None, alias="to"),
|
| 701 |
+
top_k: int = Query(10, ge=1, le=100)):
|
| 702 |
p = await authn(request)
|
| 703 |
+
result = await db.read(store.analytics, p, time.time(), days, start, end, top_k,
|
| 704 |
+
board.presence.pending_history())
|
| 705 |
+
result["listening"]["now"] = board.presence.listening_now(time.time())
|
| 706 |
+
result["health"] = health_summary()
|
| 707 |
+
return result
|
| 708 |
|
| 709 |
def _origin(request: Request) -> str:
|
| 710 |
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
src/msgboard/store.py
CHANGED
|
@@ -25,6 +25,7 @@ import sqlite3
|
|
| 25 |
from typing import Any
|
| 26 |
|
| 27 |
from . import auth
|
|
|
|
| 28 |
from .auth import Principal
|
| 29 |
from .config import LIMITS
|
| 30 |
from .presence import FIELDS as PRESENCE_FIELDS, unknown as unknown_presence
|
|
@@ -409,7 +410,7 @@ def load_presence(conn) -> dict[int, dict]:
|
|
| 409 |
return records
|
| 410 |
|
| 411 |
|
| 412 |
-
def save_presence(conn, records: dict[int, dict]) -> None:
|
| 413 |
conn.executemany(
|
| 414 |
"""INSERT INTO principal_presence
|
| 415 |
(principal_id, last_seen_at, last_poll_at, listens, cursor, cursor_since, waiting, waiting_since)
|
|
@@ -421,6 +422,11 @@ def save_presence(conn, records: dict[int, dict]) -> None:
|
|
| 421 |
json.dumps(record["listens"]) if record["listens"] is not None else None,
|
| 422 |
record["cursor"], record["cursor_since"], record["waiting"], record["waiting_since"])
|
| 423 |
for pid, record in records.items()])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
|
| 425 |
|
| 426 |
def attach_presence(conn, viewer: Principal, items: list[dict], records: dict[int, dict]) -> list[dict]:
|
|
@@ -2001,28 +2007,26 @@ def _reactions_to_me(conn, p: Principal, scope: str, limit: int) -> list[dict]:
|
|
| 2001 |
# ------------------------------------------------------------------ analytics
|
| 2002 |
|
| 2003 |
|
| 2004 |
-
def analytics(conn, p: Principal, now: float, days: int =
|
|
|
|
| 2005 |
"""Board activity for admins (and readers): totals, a daily series, channels and who posts."""
|
| 2006 |
if not p.sees_everything:
|
| 2007 |
-
raise forbidden("only admins can see analytics")
|
| 2008 |
days = max(1, min(int(days), 365))
|
| 2009 |
-
|
| 2010 |
-
|
| 2011 |
-
|
| 2012 |
-
|
| 2013 |
-
|
| 2014 |
-
|
| 2015 |
-
if d in daily:
|
| 2016 |
-
daily[d][k] = n
|
| 2017 |
-
series = [{"day": d, **v} for d, v in sorted(daily.items())]
|
| 2018 |
|
| 2019 |
chans: dict = {}
|
| 2020 |
for r in conn.execute("""SELECT c.id, c.name, c.kind, c.archived,
|
| 2021 |
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id) AS total,
|
| 2022 |
-
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0) AS recent,
|
| 2023 |
-
(SELECT COUNT(DISTINCT m.sender_id) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0) AS posters,
|
| 2024 |
(SELECT MAX(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_at
|
| 2025 |
-
FROM channels c""", {"d0":
|
| 2026 |
key = "dm" if r["kind"] == "dm" else r["id"] # DMs are counted together, never by pair
|
| 2027 |
c = chans.setdefault(key, {"id": None if key == "dm" else r["id"], "name": "direct messages" if key == "dm" else r["name"],
|
| 2028 |
"kind": r["kind"], "archived": bool(r["archived"]) if key != "dm" else False,
|
|
@@ -2033,15 +2037,15 @@ def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
|
|
| 2033 |
|
| 2034 |
per_day = {}
|
| 2035 |
for pid, d, n in conn.execute("""SELECT sender_id, CAST(created_at / 86400 AS INTEGER) * 86400, COUNT(*) FROM messages
|
| 2036 |
-
WHERE created_at >= ? GROUP BY 1, 2""", (
|
| 2037 |
per_day.setdefault(pid, {})[d] = n
|
| 2038 |
posters = []
|
| 2039 |
for r in conn.execute("""SELECT p.id, p.display_name, p.external_id, p.identity_type, p.role, p.owner_id, p.pattern_seed,
|
| 2040 |
substr(av.sha256, 1, 12) AS avatar, COUNT(m.id) AS n, MAX(m.created_at) AS last_at,
|
| 2041 |
COUNT(DISTINCT m.channel_id) AS nchannels
|
| 2042 |
-
FROM principals p JOIN messages m ON m.sender_id = p.id AND m.created_at >= ?
|
| 2043 |
LEFT JOIN avatars av ON av.principal_id = p.id
|
| 2044 |
-
GROUP BY p.id ORDER BY n DESC""", (
|
| 2045 |
posters.append({"id": r["id"], "display_name": r["display_name"], "external_id": r["external_id"],
|
| 2046 |
"identity_type": r["identity_type"], "role": r["role"], "avatar": r["avatar"], "pattern": r["pattern_seed"],
|
| 2047 |
"owner": _brief(conn, r["owner_id"]), "messages": r["n"], "last_at": r["last_at"], "channels": r["nchannels"],
|
|
@@ -2049,6 +2053,7 @@ def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
|
|
| 2049 |
|
| 2050 |
count = lambda sql, *a: conn.execute(sql, a).fetchone()[0]
|
| 2051 |
week = now - 7 * 86400
|
|
|
|
| 2052 |
totals = {
|
| 2053 |
"messages": count("SELECT COUNT(*) FROM messages"),
|
| 2054 |
"messages_window": sum(d["people"] + d["agents"] for d in series),
|
|
@@ -2056,14 +2061,136 @@ def analytics(conn, p: Principal, now: float, days: int = 30) -> dict:
|
|
| 2056 |
"agents": count("SELECT COUNT(*) FROM principals WHERE identity_type != 'user' AND disabled = 0"),
|
| 2057 |
"channels": count("SELECT COUNT(*) FROM channels WHERE kind != 'dm' AND archived = 0"),
|
| 2058 |
"active_people_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
|
| 2059 |
-
WHERE m.created_at >= ? AND p.identity_type = 'user'""", week),
|
| 2060 |
"active_agents_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
|
| 2061 |
-
WHERE m.created_at >= ? AND p.identity_type != 'user'""", week),
|
| 2062 |
-
"threads_window": count("SELECT COUNT(DISTINCT thread_id) FROM messages WHERE thread_id IS NOT NULL AND created_at >= ?",
|
| 2063 |
-
"attachments_window": count
|
| 2064 |
}
|
| 2065 |
-
return {"days":
|
| 2066 |
-
"channels": channels, "posters": posters}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2067 |
|
| 2068 |
|
| 2069 |
# ------------------------------------------------------------------ search
|
|
|
|
| 25 |
from typing import Any
|
| 26 |
|
| 27 |
from . import auth
|
| 28 |
+
from . import analytics as analyticsmod
|
| 29 |
from .auth import Principal
|
| 30 |
from .config import LIMITS
|
| 31 |
from .presence import FIELDS as PRESENCE_FIELDS, unknown as unknown_presence
|
|
|
|
| 410 |
return records
|
| 411 |
|
| 412 |
|
| 413 |
+
def save_presence(conn, records: dict[int, dict], history: dict | None = None) -> None:
|
| 414 |
conn.executemany(
|
| 415 |
"""INSERT INTO principal_presence
|
| 416 |
(principal_id, last_seen_at, last_poll_at, listens, cursor, cursor_since, waiting, waiting_since)
|
|
|
|
| 422 |
json.dumps(record["listens"]) if record["listens"] is not None else None,
|
| 423 |
record["cursor"], record["cursor_since"], record["waiting"], record["waiting_since"])
|
| 424 |
for pid, record in records.items()])
|
| 425 |
+
if history:
|
| 426 |
+
conn.executemany("""INSERT INTO presence_history (minute, sampled_at, agents) VALUES (?, ?, ?)
|
| 427 |
+
ON CONFLICT(minute) DO UPDATE SET sampled_at=excluded.sampled_at, agents=excluded.agents
|
| 428 |
+
WHERE excluded.sampled_at >= presence_history.sampled_at""",
|
| 429 |
+
[(minute, sample[0], sample[1]) for minute, sample in history.items()])
|
| 430 |
|
| 431 |
|
| 432 |
def attach_presence(conn, viewer: Principal, items: list[dict], records: dict[int, dict]) -> list[dict]:
|
|
|
|
| 2007 |
# ------------------------------------------------------------------ analytics
|
| 2008 |
|
| 2009 |
|
| 2010 |
+
def analytics(conn, p: Principal, now: float, days: int = 7, start: float | None = None,
|
| 2011 |
+
end: float | None = None, top_k: int = 10, history: dict | None = None) -> dict:
|
| 2012 |
"""Board activity for admins (and readers): totals, a daily series, channels and who posts."""
|
| 2013 |
if not p.sees_everything:
|
| 2014 |
+
raise forbidden("only admins and readers can see analytics")
|
| 2015 |
days = max(1, min(int(days), 365))
|
| 2016 |
+
start, end = analyticsmod.bounds(now, days, start, end)
|
| 2017 |
+
if not 1 <= top_k <= 100:
|
| 2018 |
+
raise bad_request("top_k must be between 1 and 100")
|
| 2019 |
+
stats = _analytics_messages(conn, start, end)
|
| 2020 |
+
series = [{key: row[key] for key in ("day", "people", "agents")} for row in stats["daily"]]
|
| 2021 |
+
daily = {row["day"]: row for row in series}
|
|
|
|
|
|
|
|
|
|
| 2022 |
|
| 2023 |
chans: dict = {}
|
| 2024 |
for r in conn.execute("""SELECT c.id, c.name, c.kind, c.archived,
|
| 2025 |
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id) AS total,
|
| 2026 |
+
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0 AND m.created_at < :end) AS recent,
|
| 2027 |
+
(SELECT COUNT(DISTINCT m.sender_id) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= :d0 AND m.created_at < :end) AS posters,
|
| 2028 |
(SELECT MAX(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_at
|
| 2029 |
+
FROM channels c""", {"d0": start, "end": end}):
|
| 2030 |
key = "dm" if r["kind"] == "dm" else r["id"] # DMs are counted together, never by pair
|
| 2031 |
c = chans.setdefault(key, {"id": None if key == "dm" else r["id"], "name": "direct messages" if key == "dm" else r["name"],
|
| 2032 |
"kind": r["kind"], "archived": bool(r["archived"]) if key != "dm" else False,
|
|
|
|
| 2037 |
|
| 2038 |
per_day = {}
|
| 2039 |
for pid, d, n in conn.execute("""SELECT sender_id, CAST(created_at / 86400 AS INTEGER) * 86400, COUNT(*) FROM messages
|
| 2040 |
+
WHERE created_at >= ? AND created_at < ? GROUP BY 1, 2""", (start, end)):
|
| 2041 |
per_day.setdefault(pid, {})[d] = n
|
| 2042 |
posters = []
|
| 2043 |
for r in conn.execute("""SELECT p.id, p.display_name, p.external_id, p.identity_type, p.role, p.owner_id, p.pattern_seed,
|
| 2044 |
substr(av.sha256, 1, 12) AS avatar, COUNT(m.id) AS n, MAX(m.created_at) AS last_at,
|
| 2045 |
COUNT(DISTINCT m.channel_id) AS nchannels
|
| 2046 |
+
FROM principals p JOIN messages m ON m.sender_id = p.id AND m.created_at >= ? AND m.created_at < ?
|
| 2047 |
LEFT JOIN avatars av ON av.principal_id = p.id
|
| 2048 |
+
GROUP BY p.id ORDER BY n DESC, p.id""", (start, end)):
|
| 2049 |
posters.append({"id": r["id"], "display_name": r["display_name"], "external_id": r["external_id"],
|
| 2050 |
"identity_type": r["identity_type"], "role": r["role"], "avatar": r["avatar"], "pattern": r["pattern_seed"],
|
| 2051 |
"owner": _brief(conn, r["owner_id"]), "messages": r["n"], "last_at": r["last_at"], "channels": r["nchannels"],
|
|
|
|
| 2053 |
|
| 2054 |
count = lambda sql, *a: conn.execute(sql, a).fetchone()[0]
|
| 2055 |
week = now - 7 * 86400
|
| 2056 |
+
files = _analytics_files(conn, start, end)
|
| 2057 |
totals = {
|
| 2058 |
"messages": count("SELECT COUNT(*) FROM messages"),
|
| 2059 |
"messages_window": sum(d["people"] + d["agents"] for d in series),
|
|
|
|
| 2061 |
"agents": count("SELECT COUNT(*) FROM principals WHERE identity_type != 'user' AND disabled = 0"),
|
| 2062 |
"channels": count("SELECT COUNT(*) FROM channels WHERE kind != 'dm' AND archived = 0"),
|
| 2063 |
"active_people_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
|
| 2064 |
+
WHERE m.created_at >= ? AND m.created_at < ? AND p.identity_type = 'user'""", week, now),
|
| 2065 |
"active_agents_7d": count("""SELECT COUNT(DISTINCT m.sender_id) FROM messages m JOIN principals p ON p.id = m.sender_id
|
| 2066 |
+
WHERE m.created_at >= ? AND m.created_at < ? AND p.identity_type != 'user'""", week, now),
|
| 2067 |
+
"threads_window": count("SELECT COUNT(DISTINCT thread_id) FROM messages WHERE thread_id IS NOT NULL AND created_at >= ? AND created_at < ?", start, end),
|
| 2068 |
+
"attachments_window": files["count"],
|
| 2069 |
}
|
| 2070 |
+
return {"days": len(series), "since": start, "generated_at": now, "totals": totals, "daily": series,
|
| 2071 |
+
"channels": channels, "posters": posters, "range": {"from": start, "to": end, "bucket_seconds": 86400},
|
| 2072 |
+
"message_stats": stats, "response_times": _analytics_responses(conn, start, min(now, end)),
|
| 2073 |
+
"interactions": _analytics_interactions(conn, start, end, top_k), "files": files,
|
| 2074 |
+
"listening": _analytics_listening(conn, start, end, history or {})}
|
| 2075 |
+
|
| 2076 |
+
|
| 2077 |
+
def _analytics_messages(conn, start: float, end: float) -> dict:
|
| 2078 |
+
import math
|
| 2079 |
+
|
| 2080 |
+
daily = {day: {"day": day, "messages": 0, "people": 0, "agents": 0, "words": 0, "characters": 0}
|
| 2081 |
+
for day in range(int(start // 86400) * 86400, math.ceil(end / 86400) * 86400, 86400)}
|
| 2082 |
+
for row in conn.execute("""SELECT m.created_at, m.body, p.identity_type FROM messages m
|
| 2083 |
+
JOIN principals p ON p.id = m.sender_id
|
| 2084 |
+
WHERE m.created_at >= ? AND m.created_at < ?""", (start, end)):
|
| 2085 |
+
data = daily[int(row["created_at"] // 86400) * 86400]
|
| 2086 |
+
data["messages"] += 1
|
| 2087 |
+
data["people" if row["identity_type"] == "user" else "agents"] += 1
|
| 2088 |
+
data["characters"] += len(row["body"])
|
| 2089 |
+
data["words"] += len(analyticsmod.WORD.findall(row["body"]))
|
| 2090 |
+
for data in daily.values():
|
| 2091 |
+
data["average_length"] = data["characters"] / data["messages"] if data["messages"] else None
|
| 2092 |
+
total = {key: sum(data[key] for data in daily.values()) for key in ("messages", "words", "characters")}
|
| 2093 |
+
return {**total, "average_length": total["characters"] / total["messages"] if total["messages"] else None,
|
| 2094 |
+
"daily": list(daily.values())}
|
| 2095 |
+
|
| 2096 |
+
|
| 2097 |
+
def _analytics_files(conn, start: float, end: float) -> dict:
|
| 2098 |
+
rows = conn.execute("""SELECT uploader_id, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
|
| 2099 |
+
FROM attachments WHERE published = 1 AND created_at >= ? AND created_at < ?
|
| 2100 |
+
GROUP BY uploader_id ORDER BY bytes DESC, uploader_id""", (start, end))
|
| 2101 |
+
people = [dict(_brief(conn, row["uploader_id"]), count=row["count"], bytes=row["bytes"]) for row in rows]
|
| 2102 |
+
return {"count": sum(item["count"] for item in people), "bytes": sum(item["bytes"] for item in people),
|
| 2103 |
+
"by_principal": people}
|
| 2104 |
+
|
| 2105 |
+
|
| 2106 |
+
def _analytics_responses(conn, start: float, end: float) -> dict:
|
| 2107 |
+
# Each request/agent appears once even when a DM also mentions its recipient.
|
| 2108 |
+
# Indexed first-response lookups avoid joining every request to every later message.
|
| 2109 |
+
rows = conn.execute("""WITH requests AS (
|
| 2110 |
+
SELECT m.id, mm.principal_id AS target, m.channel_id, COALESCE(m.thread_id, m.id) AS thread,
|
| 2111 |
+
m.created_at, c.kind
|
| 2112 |
+
FROM messages m JOIN message_mentions mm ON mm.message_id = m.id
|
| 2113 |
+
JOIN principals p ON p.id = mm.principal_id JOIN channels c ON c.id = m.channel_id
|
| 2114 |
+
WHERE m.created_at >= :start AND m.created_at < :end AND p.identity_type != 'user'
|
| 2115 |
+
AND mm.principal_id != m.sender_id
|
| 2116 |
+
UNION
|
| 2117 |
+
SELECT m.id, cm.principal_id, m.channel_id, COALESCE(m.thread_id, m.id), m.created_at, c.kind
|
| 2118 |
+
FROM messages m JOIN channels c ON c.id = m.channel_id
|
| 2119 |
+
JOIN channel_members cm ON cm.channel_id = c.id JOIN principals p ON p.id = cm.principal_id
|
| 2120 |
+
WHERE c.kind = 'dm' AND m.created_at >= :start AND m.created_at < :end
|
| 2121 |
+
AND p.identity_type != 'user' AND cm.principal_id != m.sender_id
|
| 2122 |
+
) SELECT q.target, q.created_at,
|
| 2123 |
+
CASE WHEN q.kind = 'dm' THEN (
|
| 2124 |
+
SELECT r.created_at FROM messages r WHERE r.channel_id = q.channel_id AND r.sender_id = q.target
|
| 2125 |
+
AND r.created_at >= q.created_at AND r.created_at < :end AND r.id > q.id
|
| 2126 |
+
ORDER BY r.created_at, r.id LIMIT 1)
|
| 2127 |
+
ELSE (
|
| 2128 |
+
SELECT r.created_at FROM messages r WHERE r.sender_id = q.target AND r.thread_id = q.thread
|
| 2129 |
+
AND r.created_at >= q.created_at AND r.created_at < :end AND r.id > q.id
|
| 2130 |
+
ORDER BY r.created_at, r.id LIMIT 1)
|
| 2131 |
+
END AS answered_at FROM requests q""", {"start": start, "end": end})
|
| 2132 |
+
agents = {}
|
| 2133 |
+
latencies = []
|
| 2134 |
+
requests = 0
|
| 2135 |
+
for row in rows:
|
| 2136 |
+
requests += 1
|
| 2137 |
+
agent = agents.setdefault(row["target"], {"requests": 0, "latencies": []})
|
| 2138 |
+
agent["requests"] += 1
|
| 2139 |
+
if row["answered_at"] is not None:
|
| 2140 |
+
latency = row["answered_at"] - row["created_at"]
|
| 2141 |
+
latencies.append(latency)
|
| 2142 |
+
agent["latencies"].append(latency)
|
| 2143 |
+
per_agent = [dict(_brief(conn, pid), **analyticsmod.response_summary(data["requests"], data["latencies"]))
|
| 2144 |
+
for pid, data in agents.items()]
|
| 2145 |
+
return {"rule": "Mention or DM to an agent -> its first later message in that thread or DM, before the range ends. Reactions do not count.",
|
| 2146 |
+
**analyticsmod.response_summary(requests, latencies), "histogram": analyticsmod.response_histogram(latencies),
|
| 2147 |
+
"agents": sorted(per_agent, key=lambda item: (-item["requests"], item["id"]))}
|
| 2148 |
+
|
| 2149 |
+
|
| 2150 |
+
def _analytics_interactions(conn, start: float, end: float, top_k: int) -> dict:
|
| 2151 |
+
rows = conn.execute("""WITH events AS (
|
| 2152 |
+
SELECT m.id, m.sender_id AS source, cm.principal_id AS target, 0 AS priority
|
| 2153 |
+
FROM messages m JOIN channels c ON c.id = m.channel_id
|
| 2154 |
+
JOIN channel_members cm ON cm.channel_id = m.channel_id
|
| 2155 |
+
WHERE c.kind = 'dm' AND m.created_at >= :start AND m.created_at < :end AND cm.principal_id != m.sender_id
|
| 2156 |
+
UNION ALL
|
| 2157 |
+
SELECT m.id, m.sender_id, parent.sender_id, 1
|
| 2158 |
+
FROM messages m JOIN messages parent ON parent.id = m.reply_to JOIN channels c ON c.id = m.channel_id
|
| 2159 |
+
WHERE c.kind != 'dm' AND m.created_at >= :start AND m.created_at < :end
|
| 2160 |
+
UNION ALL
|
| 2161 |
+
SELECT m.id, m.sender_id, mm.principal_id, 2
|
| 2162 |
+
FROM messages m JOIN message_mentions mm ON mm.message_id = m.id JOIN channels c ON c.id = m.channel_id
|
| 2163 |
+
WHERE c.kind != 'dm' AND m.created_at >= :start AND m.created_at < :end
|
| 2164 |
+
), pairs AS (
|
| 2165 |
+
SELECT e.id, min(source, target) AS a, max(source, target) AS b, min(priority) AS priority
|
| 2166 |
+
FROM events e JOIN principals s ON s.id = e.source JOIN principals t ON t.id = e.target
|
| 2167 |
+
WHERE (s.identity_type = 'user') != (t.identity_type = 'user') GROUP BY e.id, a, b
|
| 2168 |
+
) SELECT a, b, priority, COUNT(*) AS count FROM pairs GROUP BY a, b, priority""", {"start": start, "end": end})
|
| 2169 |
+
edges = {}
|
| 2170 |
+
weights = {}
|
| 2171 |
+
for row in rows:
|
| 2172 |
+
a, b, count = row["a"], row["b"], row["count"]
|
| 2173 |
+
edge = edges.setdefault((a, b), {"source": a, "target": b, "total": 0, "mentions": 0, "replies": 0, "dms": 0})
|
| 2174 |
+
edge[("dms", "replies", "mentions")[row["priority"]]] += count
|
| 2175 |
+
edge["total"] += count
|
| 2176 |
+
for pid in (a, b):
|
| 2177 |
+
weights[pid] = weights.get(pid, 0) + count
|
| 2178 |
+
selected = sorted(weights, key=lambda pid: (-weights[pid], pid))[:top_k]
|
| 2179 |
+
identities = {pid: _brief(conn, pid) for pid in selected}
|
| 2180 |
+
return analyticsmod.chord_graph(list(edges.values()), identities, top_k)
|
| 2181 |
+
|
| 2182 |
+
|
| 2183 |
+
def _analytics_listening(conn, start: float, end: float, pending: dict) -> dict:
|
| 2184 |
+
first = conn.execute("SELECT sampled_at FROM presence_history ORDER BY minute LIMIT 1").fetchone()
|
| 2185 |
+
started_at = first[0] if first else None
|
| 2186 |
+
if pending:
|
| 2187 |
+
candidate = min(sample[0] for sample in pending.values())
|
| 2188 |
+
started_at = min(started_at, candidate) if started_at is not None else candidate
|
| 2189 |
+
rows = conn.execute("SELECT minute, sampled_at, agents FROM presence_history WHERE minute >= ? AND minute < ?",
|
| 2190 |
+
(int(start // 60) * 60, end))
|
| 2191 |
+
samples = {row[0]: (row[1], row[2]) for row in rows}
|
| 2192 |
+
samples.update(pending)
|
| 2193 |
+
return analyticsmod.listening_series(start, end, samples, started_at)
|
| 2194 |
|
| 2195 |
|
| 2196 |
# ------------------------------------------------------------------ search
|