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.
199 lines
6.3 KiB
TypeScript
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, "&").replace(/"/g, """).replace(/</g, "<").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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|