Renames the app/ingest/ package to app/glean/ and updates all references across Python modules, shell scripts, Vue components, tests, and documentation. Intentionally preserved: - SQLite column name ingest_time (avoids schema migration) - RetrievedEntry.ingest_time field (maps to the column above) - Any public-facing JSON keys that reference ingest_time Changes by category: - app/ingest/ → app/glean/ (full package move, all parsers) - app/tasks/ingest_scheduler.py → app/tasks/glean_scheduler.py - scripts/ingest_corpus.py → scripts/glean_corpus.py - tests/test_ingest_*.py → tests/test_glean_*.py - Docstrings, log messages, comments: ingest → glean - Env var: TURNSTONE_INGEST_INTERVAL → TURNSTONE_GLEAN_INTERVAL - Shell scripts: glean.log, glean_corpus.py references - README.md: multi-source ingest → multi-source glean - .env.example: updated env var name - patterns/: new diagnostic patterns from 2026-05-20 SSH incident (service_crash_loop, pkg_daemon_restart, ssh_forward_conflict) - SourcesView.vue: pipeline label updated - All test import paths updated to app.glean.* 285 tests passing.
183 lines
6.9 KiB
Vue
183 lines
6.9 KiB
Vue
<template>
|
|
<div class="p-4 sm:p-6 max-w-5xl mx-auto">
|
|
<div class="mb-6 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-text-primary text-xl font-semibold mb-1">Log Sources</h1>
|
|
<p class="text-text-dim text-sm">All hosts and services in the gleaned corpus.</p>
|
|
</div>
|
|
<label class="btn-secondary text-sm cursor-pointer shrink-0">
|
|
<span>Upload log file</span>
|
|
<input type="file" class="hidden" @change="handleUpload" />
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Upload / action feedback -->
|
|
<div v-if="actionMsg" class="mb-4 text-sm rounded border px-4 py-2.5"
|
|
:class="actionError ? 'border-sev-error text-sev-error bg-surface-raised' : 'border-accent text-accent bg-surface-raised'">
|
|
{{ actionMsg }}
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-text-dim py-8 text-center text-sm">Loading…</div>
|
|
|
|
<div v-else-if="sources.length === 0" class="text-text-dim py-12 text-center">
|
|
<p class="mb-1">No log sources found.</p>
|
|
<p class="text-sm">Run the glean pipeline: <code class="bg-surface-raised px-1 rounded">python scripts/glean_corpus.py</code></p>
|
|
</div>
|
|
|
|
<div v-else class="rounded border border-surface-border overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm min-w-[560px]">
|
|
<thead class="bg-surface-raised border-b border-surface-border">
|
|
<tr>
|
|
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Source</th>
|
|
<th class="text-right px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Entries</th>
|
|
<th class="text-right px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Errors</th>
|
|
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Earliest</th>
|
|
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Latest</th>
|
|
<th class="px-4 py-2.5"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="src in sources"
|
|
:key="src.source_id"
|
|
class="border-b border-surface-border hover:bg-surface-raised transition-colors"
|
|
>
|
|
<td class="px-4 py-2.5 text-accent">{{ src.source_id }}</td>
|
|
<td class="px-4 py-2.5 text-text-muted text-right tabular-nums">{{ src.entry_count.toLocaleString() }}</td>
|
|
<td class="px-4 py-2.5 text-right tabular-nums">
|
|
<span :class="src.error_count > 0 ? 'text-sev-error' : 'text-text-dim'">
|
|
{{ src.error_count.toLocaleString() }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-2.5 text-text-dim text-xs">{{ formatTs(src.earliest) }}</td>
|
|
<td class="px-4 py-2.5 text-text-dim text-xs">{{ formatTs(src.latest) }}</td>
|
|
<td class="px-4 py-2.5">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button
|
|
:disabled="busy.has(src.source_id)"
|
|
@click="reglean(src.source_id)"
|
|
class="text-text-dim hover:text-accent transition-colors text-xs px-2 py-1 rounded hover:bg-surface disabled:opacity-40"
|
|
title="Re-glean from sources.yaml"
|
|
>{{ busy.has(src.source_id) ? '…' : 'reglean' }}</button>
|
|
<button
|
|
:disabled="busy.has(src.source_id)"
|
|
@click="deleteSource(src.source_id)"
|
|
class="text-text-dim hover:text-sev-error transition-colors text-xs px-2 py-1 rounded hover:bg-surface disabled:opacity-40"
|
|
title="Delete all entries for this source"
|
|
>delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import type { LogSource } from '@/stores/search'
|
|
|
|
const sources = ref<LogSource[]>([])
|
|
const loading = ref(true)
|
|
const busy = ref(new Set<string>())
|
|
const actionMsg = ref('')
|
|
const actionError = ref(false)
|
|
|
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
|
|
|
async function loadSources(): Promise<void> {
|
|
try {
|
|
const res = await fetch(`${BASE}/api/sources`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
sources.value = data.sources
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(loadSources)
|
|
|
|
function setBusy(id: string, on: boolean): void {
|
|
const next = new Set(busy.value)
|
|
on ? next.add(id) : next.delete(id)
|
|
busy.value = next
|
|
}
|
|
|
|
async function deleteSource(sourceId: string): Promise<void> {
|
|
if (!confirm(`Delete all entries for "${sourceId}"? This cannot be undone.`)) return
|
|
setBusy(sourceId, true)
|
|
actionMsg.value = ''
|
|
try {
|
|
const res = await fetch(`${BASE}/api/sources/${encodeURIComponent(sourceId)}`, { method: 'DELETE' })
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
actionMsg.value = `Deleted ${data.deleted.toLocaleString()} entries for "${sourceId}"`
|
|
actionError.value = false
|
|
sources.value = sources.value.filter(s => s.source_id !== sourceId)
|
|
} else {
|
|
const data = await res.json()
|
|
actionMsg.value = data.detail ?? 'Delete failed'
|
|
actionError.value = true
|
|
}
|
|
} finally {
|
|
setBusy(sourceId, false)
|
|
}
|
|
}
|
|
|
|
async function reglean(sourceId: string): Promise<void> {
|
|
setBusy(sourceId, true)
|
|
actionMsg.value = ''
|
|
actionError.value = false
|
|
try {
|
|
const res = await fetch(`${BASE}/api/sources/${encodeURIComponent(sourceId)}/glean`, { method: 'POST' })
|
|
const data = await res.json()
|
|
if (res.ok) {
|
|
actionMsg.value = `Re-glean complete: ${data.gleaned.toLocaleString()} new entries for "${sourceId}"`
|
|
actionError.value = false
|
|
await loadSources()
|
|
} else {
|
|
actionMsg.value = data.detail ?? 'Re-glean failed'
|
|
actionError.value = true
|
|
}
|
|
} finally {
|
|
setBusy(sourceId, false)
|
|
}
|
|
}
|
|
|
|
async function handleUpload(e: Event): Promise<void> {
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (!file) return
|
|
actionMsg.value = 'Uploading…'
|
|
actionError.value = false
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const res = await fetch(`${BASE}/api/glean/upload`, { method: 'POST', body: form })
|
|
const data = await res.json()
|
|
if (res.ok) {
|
|
actionMsg.value = `Uploaded: ${data.gleaned.toLocaleString()} entries gleaned as "${data.source_id}"`
|
|
actionError.value = false
|
|
await loadSources()
|
|
} else {
|
|
actionMsg.value = data.detail ?? 'Upload failed'
|
|
actionError.value = true
|
|
}
|
|
;(e.target as HTMLInputElement).value = ''
|
|
}
|
|
|
|
function formatTs(iso: string | null): string {
|
|
if (!iso) return '—'
|
|
try {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
} catch {
|
|
return iso
|
|
}
|
|
}
|
|
</script>
|