turnstone/web/src/components/SetupWizard.vue
pyr0ball 1131816666 feat: bundle PII sanitization, onboarding wizard, NL source addition (#51, #52, #53)
Bundle export (#51):
- _redact_text() with 5 compiled regex patterns (IPv4, email, user=, host=, password=)
- build_bundle(sanitize=False) — per-entry redaction at export time
- sent_bundles table tracks every outgoing export (GET and POST /send)
- GET /api/sent-bundles exposes history; SentBundle model added
- BundlesView: Received/Sent tabs, sanitized badge, 5-entry preview, re-download
- IncidentsView: Sanitize PII checkbox next to Send Bundle

Onboarding wizard (#52):
- app/services/discover.py: journald/Docker/file detection (best-effort, safe in containers)
- GET /api/setup/status, /discover, POST /api/setup/write (additive, appends to existing)
- SetupWizard.vue: 3-step Detect → Select → Confirm
  - Step 1 shows grouped summary (journald/file/docker counts)
  - Step 2: collapsible groups with All/None section toggles
    - journald + file: pre-selected; docker: collapsed, none pre-selected
  - Step 3: YAML preview before write
- SourcesView: shows wizard on first run; Add Source button reuses it

NL source addition (#53):
- app/services/nl_source.py: keyword shortcut (13 well-known apps) + LLM fallback
- POST /api/setup/interpret: keyword → LLM → null (graceful fallback)
- NL field in wizard step 2; manual form shown when interpretation fails
- Added sources appear in grouped list immediately
2026-05-29 14:14:28 -07:00

421 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="rounded border border-accent bg-surface-raised p-6 sm:p-8 max-w-2xl mx-auto">
<!-- Step indicator -->
<div class="flex items-center gap-2 mb-6">
<span v-for="(label, i) in stepLabels" :key="i" class="flex items-center gap-2">
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold border"
:class="i + 1 === step
? 'bg-accent text-bg border-accent'
: i + 1 < step
? 'bg-accent/20 text-accent border-accent/40'
: 'bg-surface text-text-dim border-surface-border'"
>{{ i + 1 }}</span>
<span class="text-xs hidden sm:inline" :class="i + 1 === step ? 'text-text-primary' : 'text-text-dim'">{{ label }}</span>
<span v-if="i < stepLabels.length - 1" class="text-text-dim text-xs"></span>
</span>
</div>
<!-- Step 1: Detect -->
<div v-if="step === 1">
<h2 class="text-text-primary text-base font-semibold mb-1">Detecting log sources</h2>
<p class="text-text-dim text-sm mb-5">Turnstone is scanning for available log sources on this host.</p>
<div v-if="discovering" class="flex items-center gap-2 text-text-dim text-sm py-4">
<svg class="animate-spin w-4 h-4 text-accent" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
Scanning
</div>
<div v-else-if="discoverError" class="text-sev-error text-sm py-4">
{{ discoverError }}
<button @click="runDiscover" class="ml-2 underline text-accent text-xs">Retry</button>
</div>
<div v-else>
<div v-if="candidates.length === 0" class="text-text-dim text-sm py-3 mb-4">
No sources auto-detected. You can add sources manually in the next step.
</div>
<div v-else class="space-y-1 text-sm mb-4">
<div v-for="g in groups" :key="g.type" class="flex items-center gap-2 text-text-muted">
<span class="font-mono text-xs text-text-dim px-1.5 py-0.5 rounded border border-surface-border">{{ g.type }}</span>
<span><strong class="text-text-primary">{{ g.items.length }}</strong> {{ g.label }}</span>
</div>
</div>
<div class="flex justify-between items-center mt-6">
<a @click.prevent="$emit('skip')" href="#" class="text-text-dim text-xs hover:text-text-muted">
Skip I'll edit sources.yaml manually
</a>
<button @click="step = 2" class="btn-primary text-sm">Continue →</button>
</div>
</div>
</div>
<!-- ── Step 2: Select ── -->
<div v-if="step === 2">
<h2 class="text-text-primary text-base font-semibold mb-1">Select log sources</h2>
<p class="text-text-dim text-sm mb-4">Choose which sources to monitor. You can add more later.</p>
<!-- Grouped source list -->
<div class="space-y-3 mb-4">
<div v-for="g in groups" :key="g.type" class="rounded border border-surface-border overflow-hidden">
<!-- Group header -->
<div class="flex items-center gap-3 px-3 py-2 bg-surface border-b border-surface-border">
<button @click="toggleGroupOpen(g.type)" class="flex items-center gap-2 flex-1 min-w-0 text-left">
<span class="text-text-dim text-xs">{{ groupOpen[g.type] ? '' : '' }}</span>
<span class="text-text-primary text-sm font-medium">{{ g.label }}</span>
<span class="text-text-dim text-xs">({{ g.items.length }})</span>
<span v-if="groupSelectedCount(g.type) > 0" class="text-accent text-xs ml-1">
{{ groupSelectedCount(g.type) }} selected
</span>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
@click="selectGroup(g.type)"
class="text-xs px-2 py-0.5 rounded border border-surface-border text-text-dim hover:text-accent hover:border-accent transition-colors"
>All</button>
<button
@click="deselectGroup(g.type)"
class="text-xs px-2 py-0.5 rounded border border-surface-border text-text-dim hover:text-sev-error hover:border-sev-error transition-colors"
>None</button>
</div>
</div>
<!-- Group items -->
<div v-if="groupOpen[g.type]" class="divide-y divide-surface-border max-h-64 overflow-y-auto">
<label
v-for="c in g.items"
:key="c.id"
class="flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-colors"
:class="isSelected(c) ? 'bg-accent/5' : 'hover:bg-surface'"
>
<input
type="checkbox"
:checked="isSelected(c)"
@change="toggleCandidate(c)"
class="mt-0.5 accent-accent shrink-0"
/>
<div class="min-w-0 flex-1">
<div class="text-text-primary text-sm">{{ c.label }}</div>
<div v-if="c.path" class="font-mono text-xs text-text-dim mt-0.5 truncate">{{ c.path }}</div>
<div v-else-if="c.container" class="font-mono text-xs text-text-dim mt-0.5">{{ c.container }}</div>
</div>
</label>
</div>
</div>
</div>
<!-- NL / manual add -->
<div class="border border-surface-border rounded p-4 mb-4">
<p class="text-text-muted text-xs font-medium mb-2">Add a source by description</p>
<div class="flex gap-2">
<input
v-model="nlDescription"
type="text"
placeholder="e.g. nginx access log, qbittorrent, sonarr"
class="flex-1 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"
@keydown.enter="interpretNL"
/>
<button
@click="interpretNL"
:disabled="!nlDescription.trim() || interpreting"
class="btn-secondary text-xs px-3 disabled:opacity-40 disabled:cursor-not-allowed"
>{{ interpreting ? '' : 'Add' }}</button>
</div>
<div v-if="nlError" class="text-sev-error text-xs mt-2">{{ nlError }}</div>
<div v-if="showManualForm" class="mt-3 space-y-2">
<p class="text-text-dim text-xs">Couldn't interpret that fill in manually:</p>
<div class="flex gap-2">
<input v-model="manualId" placeholder="id (e.g. nginx)" class="flex-1 input-sm" />
<input v-model="manualPath" placeholder="/path/to/log.txt" class="flex-1 input-sm" />
</div>
<button @click="addManual" class="btn-secondary text-xs mt-1">Add manually</button>
</div>
</div>
<div class="flex justify-between items-center">
<button @click="step = 1" class="text-text-dim text-xs hover:text-text-muted"> Back</button>
<div class="flex items-center gap-3">
<span class="text-text-dim text-xs">
{{ selected.length }} source{{ selected.length === 1 ? '' : 's' }} selected
</span>
<button
@click="step = 3"
:disabled="selected.length === 0"
class="btn-primary text-sm disabled:opacity-40 disabled:cursor-not-allowed"
>Review </button>
</div>
</div>
</div>
<!-- Step 3: Confirm -->
<div v-if="step === 3">
<h2 class="text-text-primary text-base font-semibold mb-1">Confirm and write</h2>
<p class="text-text-dim text-sm mb-4">Review the <code class="bg-surface px-1 rounded">sources.yaml</code> that will be written.</p>
<pre class="bg-surface border border-surface-border rounded p-3 text-xs font-mono text-text-primary overflow-x-auto max-h-64 mb-5 whitespace-pre">{{ previewYaml }}</pre>
<div v-if="writeError" class="text-sev-error text-sm mb-4">{{ writeError }}</div>
<div v-if="writeSuccess" class="text-green-400 text-sm mb-4">{{ writeSuccess }}</div>
<div class="flex justify-between items-center">
<button @click="step = 2" class="text-text-dim text-xs hover:text-text-muted"> Back</button>
<button
@click="writeAndFinish"
:disabled="writing"
class="btn-primary text-sm disabled:opacity-40 disabled:cursor-not-allowed"
>{{ writing ? 'Writing…' : 'Write sources.yaml' }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
const emit = defineEmits<{ done: []; skip: [] }>()
interface Candidate {
type: string
id: string
label: string
description: string
path?: string
container?: string
runtime?: string
unit?: string
available: boolean
}
interface Group {
type: string
label: string
items: Candidate[]
}
const GROUP_META: Record<string, { label: string; order: number; defaultOpen: boolean; preselect: boolean }> = {
journald: { label: 'System journal', order: 0, defaultOpen: true, preselect: true },
file: { label: 'Log files', order: 1, defaultOpen: true, preselect: true },
docker: { label: 'Docker containers', order: 2, defaultOpen: false, preselect: false },
}
const stepLabels = ['Detect', 'Select', 'Confirm']
const step = ref(1)
const discovering = ref(false)
const discoverError = ref<string | null>(null)
const candidates = ref<Candidate[]>([])
const selected = ref<Candidate[]>([])
// Track which groups are expanded
const groupOpen = reactive<Record<string, boolean>>({})
const groups = computed<Group[]>(() => {
const map: Record<string, Candidate[]> = {}
for (const c of candidates.value) {
;(map[c.type] ??= []).push(c)
}
return Object.entries(map)
.map(([type, items]) => ({
type,
label: GROUP_META[type]?.label ?? type,
items,
}))
.sort((a, b) => (GROUP_META[a.type]?.order ?? 99) - (GROUP_META[b.type]?.order ?? 99))
})
function groupSelectedCount(type: string): number {
const group = groups.value.find(g => g.type === type)
if (!group) return 0
return group.items.filter(c => isSelected(c)).length
}
function toggleGroupOpen(type: string) {
groupOpen[type] = !groupOpen[type]
}
function selectGroup(type: string) {
const group = groups.value.find(g => g.type === type)
if (!group) return
const newIds = new Set(selected.value.map(s => s.id))
const additions = group.items.filter(c => !newIds.has(c.id))
selected.value = [...selected.value, ...additions]
groupOpen[type] = true
}
function deselectGroup(type: string) {
const group = groups.value.find(g => g.type === type)
if (!group) return
const removeIds = new Set(group.items.map(c => c.id))
selected.value = selected.value.filter(s => !removeIds.has(s.id))
}
// NL / manual add
const nlDescription = ref('')
const interpreting = ref(false)
const nlError = ref<string | null>(null)
const showManualForm = ref(false)
const manualId = ref('')
const manualPath = ref('')
// Write
const writing = ref(false)
const writeError = ref<string | null>(null)
const writeSuccess = ref<string | null>(null)
const previewYaml = computed(() => {
if (!selected.value.length) return '# No sources selected'
const lines = ['sources:']
for (const src of selected.value) {
if (src.type === 'journald') {
lines.push(` - id: ${src.id}`)
lines.push(` type: journald`)
if (src.unit) lines.push(` unit: ${src.unit}`)
} else if (src.type === 'docker') {
lines.push(` - id: ${src.id}`)
lines.push(` type: docker`)
lines.push(` runtime: ${src.runtime ?? 'docker'}`)
lines.push(` container: ${src.container ?? src.id.split(':').pop()}`)
} else {
lines.push(` - id: ${src.id}`)
lines.push(` path: ${src.path}`)
}
}
return lines.join('\n')
})
function isSelected(c: Candidate): boolean {
return selected.value.some(s => s.id === c.id)
}
function toggleCandidate(c: Candidate) {
if (isSelected(c)) {
selected.value = selected.value.filter(s => s.id !== c.id)
} else {
selected.value = [...selected.value, c]
}
}
async function runDiscover() {
discovering.value = true
discoverError.value = null
try {
const res = await fetch(`${BASE}/api/setup/discover`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
candidates.value = data.candidates ?? []
// Initialise group open state and pre-selection per group meta
for (const [type, meta] of Object.entries(GROUP_META)) {
groupOpen[type] = meta.defaultOpen
}
// Any type not in GROUP_META gets collapsed by default
for (const c of candidates.value) {
if (!(c.type in groupOpen)) groupOpen[c.type] = false
}
// Pre-select only groups where preselect = true
selected.value = candidates.value.filter(c => GROUP_META[c.type]?.preselect ?? false)
} catch (e: any) {
discoverError.value = e.message ?? 'Discovery failed'
} finally {
discovering.value = false
}
}
async function interpretNL() {
if (!nlDescription.value.trim()) return
interpreting.value = true
nlError.value = null
showManualForm.value = false
try {
const res = await fetch(`${BASE}/api/setup/interpret`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: nlDescription.value }),
})
const data = await res.json()
if (data.source) {
const candidate: Candidate = { available: true, ...data.source }
// Add to candidates so it appears in the correct group
if (!candidates.value.some(c => c.id === candidate.id)) {
candidates.value = [...candidates.value, candidate]
if (!(candidate.type in groupOpen)) groupOpen[candidate.type] = true
}
if (!isSelected(candidate)) selected.value = [...selected.value, candidate]
nlDescription.value = ''
} else {
showManualForm.value = true
nlError.value = data.validation_error
? `Validation: ${data.validation_error}`
: 'Could not interpret — fill in manually below.'
}
} catch {
showManualForm.value = true
nlError.value = 'Interpretation failed — fill in manually below.'
} finally {
interpreting.value = false
}
}
function addManual() {
if (!manualId.value.trim() || !manualPath.value.trim()) return
const candidate: Candidate = {
type: 'file',
id: manualId.value.trim(),
path: manualPath.value.trim(),
label: manualId.value.trim(),
description: `Read from ${manualPath.value.trim()}`,
available: true,
}
if (!candidates.value.some(c => c.id === candidate.id)) {
candidates.value = [...candidates.value, candidate]
groupOpen['file'] = true
}
if (!isSelected(candidate)) selected.value = [...selected.value, candidate]
manualId.value = ''
manualPath.value = ''
showManualForm.value = false
nlDescription.value = ''
nlError.value = null
}
async function writeAndFinish() {
writing.value = true
writeError.value = null
try {
const res = await fetch(`${BASE}/api/setup/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sources: selected.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
writeError.value = err.detail ?? 'Write failed'
return
}
const data = await res.json()
writeSuccess.value = `Wrote ${data.written} source${data.written === 1 ? '' : 's'} to sources.yaml.`
setTimeout(() => emit('done'), 1200)
} catch (e: any) {
writeError.value = e.message ?? 'Network error'
} finally {
writing.value = false
}
}
onMounted(runDiscover)
</script>
<style scoped>
.input-sm {
@apply bg-surface border border-surface-border rounded px-2 py-1 text-xs text-text-primary focus:outline-none focus:border-accent;
}
</style>