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:
parent
2fbf623f02
commit
e8a1e2d77d
5 changed files with 275 additions and 0 deletions
|
|
@ -57,6 +57,7 @@ const navLinks = [
|
|||
{ to: '/incidents', label: 'Incidents' },
|
||||
{ to: '/bundles', label: 'Bundles' },
|
||||
{ to: '/sources', label: 'Sources' },
|
||||
{ to: '/context', label: 'Context' },
|
||||
]
|
||||
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
|
|
|||
7
web/src/components/DocUploadZone.vue
Normal file
7
web/src/components/DocUploadZone.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{ uploaded: [] }>()
|
||||
</script>
|
||||
7
web/src/components/WizardOverlay.vue
Normal file
7
web/src/components/WizardOverlay.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{ close: []; complete: [] }>()
|
||||
</script>
|
||||
|
|
@ -6,6 +6,7 @@ import SourcesView from '@/views/SourcesView.vue'
|
|||
import IncidentsView from '@/views/IncidentsView.vue'
|
||||
import BundlesView from '@/views/BundlesView.vue'
|
||||
import SettingsView from '@/views/SettingsView.vue'
|
||||
import ContextView from '@/views/ContextView.vue'
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -17,6 +18,7 @@ export default createRouter({
|
|||
{ path: '/incidents', component: IncidentsView },
|
||||
{ path: '/bundles', component: BundlesView },
|
||||
{ path: '/sources', component: SourcesView },
|
||||
{ path: '/context', component: ContextView },
|
||||
{ path: '/settings', component: SettingsView },
|
||||
],
|
||||
})
|
||||
|
|
|
|||
258
web/src/views/ContextView.vue
Normal file
258
web/src/views/ContextView.vue
Normal 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>
|
||||
Loading…
Reference in a new issue