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).
258 lines
11 KiB
Vue
258 lines
11 KiB
Vue
<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>
|