- App: hamburger menu on mobile, nav links hidden below md breakpoint - LogSearch: collapsible sidebar on mobile, stacks above results vertically - Incidents/Sources: overflow-x-auto on table containers, min-w to preserve column layout on desktop; drawer action buttons flex-wrap on small screens - Bundles: flex-wrap on header row, hide source_host + timestamp below sm - General: p-4 sm:p-6 padding on all standard views
183 lines
6.5 KiB
Vue
183 lines
6.5 KiB
Vue
<template>
|
|
<div class="p-4 sm:p-6 max-w-5xl mx-auto">
|
|
|
|
<!-- Header -->
|
|
<div class="mb-6">
|
|
<h1 class="text-text-primary text-xl font-semibold mb-1">Received Bundles</h1>
|
|
<p class="text-text-dim text-sm">Labeled incident bundles sent from remote Turnstone instances. Use these to build detection signatures.</p>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-text-dim py-8 text-center text-sm">Loading…</div>
|
|
|
|
<div v-else-if="bundles.length === 0" class="rounded border border-surface-border bg-surface-raised p-8 text-center">
|
|
<p class="text-text-muted text-base mb-1">No bundles received yet.</p>
|
|
<p class="text-text-dim text-sm">Bundles arrive when a remote Turnstone instance sends a labeled incident.</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="b in bundles"
|
|
:key="b.id"
|
|
class="rounded border bg-surface-raised overflow-hidden"
|
|
:class="selected?.id === b.id ? 'border-accent' : 'border-surface-border'"
|
|
>
|
|
<!-- Bundle header row -->
|
|
<div
|
|
class="flex flex-wrap items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 cursor-pointer hover:bg-surface transition-colors"
|
|
@click="toggleBundle(b)"
|
|
>
|
|
<span class="font-mono text-xs text-accent bg-surface px-1.5 py-0.5 rounded border border-surface-border shrink-0">
|
|
{{ b.issue_type || 'untyped' }}
|
|
</span>
|
|
<span class="text-text-primary text-sm flex-1 min-w-0 truncate">{{ b.label }}</span>
|
|
<span class="text-text-dim text-xs shrink-0 hidden sm:inline">{{ b.source_host }}</span>
|
|
<span class="px-2 py-0.5 rounded text-xs font-medium border shrink-0" :style="severityStyle(b.severity)">{{ b.severity }}</span>
|
|
<span class="text-text-dim text-xs shrink-0">{{ b.entry_count }} entries</span>
|
|
<span class="text-text-dim text-xs shrink-0 hidden sm:inline">{{ formatTs(b.bundled_at) }}</span>
|
|
<span class="text-text-dim text-xs shrink-0">{{ selected?.id === b.id ? '▲' : '▼' }}</span>
|
|
</div>
|
|
|
|
<!-- Expanded entries -->
|
|
<div v-if="selected?.id === b.id" class="border-t border-surface-border">
|
|
<div v-if="expandLoading" class="text-text-dim text-sm px-4 py-4">Loading entries…</div>
|
|
<div v-else-if="expandedEntries.length === 0" class="text-text-dim text-sm px-4 py-4">No entries in bundle.</div>
|
|
<div v-else class="p-4 space-y-1 max-h-[32rem] overflow-y-auto">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<p class="text-text-dim text-xs">{{ expandedEntries.length }} log entries</p>
|
|
<button
|
|
@click="exportBundle(b)"
|
|
class="ml-auto text-xs px-2 py-1 rounded border border-surface-border text-text-muted hover:text-accent hover:border-accent transition-colors"
|
|
>
|
|
Export JSON
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="entry in expandedEntries"
|
|
:key="entry.entry_id"
|
|
class="font-mono text-xs py-1 px-2 rounded bg-surface border border-surface-border"
|
|
>
|
|
<span class="text-text-dim mr-2">{{ shortTs(entry.timestamp_iso) }}</span>
|
|
<span :class="['mr-2', severityClass(entry.severity)]">{{ entry.severity || '?' }}</span>
|
|
<span class="text-text-muted">{{ lastPart(entry.source_id) }}</span>
|
|
<span class="text-text-dim mx-1">|</span>
|
|
<span class="text-text-primary">{{ entry.text.slice(0, 200) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
|
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
|
|
|
interface BundleSummary {
|
|
id: string
|
|
source_host: string
|
|
issue_type: string
|
|
label: string
|
|
severity: string
|
|
started_at: string | null
|
|
bundled_at: string
|
|
entry_count: number
|
|
bundle_json: string
|
|
}
|
|
|
|
interface LogEntry {
|
|
entry_id: string
|
|
source_id: string
|
|
timestamp_iso: string | null
|
|
severity: string | null
|
|
text: string
|
|
matched_patterns: string[]
|
|
}
|
|
|
|
const bundles = ref<BundleSummary[]>([])
|
|
const loading = ref(true)
|
|
const selected = ref<BundleSummary | null>(null)
|
|
const expandedEntries = ref<LogEntry[]>([])
|
|
const expandLoading = ref(false)
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const res = await fetch(`${BASE}/api/bundles`)
|
|
if (res.ok) bundles.value = (await res.json()).bundles
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
async function toggleBundle(b: BundleSummary) {
|
|
if (selected.value?.id === b.id) {
|
|
selected.value = null
|
|
expandedEntries.value = []
|
|
return
|
|
}
|
|
selected.value = b
|
|
expandedEntries.value = []
|
|
expandLoading.value = true
|
|
try {
|
|
// bundle_json is stored inline — parse it directly, no round-trip needed
|
|
const parsed = JSON.parse(b.bundle_json)
|
|
expandedEntries.value = parsed.log_entries ?? []
|
|
} catch {
|
|
expandLoading.value = false
|
|
} finally {
|
|
expandLoading.value = false
|
|
}
|
|
}
|
|
|
|
function exportBundle(b: BundleSummary) {
|
|
const blob = new Blob([b.bundle_json], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `bundle-${b.issue_type || 'untyped'}-${b.id.slice(0, 8)}.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
function severityStyle(sev: string): Record<string, string> {
|
|
const k = sev?.toLowerCase() ?? 'low'
|
|
const known = ['low', 'medium', 'high', 'critical']
|
|
const key = known.includes(k) ? k : 'low'
|
|
return {
|
|
backgroundColor: `var(--badge-${key}-bg)`,
|
|
color: `var(--badge-${key}-text)`,
|
|
borderColor: `var(--badge-${key}-text)`,
|
|
}
|
|
}
|
|
|
|
function severityClass(sev: string | null): string {
|
|
return {
|
|
ERROR: 'text-sev-error',
|
|
CRITICAL: 'text-sev-critical',
|
|
WARN: 'text-sev-warn',
|
|
WARNING: 'text-sev-warn',
|
|
INFO: 'text-sev-info',
|
|
DEBUG: 'text-text-dim',
|
|
}[sev?.toUpperCase() ?? ''] ?? 'text-text-dim'
|
|
}
|
|
|
|
function lastPart(sourceId: string): string {
|
|
return sourceId.split(':').pop() ?? sourceId
|
|
}
|
|
|
|
function formatTs(iso: string | null): string {
|
|
if (!iso) return '—'
|
|
try {
|
|
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
} catch { return iso }
|
|
}
|
|
|
|
function shortTs(iso: string | null): string {
|
|
if (!iso) return '?'
|
|
try {
|
|
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
} catch { return iso }
|
|
}
|
|
</script>
|