feat(blocklist): BlocklistView + Pi-hole settings UI
This commit is contained in:
parent
5263a67fb3
commit
175bdff9cd
4 changed files with 452 additions and 1 deletions
|
|
@ -58,6 +58,7 @@ const navLinks = [
|
||||||
{ to: '/bundles', label: 'Bundles' },
|
{ to: '/bundles', label: 'Bundles' },
|
||||||
{ to: '/sources', label: 'Sources' },
|
{ to: '/sources', label: 'Sources' },
|
||||||
{ to: '/context', label: 'Context' },
|
{ to: '/context', label: 'Context' },
|
||||||
|
{ to: '/blocklist', label: 'Blocklist' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import IncidentsView from '@/views/IncidentsView.vue'
|
||||||
import BundlesView from '@/views/BundlesView.vue'
|
import BundlesView from '@/views/BundlesView.vue'
|
||||||
import SettingsView from '@/views/SettingsView.vue'
|
import SettingsView from '@/views/SettingsView.vue'
|
||||||
import ContextView from '@/views/ContextView.vue'
|
import ContextView from '@/views/ContextView.vue'
|
||||||
|
import BlocklistView from '@/views/BlocklistView.vue'
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -19,6 +20,7 @@ export default createRouter({
|
||||||
{ path: '/bundles', component: BundlesView },
|
{ path: '/bundles', component: BundlesView },
|
||||||
{ path: '/sources', component: SourcesView },
|
{ path: '/sources', component: SourcesView },
|
||||||
{ path: '/context', component: ContextView },
|
{ path: '/context', component: ContextView },
|
||||||
|
{ path: '/blocklist', component: BlocklistView },
|
||||||
{ path: '/settings', component: SettingsView },
|
{ path: '/settings', component: SettingsView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
333
web/src/views/BlocklistView.vue
Normal file
333
web/src/views/BlocklistView.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-6 max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-text-primary text-xl font-semibold mb-1">Blocklist</h1>
|
||||||
|
<p class="text-text-dim text-sm">
|
||||||
|
Review destinations flagged from router logs and push approved entries to Pi-hole.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
@click="testConnection"
|
||||||
|
:class="[
|
||||||
|
'text-xs px-2 py-1 rounded border transition-colors',
|
||||||
|
connectionStatus === null ? 'border-surface-border text-text-dim hover:text-text-primary' :
|
||||||
|
connectionStatus ? 'border-green-600 text-green-400' : 'border-sev-error text-sev-error'
|
||||||
|
]"
|
||||||
|
:title="connectionDetail || 'Click to test Pi-hole connection'"
|
||||||
|
>
|
||||||
|
Pi-hole
|
||||||
|
<span v-if="connectionStatus === null">●</span>
|
||||||
|
<span v-else-if="connectionStatus">✓</span>
|
||||||
|
<span v-else>✗</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="scan"
|
||||||
|
:disabled="scanning"
|
||||||
|
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{{ scanning ? 'Scanning…' : 'Scan now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status filter tabs -->
|
||||||
|
<div role="tablist" aria-label="Filter by status" class="flex gap-1 mb-6 border-b border-surface-border">
|
||||||
|
<button
|
||||||
|
v-for="(tab, idx) in tabs"
|
||||||
|
:key="tab.value"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === tab.value"
|
||||||
|
:tabindex="activeTab === tab.value ? 0 : -1"
|
||||||
|
@click="activeTab = tab.value"
|
||||||
|
@keydown="handleTabKey($event, idx)"
|
||||||
|
:ref="(el) => collectTabRef(el, idx)"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 text-sm transition-colors border-b-2 -mb-px',
|
||||||
|
activeTab === tab.value
|
||||||
|
? 'border-accent text-accent'
|
||||||
|
: 'border-transparent text-text-dim hover:text-text-primary'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span v-if="tab.count !== null" class="ml-1 text-xs opacity-70">({{ tab.count }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading / empty states -->
|
||||||
|
<div v-if="loading" class="text-text-dim py-12 text-center text-sm">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredCandidates.length === 0" class="text-text-dim py-12 text-center text-sm">
|
||||||
|
<p v-if="activeTab === 'pending'">No pending candidates. Run a scan to find new destinations.</p>
|
||||||
|
<p v-else>No candidates with status "{{ activeTab }}".</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Candidates grouped by device -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div
|
||||||
|
v-for="(group, deviceIp) in groupedCandidates"
|
||||||
|
:key="String(deviceIp)"
|
||||||
|
class="rounded border border-surface-border overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="bg-surface-raised border-b border-surface-border px-4 py-2.5 text-xs text-text-dim font-medium">
|
||||||
|
{{ group.name }} <span class="text-text-dim font-normal ml-1">({{ deviceIp }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-surface-border">
|
||||||
|
<div
|
||||||
|
v-for="c in group.candidates"
|
||||||
|
:key="c.id"
|
||||||
|
class="px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<span class="font-mono text-sm text-text-primary flex-1 min-w-0 truncate">{{ c.domain_or_ip }}</span>
|
||||||
|
<span
|
||||||
|
v-if="c.matched_rule"
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded bg-surface border border-surface-border text-text-dim font-mono shrink-0"
|
||||||
|
>{{ c.matched_rule }}</span>
|
||||||
|
<span class="text-xs text-text-dim shrink-0">{{ c.hit_count }} hit{{ c.hit_count !== 1 ? 's' : '' }}</span>
|
||||||
|
<span class="text-xs text-text-dim shrink-0">{{ shortDate(c.first_seen) }} → {{ shortDate(c.last_seen) }}</span>
|
||||||
|
|
||||||
|
<!-- Pending actions -->
|
||||||
|
<template v-if="c.status === 'pending'">
|
||||||
|
<button
|
||||||
|
@click="approve(c)"
|
||||||
|
:aria-label="`Approve ${c.domain_or_ip}`"
|
||||||
|
class="text-xs px-3 py-1 rounded border border-green-600/50 text-green-400 hover:bg-green-600/10 transition-colors"
|
||||||
|
>Approve</button>
|
||||||
|
<button
|
||||||
|
@click="reject(c)"
|
||||||
|
:aria-label="`Reject ${c.domain_or_ip}`"
|
||||||
|
class="text-xs px-3 py-1 rounded border border-surface-border text-text-dim hover:text-sev-error hover:border-sev-error transition-colors"
|
||||||
|
>Reject</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Approved actions -->
|
||||||
|
<template v-else-if="c.status === 'approved'">
|
||||||
|
<span class="text-xs text-green-400">Approved</span>
|
||||||
|
<button
|
||||||
|
@click="push(c)"
|
||||||
|
:aria-label="`Push ${c.domain_or_ip} to Pi-hole`"
|
||||||
|
class="text-xs px-3 py-1 rounded bg-accent text-surface hover:opacity-90 transition-opacity"
|
||||||
|
>Push to Pi-hole</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Pushed actions -->
|
||||||
|
<template v-else-if="c.status === 'pushed'">
|
||||||
|
<span class="text-xs text-accent">Blocked</span>
|
||||||
|
<button
|
||||||
|
@click="unblock(c)"
|
||||||
|
:aria-label="`Unblock ${c.domain_or_ip}`"
|
||||||
|
class="text-xs px-3 py-1 rounded border border-surface-border text-text-dim hover:text-text-primary transition-colors"
|
||||||
|
>Unblock</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Other statuses -->
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-xs text-text-dim capitalize">{{ c.status }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log evidence -->
|
||||||
|
<div v-if="c.log_evidence.length" class="mt-1">
|
||||||
|
<button
|
||||||
|
@click="toggleEvidence(c.id)"
|
||||||
|
:aria-expanded="expandedEvidence.has(c.id)"
|
||||||
|
class="text-xs text-text-dim hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ expandedEvidence.has(c.id) ? '▾' : '▸' }} log evidence ({{ c.log_evidence.length }})
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedEvidence.has(c.id)" class="mt-1 text-xs font-mono text-text-dim bg-surface rounded p-2 space-y-0.5">
|
||||||
|
<p v-for="eid in c.log_evidence.slice(0, 3)" :key="eid" class="truncate">{{ eid }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline error -->
|
||||||
|
<p v-if="errors[c.id]" class="mt-1 text-xs text-sev-error">{{ errors[c.id] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global scan error -->
|
||||||
|
<div v-if="scanError" role="alert" class="mt-4 text-sm text-sev-error">{{ scanError }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
id: string
|
||||||
|
domain_or_ip: string
|
||||||
|
source_device_ip: string | null
|
||||||
|
source_device_name: string | null
|
||||||
|
first_seen: string
|
||||||
|
last_seen: string
|
||||||
|
hit_count: number
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'pushed' | 'unblocked'
|
||||||
|
pushed_at: string | null
|
||||||
|
log_evidence: string[]
|
||||||
|
matched_rule: string | null
|
||||||
|
llm_score: number | null
|
||||||
|
llm_reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = ref<Candidate[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const scanning = ref(false)
|
||||||
|
const scanError = ref<string | null>(null)
|
||||||
|
const activeTab = ref('pending')
|
||||||
|
const expandedEvidence = ref(new Set<string>())
|
||||||
|
const errors = ref<Record<string, string>>({})
|
||||||
|
const connectionStatus = ref<boolean | null>(null)
|
||||||
|
const connectionDetail = ref('')
|
||||||
|
const tabBtnRefs = ref<HTMLButtonElement[]>([])
|
||||||
|
|
||||||
|
const tabDefs = [
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
{ value: 'pushed', label: 'Pushed' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' },
|
||||||
|
{ value: 'unblocked', label: 'Unblocked' },
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabs = computed(() =>
|
||||||
|
tabDefs.map(t => ({
|
||||||
|
...t,
|
||||||
|
count: ['pending', 'approved', 'pushed'].includes(t.value)
|
||||||
|
? candidates.value.filter(c => c.status === t.value).length
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredCandidates = computed(() =>
|
||||||
|
activeTab.value === 'all'
|
||||||
|
? candidates.value
|
||||||
|
: candidates.value.filter(c => c.status === activeTab.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupedCandidates = computed(() => {
|
||||||
|
const groups: Record<string, { name: string; candidates: Candidate[] }> = {}
|
||||||
|
for (const c of filteredCandidates.value) {
|
||||||
|
const key = c.source_device_ip ?? 'unknown'
|
||||||
|
if (!groups[key]) groups[key] = { name: c.source_device_name ?? key, candidates: [] }
|
||||||
|
groups[key]!.candidates.push(c)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/candidates`)
|
||||||
|
if (res.ok) candidates.value = (await res.json()).candidates
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
scanning.value = true
|
||||||
|
scanError.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/scan`, { method: 'POST' })
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail ?? res.statusText)
|
||||||
|
setTimeout(load, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
scanError.value = e instanceof Error ? e.message : 'Scan failed'
|
||||||
|
} finally {
|
||||||
|
scanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve(c: Candidate) { await updateStatus(c, 'approved') }
|
||||||
|
async function reject(c: Candidate) { await updateStatus(c, 'rejected') }
|
||||||
|
|
||||||
|
async function updateStatus(c: Candidate, status: string) {
|
||||||
|
errors.value[c.id] = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/candidates/${c.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail ?? res.statusText)
|
||||||
|
const updated: Candidate = await res.json()
|
||||||
|
candidates.value = candidates.value.map(x => x.id === c.id ? updated : x)
|
||||||
|
} catch (e) {
|
||||||
|
errors.value[c.id] = e instanceof Error ? e.message : 'Update failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function push(c: Candidate) {
|
||||||
|
errors.value[c.id] = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/push/${c.id}`, { method: 'POST' })
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail ?? res.statusText)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
errors.value[c.id] = e instanceof Error ? e.message : 'Push failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unblock(c: Candidate) {
|
||||||
|
errors.value[c.id] = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/push/${c.id}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail ?? res.statusText)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
errors.value[c.id] = e instanceof Error ? e.message : 'Unblock failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
connectionStatus.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/test`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
connectionStatus.value = data.ok === true
|
||||||
|
connectionDetail.value = data.ok
|
||||||
|
? `Pi-hole ${data.version} — ${data.domain_count} domains blocked`
|
||||||
|
: `Error: ${data.error ?? 'connection failed'}`
|
||||||
|
} catch {
|
||||||
|
connectionStatus.value = false
|
||||||
|
connectionDetail.value = 'Network error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEvidence(id: string) {
|
||||||
|
const s = new Set(expandedEvidence.value)
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id)
|
||||||
|
expandedEvidence.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
try { return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }
|
||||||
|
catch { return iso }
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTabRef(el: unknown, idx: number) {
|
||||||
|
if (el instanceof HTMLButtonElement) tabBtnRefs.value[idx] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabKey(e: KeyboardEvent, idx: number) {
|
||||||
|
let next = idx
|
||||||
|
if (e.key === 'ArrowRight') next = idx + 1
|
||||||
|
else if (e.key === 'ArrowLeft') next = idx - 1
|
||||||
|
else return
|
||||||
|
e.preventDefault()
|
||||||
|
const clamped = Math.max(0, Math.min(tabDefs.length - 1, next))
|
||||||
|
activeTab.value = tabDefs[clamped]!.value
|
||||||
|
tabBtnRefs.value[clamped]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
@ -175,6 +175,84 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pi-hole integration -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-text-primary text-sm font-semibold mb-1">Pi-hole Blocklist</h2>
|
||||||
|
<p class="text-text-dim text-xs mb-3">
|
||||||
|
Push flagged IoT destinations to your Pi-hole instance.
|
||||||
|
Find your API key (v5) or app password (v6) in Pi-hole's admin settings.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Pi-hole URL</label>
|
||||||
|
<input
|
||||||
|
v-model="prefs.pihole_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="http://pi.hole"
|
||||||
|
class="w-full bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Version</label>
|
||||||
|
<select
|
||||||
|
v-model="prefs.pihole_version"
|
||||||
|
class="bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="v6">v6</option>
|
||||||
|
<option value="v5">v5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">API Key / App Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="prefs.pihole_api_key"
|
||||||
|
:type="showPiholeKey ? 'text' : 'password'"
|
||||||
|
placeholder="Pi-hole app password or API key"
|
||||||
|
class="w-full bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPiholeKey = !showPiholeKey"
|
||||||
|
:aria-label="showPiholeKey ? 'Hide key' : 'Show key'"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-dim hover:text-text-primary text-xs"
|
||||||
|
>{{ showPiholeKey ? 'Hide' : 'Show' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Router Source IDs <span class="font-normal text-text-dim">(comma-separated)</span></label>
|
||||||
|
<input
|
||||||
|
v-model="prefs.router_source_ids"
|
||||||
|
type="text"
|
||||||
|
placeholder="router:syslog, router:firewall"
|
||||||
|
class="w-full bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Device Names <span class="font-normal">(JSON: {"ip": "name"})</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="prefs.device_names"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"192.168.1.45": "Samsung Projector", "192.168.1.67": "Belkin Switch 1"}'
|
||||||
|
class="w-full bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-dim font-mono focus:outline-none focus:border-accent transition-colors resize-y"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="savePihole"
|
||||||
|
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>Save Pi-hole settings</button>
|
||||||
|
<button
|
||||||
|
@click="testPihole"
|
||||||
|
class="px-3 py-2 text-sm rounded border border-surface-border text-text-muted hover:text-accent hover:border-accent transition-colors"
|
||||||
|
>Test connection</button>
|
||||||
|
<span v-if="piholeStatus" :class="piholeStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs">{{ piholeStatus.msg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="saveStatus"
|
v-if="saveStatus"
|
||||||
role="status"
|
role="status"
|
||||||
|
|
@ -207,12 +285,19 @@ interface Prefs {
|
||||||
llm_model: string
|
llm_model: string
|
||||||
llm_api_key: string
|
llm_api_key: string
|
||||||
severity_overrides: SeverityOverride[]
|
severity_overrides: SeverityOverride[]
|
||||||
|
pihole_url: string
|
||||||
|
pihole_version: string
|
||||||
|
pihole_api_key: string
|
||||||
|
router_source_ids: string
|
||||||
|
device_names: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', severity_overrides: [] })
|
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '' })
|
||||||
const saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
const saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
const showAddOverride = ref(false)
|
const showAddOverride = ref(false)
|
||||||
const showApiKey = ref(false)
|
const showApiKey = ref(false)
|
||||||
|
const showPiholeKey = ref(false)
|
||||||
|
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
|
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
|
||||||
const entryPointBtnRefs = ref<HTMLButtonElement[]>([])
|
const entryPointBtnRefs = ref<HTMLButtonElement[]>([])
|
||||||
|
|
||||||
|
|
@ -304,4 +389,34 @@ async function addOverride() {
|
||||||
showAddOverride.value = false
|
showAddOverride.value = false
|
||||||
await saveOverrides()
|
await saveOverrides()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function savePihole() {
|
||||||
|
saveStatus.value = null
|
||||||
|
try {
|
||||||
|
await patch({
|
||||||
|
pihole_url: prefs.value.pihole_url,
|
||||||
|
pihole_version: prefs.value.pihole_version,
|
||||||
|
pihole_api_key: prefs.value.pihole_api_key,
|
||||||
|
router_source_ids: prefs.value.router_source_ids,
|
||||||
|
device_names: prefs.value.device_names,
|
||||||
|
})
|
||||||
|
saveStatus.value = { ok: true, msg: 'Pi-hole settings saved' }
|
||||||
|
setTimeout(() => { saveStatus.value = null }, 2000)
|
||||||
|
} catch {
|
||||||
|
saveStatus.value = { ok: false, msg: 'Save failed — check server connection' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPihole() {
|
||||||
|
piholeStatus.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/blocklist/test`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
piholeStatus.value = data.ok
|
||||||
|
? { ok: true, msg: `Connected — Pi-hole ${data.version}, ${data.domain_count} domains` }
|
||||||
|
: { ok: false, msg: data.error ?? 'Connection failed' }
|
||||||
|
} catch {
|
||||||
|
piholeStatus.value = { ok: false, msg: 'Network error' }
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue