Add accessible admin tracing interface
Build and publish frontend / publish (push) Successful in 46s

This commit is contained in:
Ein Anderssono
2026-10-15 08:29:40 +02:00
parent c462ef1871
commit 17604e736b
3 changed files with 130 additions and 2 deletions
+123 -2
View File
@@ -18,6 +18,11 @@
#dialogue li[data-role="user"] { font-weight: 650; }
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
#admin-content { display: grid; gap: 1rem; }
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
[hidden] { display: none !important; }
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
@@ -43,11 +48,25 @@
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
</form>
</section>
<section id="admin-view" aria-labelledby="admin-heading" hidden>
<h1 id="admin-heading">Administration</h1>
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
<nav id="admin-toolbar" aria-label="Administrationsvyer">
<button type="button" data-admin-view="players">Spelare</button>
<button type="button" data-admin-view="prompts">Prompter</button>
<button type="button" data-admin-view="rules">Luau-regler</button>
<button type="button" data-admin-view="audit">Revisionslogg</button>
<button type="button" id="admin-logout">Logga ut</button>
</nav>
<div id="admin-content" aria-live="polite" aria-busy="false"></div>
</section>
</main>
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · API <span id="api-version">hämtar version…</span></footer>
<script>
const auth = document.querySelector('#auth');
const play = document.querySelector('#play');
const adminView = document.querySelector('#admin-view');
const adminContent = document.querySelector('#admin-content');
const transcript = document.querySelector('#dialogue');
const messageField = document.querySelector('#message');
const messageLabel = document.querySelector('#message-label');
@@ -57,6 +76,8 @@
let gameState = null;
let submitBusy = false;
let pendingDialogue = null;
let adminOffset = 0;
const adminPageSize = 50;
async function request(path, options = {}) {
const response = await fetch(path, {credentials: 'same-origin', ...options});
@@ -88,15 +109,104 @@
function showAuth() {
stage = 'auth';
play.hidden = true;
adminView.hidden = true;
auth.hidden = false;
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
document.querySelector('#version-footer').hidden = false;
}
function showPlay() {
auth.hidden = true;
adminView.hidden = true;
play.hidden = false;
document.querySelector('#version-footer').hidden = true;
messageField.focus();
}
function showAdmin() {
auth.hidden = true;
play.hidden = true;
adminView.hidden = false;
document.querySelector('#version-footer').hidden = true;
}
function adminElement(tag, text, parent = adminContent) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
parent.append(element);
return element;
}
function adminJson(title, value, parent = adminContent) {
const details = adminElement('details', undefined, parent);
adminElement('summary', title, details);
adminElement('pre', JSON.stringify(value, null, 2), details);
return details;
}
async function loadAdminPlayers(query = '', offset = 0) {
adminOffset = offset;
adminContent.replaceChildren();
const search = adminElement('form');
search.id = 'player-search';
const label = adminElement('label', 'Sök efter användarnamn eller karaktär', search);
const field = document.createElement('input');
field.type = 'search'; field.name = 'q'; field.value = query; field.maxLength = 100;
label.append(field);
const submit = adminElement('button', 'Sök', search); submit.type = 'submit';
search.addEventListener('submit', event => { event.preventDefault(); loadAdminPlayers(field.value, 0); });
const result = await request(`/api/admin/players?q=${encodeURIComponent(query)}&limit=${adminPageSize}&offset=${offset}`);
adminElement('p', `${result.total} spelare. Visar ${result.offset + 1}–${Math.min(result.total, result.offset + result.players.length)}.`, adminContent);
const list = adminElement('ul');
for (const player of result.players) {
const item = adminElement('li', undefined, list);
const button = adminElement('button', `${player.username} · ${player.character_name || 'ingen karaktär'} · världsversion ${player.world_version ?? 'saknas'}`, item);
button.type = 'button'; button.addEventListener('click', () => openAdminPlayer(player.account_id));
}
const paging = adminElement('div');
const previous = adminElement('button', 'Föregående sida', paging);
previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminPlayers(query, Math.max(0, offset - adminPageSize)));
const next = adminElement('button', 'Nästa sida', paging);
next.type = 'button'; next.disabled = offset + result.players.length >= result.total; next.addEventListener('click', () => loadAdminPlayers(query, offset + adminPageSize));
}
async function openAdminPlayer(accountId) {
adminContent.replaceChildren();
const back = adminElement('button', 'Tillbaka till spelare'); back.type = 'button'; back.addEventListener('click', () => loadAdminPlayers('', adminOffset));
adminElement('h2', 'Spelarprofil');
const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`);
adminJson(`${profile.username} · profil och biografi`, profile);
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
const entries = [
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
...trace.ai_interactions.map(interaction => ({kind: `AI ${interaction.prompt_id} · ${interaction.outcome}`, at: interaction.requested_at, order: interaction.correlation_id, data: interaction}))
].sort((a, b) => String(a.at).localeCompare(String(b.at)));
adminElement('h2', 'Kronologiskt spelarspår');
adminElement('p', 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.');
for (const [index, entry] of entries.entries()) {
const status = entry.data.execution_mode || entry.data.metadata?.decision || entry.data.outcome || 'status okänd';
adminJson(`${index + 1}. ${entry.kind} · ${status} · ${entry.at} · ${entry.order}`, entry.data);
}
}
async function loadAdminPrompts() {
adminContent.replaceChildren();
const result = await request('/api/admin/prompts');
adminElement('h2', 'Prompttillgångar');
for (const prompt of result.prompts) adminJson(`${prompt.id} · version ${prompt.version}`, prompt.yaml);
}
async function loadAdminRules() {
adminContent.replaceChildren();
const result = await request('/api/admin/rules');
adminElement('h2', 'Luau-regler');
for (const rule of result.rules) adminJson(`${rule.rule_key} · v${rule.version} · ${rule.active ? 'aktiv' : 'inaktiv'} · ${rule.source_hash}`, {source: rule.source, validation_results: rule.validation_results, created_at: rule.created_at});
}
async function loadAdminAudit() {
adminContent.replaceChildren();
const result = await request('/api/admin/audit?limit=200');
adminElement('h2', 'Revisionslogg');
for (const entry of result.entries) adminJson(`${entry.created_at} · ${entry.action} · ${entry.admin_account_id || entry.operator_identity}`, entry);
}
async function openAdmin() {
showAdmin();
adminContent.setAttribute('aria-busy', 'true');
try { await loadAdminPlayers(); }
catch (error) { adminContent.replaceChildren(); adminElement('p', `Administrationsvyn kunde inte öppnas: ${error.message}`); }
finally { adminContent.setAttribute('aria-busy', 'false'); }
}
function appendInitialPrompt() {
appendLine('assistant', 'Välkommen. Skriv fritt om personen du vill spela. Jag föreslår en vuxen biografi som du kan ändra innan du godkänner den.');
setStage('biography');
@@ -278,7 +388,7 @@
try {
await request(`/api/${intent}`, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({username: data.get('username'), password: data.get('password')})});
form.reset();
await openGame();
if (location.pathname === '/admin') await openAdmin(); else await openGame();
} catch (error) {
window.alert(`Kunde inte logga in: ${error.message}`);
}
@@ -300,6 +410,17 @@
if (!play.hidden && !messageField.disabled) messageField.focus();
}
});
document.querySelectorAll('[data-admin-view]').forEach(button => button.addEventListener('click', async () => {
adminContent.setAttribute('aria-busy', 'true');
try {
if (button.dataset.adminView === 'players') await loadAdminPlayers();
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
else if (button.dataset.adminView === 'rules') await loadAdminRules();
else await loadAdminAudit();
} catch (error) { adminContent.replaceChildren(); adminElement('p', `Vyn kunde inte läsas: ${error.message}`); }
finally { adminContent.setAttribute('aria-busy', 'false'); }
}));
document.querySelector('#admin-logout').addEventListener('click', handleLogout);
messageField.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
@@ -311,7 +432,7 @@
}).catch(() => {
document.querySelector('#api-version').textContent = 'otillgänglig';
});
request('/api/me').then(openGame).catch(showAuth);
request('/api/me').then(() => location.pathname === '/admin' ? openAdmin() : openGame()).catch(showAuth);
</script>
</body>
</html>