turnstone/web/src/views/BundlesView.vue
pyr0ball 807fe516a6 feat(ui): mobile responsive layout
- 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
2026-05-16 02:11:58 -07:00

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>