The subtitle was built by ~-concatenating a Markup operand
(user.email | e) with HTML string literals. Under autoescaping,
Jinja2's markup_join escapes every non-Markup part once it hits a
Markup operand — so the literal <strong> tags became <strong>
and the page showed literal "<strong>...</strong>" text around the
email. The | safe in _page_hero.html was too late to undo it.
Switch to {% set %}...{% endset %} block capture: the literal
<strong> stays HTML while {{ user.email }} is still autoescaped.
Regression test asserts the tags render and a hostile email stays
escaped.
483 lines
21 KiB
HTML
483 lines
21 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}My activity — {{ instance_brand }}{% endblock %}
|
||
|
||
{% block layout %}
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% if messages %}
|
||
<div class="flash-messages">
|
||
{% for category, message in messages %}
|
||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% endwith %}
|
||
<main class="main">{{ self.content() }}</main>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
/* Page-specific styles only. Tables use the canonical .data-table, stat
|
||
tiles use .stat-card, paginate buttons use .btn — all from
|
||
style-custom.css (design-system pass, v0.54.10). What remains here is
|
||
genuinely page-specific: panel show/hide, the token chart, the sessions
|
||
help text, pipeline badges, and the download link. */
|
||
.activity-page { max-width: 1280px; margin: 0 auto; padding: 24px 24px 64px; }
|
||
|
||
.stats-panel { display: none; }
|
||
.stats-panel.is-active { display: block; }
|
||
.stats-loading { color: var(--hp-text-muted, #6b7280); font-style: italic; padding: 24px 0; }
|
||
.stats-error { color: #b91c1c; padding: 24px 0; }
|
||
.stats-empty { color: var(--hp-text-muted, #6b7280); padding: 24px 0; }
|
||
|
||
/* Canonical .data-table is left-aligned; numeric columns opt into
|
||
right-alignment per-page via .num (matches the pre-migration look). */
|
||
.data-table th.num, .data-table td.num { text-align: right; }
|
||
|
||
/* Layout-only wrappers — the tiles/buttons inside are canonical. */
|
||
.stats-paginate {
|
||
display: flex; gap: 8px; justify-content: flex-end; padding: 12px 0;
|
||
}
|
||
.stats-overview {
|
||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;
|
||
}
|
||
@media (max-width: 720px) { .stats-overview { grid-template-columns: repeat(2, 1fr); } }
|
||
|
||
.tok-chart { width: 100%; height: 160px; margin: 8px 0 16px; }
|
||
.tok-chart rect { fill: var(--hp-accent, #2563eb); opacity: 0.85; }
|
||
.tok-chart .axis text { font-size: 10px; fill: var(--hp-text-muted, #6b7280); }
|
||
|
||
.sess-help {
|
||
color: var(--text-secondary, #6b7280); font-size: 13px;
|
||
margin-bottom: 16px; line-height: 1.55;
|
||
}
|
||
.sess-help code {
|
||
background: var(--border-light, #f3f4f6); padding: 1px 6px;
|
||
border-radius: 4px; font-size: 12px;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||
font-size: 11px; font-weight: 500;
|
||
}
|
||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||
.badge-processed { background: #dbeafe; color: #1e40af; }
|
||
.badge-extracted { background: #d1fae5; color: #065f46; }
|
||
|
||
.dl-link {
|
||
display: inline-block; padding: 4px 10px;
|
||
border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
|
||
color: inherit; text-decoration: none; font-size: 12px; font-weight: 500;
|
||
background: var(--surface, #fff);
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
.dl-link:hover {
|
||
background: var(--border-light, #f9fafb);
|
||
border-color: var(--primary, #6366f1);
|
||
color: var(--primary, #4338ca);
|
||
}
|
||
</style>
|
||
|
||
<div class="activity-page">
|
||
{% set page_hero_eyebrow = "Profile" %}
|
||
{% set page_hero_title = "My activity" %}
|
||
{# Block-capture so the literal <strong> stays HTML while {{ user.email }}
|
||
is still autoescaped. `~`-concatenating a Markup operand (user.email | e)
|
||
made Jinja2's markup_join escape the literal tags too. #}
|
||
{% set page_hero_subtitle %}Sessions, token usage, data access, and sync activity for <strong>{{ user.email }}</strong>.{% endset %}
|
||
{% include "_page_hero.html" %}
|
||
|
||
<nav class="tab-strip" role="tablist" aria-label="Activity sections">
|
||
<button type="button" role="tab" class="tab-strip__item is-active"
|
||
data-tab="sessions" aria-selected="true">Sessions</button>
|
||
<button type="button" role="tab" class="tab-strip__item"
|
||
data-tab="tokens" aria-selected="false">Token usage</button>
|
||
<button type="button" role="tab" class="tab-strip__item"
|
||
data-tab="queries" aria-selected="false">Data access</button>
|
||
<button type="button" role="tab" class="tab-strip__item"
|
||
data-tab="sync" aria-selected="false">Sync activity</button>
|
||
</nav>
|
||
|
||
<section class="stats-panel is-active" id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions">
|
||
<p class="sess-help">
|
||
Sessions from your Claude Code workspace. <strong>Items extracted = 0</strong>
|
||
means the verification detector ran successfully but the LLM didn't find
|
||
anything worth tracking — that's expected for sessions that are mostly tool
|
||
calls or coding without confident factual claims.
|
||
<em>Pending</em> means the file is on disk but the scheduler hasn't processed
|
||
it yet (next tick: every 15 min by default).
|
||
</p>
|
||
<div class="stats-loading" data-state="loading">Loading sessions…</div>
|
||
<div data-state="ready" hidden>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th>Started</th><th>Model</th>
|
||
<th class="num">Prompts</th><th class="num">Tools</th>
|
||
<th class="num">Tokens</th>
|
||
<th>Pipeline</th><th class="num">Items</th>
|
||
<th></th>
|
||
</tr></thead>
|
||
<tbody data-rows></tbody>
|
||
</table>
|
||
<div class="stats-paginate">
|
||
<button class="btn btn-secondary btn-sm" type="button" data-action="prev">‹ Prev</button>
|
||
<button class="btn btn-secondary btn-sm" type="button" data-action="next">Next ›</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stats-panel" id="panel-tokens" role="tabpanel" aria-labelledby="tab-tokens">
|
||
<div class="stats-loading" data-state="loading">Loading token usage…</div>
|
||
<div data-state="ready" hidden>
|
||
<div class="stats-overview">
|
||
<div class="stat-card"><div class="stat-card__label">Input</div><div class="stat-card__value" data-tok-total="input">—</div></div>
|
||
<div class="stat-card"><div class="stat-card__label">Output</div><div class="stat-card__value" data-tok-total="output">—</div></div>
|
||
<div class="stat-card"><div class="stat-card__label">Cache read</div><div class="stat-card__value" data-tok-total="cache_read">—</div></div>
|
||
<div class="stat-card"><div class="stat-card__label">Cache creation</div><div class="stat-card__value" data-tok-total="cache_creation">—</div></div>
|
||
</div>
|
||
<svg class="tok-chart" data-tok-chart viewBox="0 0 600 160" preserveAspectRatio="none"></svg>
|
||
|
||
<h3 style="font-size: 13px; margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--hp-text-muted, #6b7280);">By model</h3>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th>Model</th><th class="num">Sessions</th>
|
||
<th class="num">Input</th><th class="num">Output</th>
|
||
<th class="num">Cache R/W</th><th class="num">Total</th>
|
||
</tr></thead>
|
||
<tbody data-tok-model></tbody>
|
||
</table>
|
||
|
||
<h3 style="font-size: 13px; margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--hp-text-muted, #6b7280);">Top sessions</h3>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th>Started</th><th>Model</th>
|
||
<th class="num">Input</th><th class="num">Output</th>
|
||
<th class="num">Cache</th><th class="num">Total</th>
|
||
</tr></thead>
|
||
<tbody data-tok-top></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stats-panel" id="panel-queries" role="tabpanel" aria-labelledby="tab-queries">
|
||
<div class="stats-loading" data-state="loading">Loading queries…</div>
|
||
<div data-state="ready" hidden>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th>When</th><th>Action</th><th>Resource</th>
|
||
<th>Result</th><th class="num">Duration (ms)</th>
|
||
</tr></thead>
|
||
<tbody data-rows></tbody>
|
||
</table>
|
||
<div class="stats-paginate">
|
||
<button class="btn btn-secondary btn-sm" type="button" data-action="next">Older ›</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stats-panel" id="panel-sync" role="tabpanel" aria-labelledby="tab-sync">
|
||
<div class="stats-loading" data-state="loading">Loading sync activity…</div>
|
||
<div data-state="ready" hidden>
|
||
<div class="stats-overview" style="grid-template-columns: 1fr;">
|
||
<div class="stat-card">
|
||
<div class="stat-card__label">Your last <code>agnes pull</code></div>
|
||
<div class="stat-card__value" data-sync-last-pull>—</div>
|
||
</div>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th>When</th><th>Action</th><th>Source</th>
|
||
<th>Result</th>
|
||
</tr></thead>
|
||
<tbody data-rows></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const root = document.querySelector('.activity-page');
|
||
if (!root) return;
|
||
|
||
function fmtNum(n) {
|
||
if (n == null) return '—';
|
||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||
if (n >= 10_000) return (n / 1_000).toFixed(0) + 'k';
|
||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
||
return String(n);
|
||
}
|
||
|
||
function fmtRelative(iso) {
|
||
if (!iso) return 'never';
|
||
const then = new Date(iso);
|
||
const sec = Math.max(0, (Date.now() - then.getTime()) / 1000);
|
||
if (sec < 60) return Math.floor(sec) + 's ago';
|
||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||
return Math.floor(sec / 86400) + 'd ago';
|
||
}
|
||
|
||
function fmtTimestamp(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
return d.toLocaleString();
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
const d = document.createElement('div');
|
||
d.textContent = s == null ? '' : String(s);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
async function fetchJSON(url) {
|
||
const r = await fetch(url, { credentials: 'same-origin' });
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
}
|
||
|
||
function setReady(panel) {
|
||
panel.querySelector('[data-state="loading"]').hidden = true;
|
||
panel.querySelector('[data-state="ready"]').hidden = false;
|
||
}
|
||
|
||
function setError(panel, msg) {
|
||
const loading = panel.querySelector('[data-state="loading"]');
|
||
loading.className = 'stats-error';
|
||
loading.textContent = msg;
|
||
}
|
||
|
||
// ----- Sessions tab -----
|
||
|
||
const sessionsState = { offset: 0, limit: 25, total: 0 };
|
||
|
||
function pipelineBadge(status) {
|
||
if (status === 'extracted') return '<span class="badge badge-extracted">extracted</span>';
|
||
if (status === 'processed') return '<span class="badge badge-processed">processed</span>';
|
||
return '<span class="badge badge-pending">pending</span>';
|
||
}
|
||
|
||
async function loadSessions() {
|
||
const panel = document.getElementById('panel-sessions');
|
||
try {
|
||
const data = await fetchJSON(
|
||
`/api/me/stats/sessions?offset=${sessionsState.offset}&limit=${sessionsState.limit}`
|
||
);
|
||
sessionsState.total = data.total;
|
||
const tbody = panel.querySelector('[data-rows]');
|
||
tbody.innerHTML = '';
|
||
if (data.rows.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="8" class="stats-empty">No sessions yet — start Claude Code in a workspace and they\'ll appear here.</td></tr>';
|
||
}
|
||
for (const r of data.rows) {
|
||
const tr = document.createElement('tr');
|
||
const dlCell = r.download_url
|
||
? `<a class="dl-link" href="${escapeHtml(r.download_url)}" download>Download</a>`
|
||
: '';
|
||
tr.innerHTML = `
|
||
<td>${fmtTimestamp(r.started_at)}</td>
|
||
<td>${r.primary_model || '<em style="color:#9ca3af;">unprocessed</em>'}</td>
|
||
<td class="num">${fmtNum(r.user_messages)}</td>
|
||
<td class="num">${fmtNum(r.tool_calls)}</td>
|
||
<td class="num">${fmtNum(r.tokens_total)}</td>
|
||
<td>${pipelineBadge(r.pipeline_status)}</td>
|
||
<td class="num">${r.items_extracted != null ? r.items_extracted : 0}</td>
|
||
<td>${dlCell}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
setReady(panel);
|
||
const prevBtn = panel.querySelector('[data-action="prev"]');
|
||
const nextBtn = panel.querySelector('[data-action="next"]');
|
||
prevBtn.disabled = sessionsState.offset === 0;
|
||
nextBtn.disabled = sessionsState.offset + sessionsState.limit >= sessionsState.total;
|
||
} catch (e) {
|
||
setError(panel, 'Could not load sessions: ' + e.message);
|
||
}
|
||
}
|
||
|
||
document.querySelector('#panel-sessions [data-action="prev"]').addEventListener('click', () => {
|
||
sessionsState.offset = Math.max(0, sessionsState.offset - sessionsState.limit);
|
||
loadSessions();
|
||
});
|
||
document.querySelector('#panel-sessions [data-action="next"]').addEventListener('click', () => {
|
||
sessionsState.offset += sessionsState.limit;
|
||
loadSessions();
|
||
});
|
||
|
||
// ----- Token usage tab -----
|
||
|
||
async function loadTokens() {
|
||
const panel = document.getElementById('panel-tokens');
|
||
try {
|
||
const data = await fetchJSON('/api/me/stats/tokens?days=30');
|
||
for (const key of ['input', 'output', 'cache_read', 'cache_creation']) {
|
||
panel.querySelector(`[data-tok-total="${key}"]`).textContent =
|
||
fmtNum(data.totals[key]);
|
||
}
|
||
|
||
const chart = panel.querySelector('[data-tok-chart]');
|
||
chart.innerHTML = '';
|
||
const days = data.daily;
|
||
if (days.length > 0) {
|
||
const max = Math.max(...days.map(d => d.total), 1);
|
||
const w = 600 / Math.max(days.length, 1);
|
||
days.forEach((d, i) => {
|
||
const h = Math.max(1, (d.total / max) * 140);
|
||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||
rect.setAttribute('x', String(i * w + 1));
|
||
rect.setAttribute('y', String(160 - h));
|
||
rect.setAttribute('width', String(Math.max(1, w - 2)));
|
||
rect.setAttribute('height', String(h));
|
||
rect.setAttribute('title', `${d.day}: ${d.total}`);
|
||
chart.appendChild(rect);
|
||
});
|
||
}
|
||
|
||
const modelTbody = panel.querySelector('[data-tok-model]');
|
||
modelTbody.innerHTML = '';
|
||
if (data.by_model.length === 0) {
|
||
modelTbody.innerHTML = '<tr><td colspan="6" class="stats-empty">No token data yet.</td></tr>';
|
||
}
|
||
for (const m of data.by_model) {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${m.model}</td>
|
||
<td class="num">${fmtNum(m.sessions)}</td>
|
||
<td class="num">${fmtNum(m.input)}</td>
|
||
<td class="num">${fmtNum(m.output)}</td>
|
||
<td class="num">${fmtNum(m.cache_read + m.cache_creation)}</td>
|
||
<td class="num"><strong>${fmtNum(m.total)}</strong></td>
|
||
`;
|
||
modelTbody.appendChild(tr);
|
||
}
|
||
|
||
const topTbody = panel.querySelector('[data-tok-top]');
|
||
topTbody.innerHTML = '';
|
||
if (data.top_sessions.length === 0) {
|
||
topTbody.innerHTML = '<tr><td colspan="6" class="stats-empty">No sessions yet.</td></tr>';
|
||
}
|
||
for (const s of data.top_sessions) {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${fmtTimestamp(s.started_at)}</td>
|
||
<td>${s.primary_model || ''}</td>
|
||
<td class="num">${fmtNum(s.input)}</td>
|
||
<td class="num">${fmtNum(s.output)}</td>
|
||
<td class="num">${fmtNum(s.cache_read + s.cache_creation)}</td>
|
||
<td class="num"><strong>${fmtNum(s.total)}</strong></td>
|
||
`;
|
||
topTbody.appendChild(tr);
|
||
}
|
||
|
||
setReady(panel);
|
||
} catch (e) {
|
||
setError(panel, 'Could not load token data: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ----- Queries tab -----
|
||
|
||
const queriesState = { cursor: null };
|
||
|
||
async function loadQueries(append) {
|
||
const panel = document.getElementById('panel-queries');
|
||
try {
|
||
const params = new URLSearchParams({ limit: '50' });
|
||
if (queriesState.cursor) {
|
||
params.set('cursor_ts', queriesState.cursor.timestamp);
|
||
params.set('cursor_id', queriesState.cursor.id);
|
||
}
|
||
const data = await fetchJSON(`/api/me/stats/queries?${params}`);
|
||
const tbody = panel.querySelector('[data-rows]');
|
||
if (!append) tbody.innerHTML = '';
|
||
if (data.rows.length === 0 && !append) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="stats-empty">No queries logged for your account yet.</td></tr>';
|
||
}
|
||
for (const r of data.rows) {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${fmtTimestamp(r.timestamp)}</td>
|
||
<td><code>${r.action}</code></td>
|
||
<td>${r.resource || ''}</td>
|
||
<td>${r.result || ''}</td>
|
||
<td class="num">${r.duration_ms != null ? r.duration_ms : ''}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
queriesState.cursor = data.next_cursor;
|
||
panel.querySelector('[data-action="next"]').disabled = !queriesState.cursor;
|
||
setReady(panel);
|
||
} catch (e) {
|
||
setError(panel, 'Could not load queries: ' + e.message);
|
||
}
|
||
}
|
||
|
||
document.querySelector('#panel-queries [data-action="next"]').addEventListener('click', () => {
|
||
loadQueries(true);
|
||
});
|
||
|
||
// ----- Sync tab -----
|
||
|
||
async function loadSync() {
|
||
const panel = document.getElementById('panel-sync');
|
||
try {
|
||
const data = await fetchJSON('/api/me/stats/sync?limit=50');
|
||
panel.querySelector('[data-sync-last-pull]').textContent =
|
||
fmtRelative(data.last_pull_at);
|
||
const tbody = panel.querySelector('[data-rows]');
|
||
tbody.innerHTML = '';
|
||
if (data.rows.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="4" class="stats-empty">No sync activity yet.</td></tr>';
|
||
}
|
||
for (const r of data.rows) {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${fmtTimestamp(r.timestamp)}</td>
|
||
<td><code>${r.action}</code></td>
|
||
<td>${r.client_kind || ''}</td>
|
||
<td>${r.result || ''}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
setReady(panel);
|
||
} catch (e) {
|
||
setError(panel, 'Could not load sync activity: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ----- Tab switching -----
|
||
|
||
const loaded = new Set();
|
||
const loaders = {
|
||
sessions: loadSessions,
|
||
tokens: loadTokens,
|
||
queries: loadQueries,
|
||
sync: loadSync,
|
||
};
|
||
|
||
function activate(name) {
|
||
document.querySelectorAll('.tab-strip__item').forEach((b) => {
|
||
const on = b.dataset.tab === name;
|
||
b.classList.toggle('is-active', on);
|
||
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
||
});
|
||
document.querySelectorAll('.stats-panel').forEach((p) => {
|
||
p.classList.toggle('is-active', p.id === 'panel-' + name);
|
||
});
|
||
if (!loaded.has(name)) {
|
||
loaded.add(name);
|
||
loaders[name]();
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll('.tab-strip__item').forEach((b) => {
|
||
b.addEventListener('click', () => activate(b.dataset.tab));
|
||
});
|
||
|
||
// URL hash support: /me/activity?tab=sessions
|
||
const urlTab = new URLSearchParams(window.location.search).get('tab');
|
||
activate(urlTab && loaders[urlTab] ? urlTab : 'sessions');
|
||
})();
|
||
</script>
|
||
{% endblock %}
|