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
421 lines
16 KiB
Vue
421 lines
16 KiB
Vue
<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>
|