feat: Context view — document and fact management with accessible tables

Adds /context route with tabbed UI for managing uploaded documents and
manually-entered environment facts. Includes inline confirm-before-delete,
add-fact form with category/key/value fields, wizard CTA panel, and
stub components for DocUploadZone and WizardOverlay (Task 14).
This commit is contained in:
pyr0ball 2026-05-13 16:57:38 -07:00
parent 2fbf623f02
commit e8a1e2d77d
5 changed files with 275 additions and 0 deletions

View file

@ -57,6 +57,7 @@ const navLinks = [
{ to: '/incidents', label: 'Incidents' }, { to: '/incidents', label: 'Incidents' },
{ to: '/bundles', label: 'Bundles' }, { to: '/bundles', label: 'Bundles' },
{ to: '/sources', label: 'Sources' }, { to: '/sources', label: 'Sources' },
{ to: '/context', label: 'Context' },
] ]
const isDark = ref(document.documentElement.classList.contains('dark')) const isDark = ref(document.documentElement.classList.contains('dark'))

View file

@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup lang="ts">
defineEmits<{ uploaded: [] }>()
</script>

View file

@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup lang="ts">
defineEmits<{ close: []; complete: [] }>()
</script>

View file

@ -6,6 +6,7 @@ import SourcesView from '@/views/SourcesView.vue'
import IncidentsView from '@/views/IncidentsView.vue' import IncidentsView from '@/views/IncidentsView.vue'
import BundlesView from '@/views/BundlesView.vue' import BundlesView from '@/views/BundlesView.vue'
import SettingsView from '@/views/SettingsView.vue' import SettingsView from '@/views/SettingsView.vue'
import ContextView from '@/views/ContextView.vue'
export default createRouter({ export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -17,6 +18,7 @@ export default createRouter({
{ path: '/incidents', component: IncidentsView }, { path: '/incidents', component: IncidentsView },
{ path: '/bundles', component: BundlesView }, { path: '/bundles', component: BundlesView },
{ path: '/sources', component: SourcesView }, { path: '/sources', component: SourcesView },
{ path: '/context', component: ContextView },
{ path: '/settings', component: SettingsView }, { path: '/settings', component: SettingsView },
], ],
}) })

View file

