Files
paliad/frontend/src/client/admin-paliadin.ts
m 05d14d5e5a feat(admin/paliadin): show user + response preview + page origin + per-tool row counts on the monitor
m's ask (2026-05-08 15:02): the Paliadin monitor should show which user
made each turn, and ideally log more than just timing/classifier.

Backend:
- PaliadinTurn gains UserEmail + UserDisplayName fields (json:omitempty
  so user-facing API paths don't leak unrelated identity info; only
  populated by the admin LIST query).
- ListRecentTurns LEFT JOINs paliad.users to surface email +
  display_name on each row. The existing global_admin OR caller-owns
  visibility predicate on the WHERE clause stays unchanged.

Frontend (admin-paliadin):
- Recent-turns table grows from 5 → 8 columns:
  Zeit · Nutzer · Art · Anfrage · Antwort · Tools · Seite · Dauer
- Nutzer cell shows display_name (fallback email, fallback first 8 of
  user_id), with the full email in the title attribute on hover.
- Antwort cell renders the first 80 chars of the response with the full
  cleanBody available on hover. Useful for spot-checking what Paliadin
  actually wrote without clicking through every turn.
- Tools cell now pairs each tool name with its rows_seen count
  ("list_my_projects (11), search_my_deadlines (18)") so the data
  density is legible at a glance.
- Seite cell exposes page_origin (where in Paliad m kicked off the
  turn) — was already audited but never surfaced.
- DE/EN i18n keys added for the four new column headers.
2026-05-08 15:05:24 +02:00

199 lines
6.3 KiB
TypeScript

import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
// Paliadin admin dashboard client (t-paliad-146 PoC).
//
// Reads /api/admin/paliadin/stats + /api/admin/paliadin/turns and
// renders the cards / bars / sparkline / tables. Pure read-only;
// dashboard refreshes on each visit (no live polling — m comes here
// every few days, not every few seconds).
interface Stats {
total_turns: number;
turns_last_7_days: number;
median_duration_ms: number;
p90_duration_ms: number;
tool_use_rate: number;
abandon_rate: number;
by_classifier: Record<string, number>;
daily_counts: { day: string; count: number }[];
top_prompts: { prompt: string; count: number }[];
}
interface Turn {
turn_id: string;
user_id: string;
user_email: string | null;
user_display_name: string | null;
session_id: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
user_message: string;
response: string | null;
used_tools: string[] | null;
rows_seen: number[] | null;
classifier_tag: string | null;
abandoned: boolean;
error_code: string | null;
page_origin: string | null;
chip_count: number;
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const [stats, turns] = await Promise.all([
fetchJSON<Stats>("/api/admin/paliadin/stats"),
fetchJSON<Turn[]>("/api/admin/paliadin/turns"),
]);
if (stats) renderStats(stats);
if (turns) renderTurns(turns);
});
async function fetchJSON<T>(url: string): Promise<T | null> {
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
function renderStats(s: Stats): void {
setText("stat-total", String(s.total_turns));
setText("stat-7d", String(s.turns_last_7_days));
setText("stat-median", formatMs(s.median_duration_ms));
setText("stat-tools", formatPct(s.tool_use_rate));
setText("stat-abandon", formatPct(s.abandon_rate));
// Classifier histogram bars.
const cont = document.getElementById("classifier-bars");
if (cont) {
const entries = Object.entries(s.by_classifier).sort((a, b) => b[1] - a[1]);
const max = Math.max(...entries.map((e) => e[1]), 1);
cont.innerHTML = entries
.map(([tag, n]) => {
const pct = (n / max) * 100;
return `<div class="paliadin-classifier-row">
<div class="paliadin-classifier-label">${escapeHTML(tag)}</div>
<div class="paliadin-classifier-bar"><div class="paliadin-classifier-fill" style="width:${pct}%"></div></div>
<div class="paliadin-classifier-count">${n}</div>
</div>`;
})
.join("");
}
// Daily sparkline (last 30 days, vertical bars).
const spark = document.getElementById("daily-spark");
if (spark) {
const days = s.daily_counts;
const max = Math.max(...days.map((d) => d.count), 1);
spark.innerHTML = days
.map((d) => {
const h = (d.count / max) * 60;
return `<div class="paliadin-spark-bar" style="height:${h}px" title="${d.day}: ${d.count}"></div>`;
})
.join("");
}
// Top prompts table.
const tbody = document.getElementById("top-prompts-tbody");
if (tbody) {
if (s.top_prompts.length === 0) {
tbody.innerHTML = `<tr><td colspan="2">Noch keine Daten.</td></tr>`;
} else {
tbody.innerHTML = s.top_prompts
.map(
(p) =>
`<tr><td>${escapeHTML(p.prompt)}</td><td>${p.count}</td></tr>`,
)
.join("");
}
}
}
function renderTurns(turns: Turn[]): void {
const tbody = document.getElementById("recent-turns-tbody");
if (!tbody) return;
if (turns.length === 0) {
tbody.innerHTML = `<tr><td colspan="8">Noch keine Anfragen.</td></tr>`;
return;
}
tbody.innerHTML = turns
.map((t) => {
const tag = t.classifier_tag || "—";
// Tools cell pairs each tool name with its rows_seen count when
// available — "list_my_projects (11), search_my_deadlines (18)" —
// so the meta is legible at a glance instead of hidden in a side
// table. Falls back to "—" for casual chats with no tool calls.
const tools = t.used_tools && t.used_tools.length > 0
? t.used_tools
.map((name, i) => {
const r = t.rows_seen?.[i];
return r != null ? `${name} (${r})` : name;
})
.join(", ")
: "—";
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
const errMark = t.error_code ? `${t.error_code}` : "";
const userLabel = t.user_display_name || t.user_email || t.user_id.slice(0, 8);
const userTitle = [t.user_email, t.user_display_name].filter(Boolean).join(" · ") || t.user_id;
// Response preview — first 200 chars of cleanBody. Full response
// available on hover via the title attribute.
const respPreview = t.response ? truncate(t.response, 80) : "—";
const respTitle = t.response || "";
const origin = t.page_origin || "—";
return `<tr>
<td>${formatTime(t.started_at)}</td>
<td title="${escapeAttr(userTitle)}">${escapeHTML(userLabel)}</td>
<td>${escapeHTML(tag)}</td>
<td title="${escapeAttr(t.user_message)}">${escapeHTML(truncate(t.user_message, 80))}${errMark}</td>
<td title="${escapeAttr(respTitle)}">${escapeHTML(respPreview)}</td>
<td>${escapeHTML(tools)}</td>
<td>${escapeHTML(origin)}</td>
<td>${dur}</td>
</tr>`;
})
.join("");
}
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\n/g, " ");
}
function setText(id: string, val: string): void {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function formatMs(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
function formatPct(r: number): string {
return `${Math.round(r * 100)} %`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "…";
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}