Compare commits

...
2 Commits
Author SHA1 Message Date
Gitea Actions e29414e7c0 Release 0.1.24 [auto-version-bump] 2026-10-15 08:30:02 +02:00
Ein Anderssono 17604e736b Add accessible admin tracing interface
Build and publish frontend / publish (push) Successful in 46s
2026-10-15 08:29:40 +02:00
5 changed files with 133 additions and 5 deletions
+2
View File
@@ -6,6 +6,8 @@ After login, character creation and play use one dialogue history and one text i
During play the UI has no persistent location, task, inventory, economy, status, or purchase panels. The player asks for information or writes an action in the same input. Rust validates state changes; the AI can only propose a typed intent. A direct request to travel may establish a new place and an encounter when Rust commits that movement.
The separate `/admin` route provides an authenticated, read-only operations view for player search/profile/trace, YAML prompt assets, generated Luau rules with validation results, and the append-only audit log. All privileged data comes from the Rust `/api/admin/...` API; the browser never connects directly to PostgreSQL. Backend endpoints require `accounts.is_admin=true`.
The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced, and Enter submits the input while Shift+Enter inserts a newline; no separate send button is required.
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
+1 -1
View File
@@ -1 +1 @@
0.1.23
0.1.24
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator
type: application
version: 0.1.23
appVersion: "0.1.23"
version: 0.1.24
appVersion: "0.1.24"
+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>
+5
View File
@@ -12,6 +12,11 @@ grep -q "event.key === 'Enter' && !event.shiftKey" index.html
grep -q "request('/api/character/draft'" index.html
grep -q "request('/api/world/draft'" index.html
grep -q "request('/api/dialogue'" index.html
grep -q 'id="admin-view"' index.html
grep -q '/api/admin/players?' index.html
grep -q "request('/api/admin/prompts'" index.html
grep -q "request('/api/admin/rules'" index.html
grep -q '/api/admin/audit?limit=200' index.html
grep -q "request('/api/world/start'" index.html
grep -q "request('/api/character'" index.html
grep -q 'location = /healthz' nginx/default.conf