@ -0,0 +1,258 @@
<template>
<div class="max-w-3xl mx-auto py-8 px-4">
<h1 class="text-xl font-semibold text-text-primary mb-1">Environment Context</h1>
<p class="text-sm text-text-dim mb-6">
Teach Turnstone about your environment so diagnoses can reference your actual setup.
</p>
<!-- Wizard CTA -->
<div class="rounded border border-surface-border bg-surface-raised p-5 mb-6">
<p class="text-sm text-text-muted mb-3">
Not sure what to add? The setup wizard walks you through configuring log sources and capturing infrastructure facts.
</p>
<button
@click="showWizard = true"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity"
>
Run setup wizard
</button>
</div>
<!-- Tabs -->
<div role="tablist" aria-label="Context sections" class="flex gap-1 mb-4 border-b border-surface-border">
<button
v-for="tab in ['Documents', 'Facts']"
:key="tab"
role="tab"
:aria-selected="activeTab === tab"
:id="`ctx-tab-${tab}`"
:aria-controls="`ctx-panel-${tab}`"
:tabindex="activeTab === tab ? 0 : -1"
@click="activeTab = tab"
:class="[
'px-4 py-2 text-sm transition-colors',
activeTab === tab
? 'text-accent border-b-2 border-accent font-semibold -mb-px'
: 'text-text-dim hover:text-text-primary'
]"
>{{ tab }}</button>
</div>
<!-- Documents tab -->
<div
v-show="activeTab === 'Documents'"
id="ctx-panel-Documents"
role="tabpanel"
aria-labelledby="ctx-tab-Documents"
tabindex="0"
>
<DocUploadZone @uploaded="loadDocs" class="mb-4" />
<div v-if="docs.length" class="rounded border border-surface-border overflow-hidden">
<table class="w-full text-sm">
<caption class="sr-only">Uploaded context documents</caption>
<thead class="bg-surface-raised">
<tr>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">File</th>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">Type</th>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">Uploaded</th>
<th scope="col" class="px-4 py-2 w-10"></th>
</tr>
</thead>
<tbody>
<tr v-for="doc in docs" :key="doc.id" class="border-t border-surface-border">
<td class="px-4 py-2 font-mono text-text-primary">{{ doc.filename }}</td>
<td class="px-4 py-2 text-text-dim">{{ doc.doc_type }}</td>
<td class="px-4 py-2 text-text-dim">{{ fmtDate(doc.uploaded_at) }}</td>
<td class="px-4 py-2 text-right">
<div v-if="confirmDelete === doc.id" class="flex items-center gap-2 text-xs">
<span class="text-text-dim">Remove?</span>
<button @click="doDelete(doc.id)" class="text-sev-error hover:underline">Confirm</button>
<button @click="confirmDelete = null" class="text-text-dim hover:text-text-primary">Cancel</button>
</div>
<button
v-else
@click="confirmDelete = doc.id"
:aria-label="`Delete document: ${doc.filename}`"
class="text-text-dim hover:text-sev-error text-xs"
>Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-sm text-text-dim text-center py-8">No documents uploaded yet.</p>
</div>
<!-- Facts tab -->
<div
v-show="activeTab === 'Facts'"
id="ctx-panel-Facts"
role="tabpanel"
aria-labelledby="ctx-tab-Facts"
tabindex="0"
>
<!-- Add fact form -->
<fieldset class="rounded border border-surface-border bg-surface-raised p-4 mb-4">
<legend class="text-sm font-medium text-text-primary px-1">Add a fact manually</legend>
<p class="text-xs text-text-dim mb-3">
Facts are structured notes about your environment e.g. what services run where, what IPs mean.
</p>
<div class="flex flex-wrap gap-3 mb-3">
<div>
<label for="fact-category" class="block text-xs text-text-dim mb-1">Category</label>
<select
id="fact-category"
v-model="newFact.category"
class="bg-surface border border-surface-border rounded px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:border-accent"
>
<option value="host">Host</option>
<option value="service">Service</option>
<option value="network">Network</option>
<option value="note">Note</option>
</select>
</div>
<div class="flex-1 min-w-32">
<label for="fact-key" class="block text-xs text-text-dim mb-1">Key <span class="text-text-dim">(e.g. "plex")</span></label>
<input
id="fact-key"
v-model="newFact.key"
type="text"
placeholder="e.g. plex"
class="w-full bg-surface border border-surface-border rounded px-3 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-w-48">
<label for="fact-value" class="block text-xs text-text-dim mb-1">Value <span class="text-text-dim">(e.g. "port:32400 on heimdall")</span></label>
<input
id="fact-value"
v-model="newFact.value"
type="text"
placeholder="e.g. port:32400 on heimdall"
class="w-full bg-surface border border-surface-border rounded px-3 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent"
/>
</div>
</div>
<button
@click="addFact"
:disabled="!newFact.key.trim() || !newFact.value.trim()"
class="px-4 py-1.5 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 disabled:opacity-40"
>Add fact</button>
</fieldset>
<div v-if="facts.length" class="rounded border border-surface-border overflow-hidden">
<table class="w-full text-sm">
<caption class="sr-only">Environment context facts</caption>
<thead class="bg-surface-raised">
<tr>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">Category</th>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">Key</th>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">Value</th>
<th scope="col" class="px-4 py-2 text-left text-text-dim font-medium">Added from</th>
<th scope="col" class="px-4 py-2 w-10"></th>
</tr>
</thead>
<tbody>
<tr v-for="fact in facts" :key="fact.id" class="border-t border-surface-border">
<td class="px-4 py-2 text-text-dim">{{ fact.category }}</td>
<td class="px-4 py-2 font-mono text-text-primary">{{ fact.key }}</td>
<td class="px-4 py-2 text-text-muted">{{ fact.value }}</td>
<td class="px-4 py-2 text-text-dim">{{ fact.source || 'manual' }}</td>
<td class="px-4 py-2 text-right">
<div v-if="confirmDeleteFact === fact.id" class="flex items-center gap-2 text-xs">
<span class="text-text-dim">Remove?</span>
<button @click="doDeleteFact(fact.id)" class="text-sev-error hover:underline">Confirm</button>
<button @click="confirmDeleteFact = null" class="text-text-dim hover:text-text-primary">Cancel</button>
</div>
<button
v-else
@click="confirmDeleteFact = fact.id"
:aria-label="`Delete fact: ${fact.key}`"
class="text-text-dim hover:text-sev-error text-xs"
>Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-sm text-text-dim text-center py-8">No facts added yet.</p>
</div>
<!-- Error -->
<div v-if="error" role="alert" class="mt-4 p-3 rounded bg-red-900/30 border border-red-700/40 text-sev-error text-sm">
{{ error }}
</div>
<!-- Wizard overlay -->
<WizardOverlay v-if="showWizard" @close="showWizard = false" @complete="onWizardComplete" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DocUploadZone from '@/components/DocUploadZone.vue'
import WizardOverlay from '@/components/WizardOverlay.vue'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
interface Doc { id: string; filename: string; doc_type: string; file_size: number | null; uploaded_at: string }
interface Fact { id: string; category: string; key: string; value: string; source: string | null; created_at: string }
const activeTab = ref('Documents')
const showWizard = ref(false)
const docs = ref<Doc[]>([])
const facts = ref<Fact[]>([])
const confirmDelete = ref<string | null>(null)
const confirmDeleteFact = ref<string | null>(null)
const error = ref<string | null>(null)
const newFact = ref({ category: 'service', key: '', value: '' })
async function loadDocs() {
const r = await fetch(`${BASE}/api/context/docs`)
if (r.ok) docs.value = await r.json()
}
async function loadFacts() {
const r = await fetch(`${BASE}/api/context/facts`)
if (r.ok) facts.value = await r.json()
}
async function doDelete(id: string) {
const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' })
if (r.ok) { confirmDelete.value = null; await loadDocs() }
else error.value = 'Failed to delete document'
}
async function doDeleteFact(id: string) {
const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' })
if (r.ok) { confirmDeleteFact.value = null; await loadFacts() }
else error.value = 'Failed to delete fact'
}
async function addFact() {
if (!newFact.value.key.trim() || !newFact.value.value.trim()) return
const r = await fetch(`${BASE}/api/context/facts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newFact.value, source: 'manual' }),
})
if (r.ok) {
newFact.value = { category: 'service', key: '', value: '' }
await loadFacts()
} else {
error.value = 'Failed to add fact'
}
}
function onWizardComplete() {
showWizard.value = false
loadFacts()
}
function fmtDate(iso: string) {
try { return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }
catch { return iso }
}
onMounted(() => { loadDocs(); loadFacts() })
</script>