turnstone/web/src/views/SettingsView.vue
pyr0ball b8f766fb74 feat: SSH target manager — GUI editor for remote host configuration (#24)
- app/services/ssh_targets.py: full CRUD service with lazy paramiko
  import, key-path validation, permission warning, and test_connection
- app/db/schema.py: ssh_targets table (id, label, host, port, user,
  key_path, last_tested, last_ok, last_error, timestamps)
- app/rest.py: GET/POST /api/ssh-targets, PATCH/DELETE /{id},
  POST /{id}/test — key contents never returned in any response
- web/src/views/SettingsView.vue: Remote Hosts section with add/edit
  form, inline connection status badges, test-connection flow, delete
  with confirmation; new Set() pattern for reactive sshTesting state
- tests/test_ssh_targets.py: 22 tests — schema, CRUD, validation,
  key-warning, serialization, paramiko-absent path
2026-06-14 15:27:12 -07:00

709 lines
30 KiB
Vue

<template>
<div class="p-6 max-w-2xl mx-auto">
<div class="mb-6">
<h1 class="text-text-primary text-xl font-semibold mb-1">Settings</h1>
<p class="text-text-dim text-sm">
Turnstone preferences stored alongside the log database.
</p>
</div>
<div class="rounded border border-surface-border bg-surface-raised p-5 space-y-6">
<!-- Entry point -->
<div>
<h2 id="entry-point-label" class="text-text-primary text-sm font-semibold mb-1">Quick Capture Entry Point</h2>
<p class="text-text-dim text-xs mb-3">
Where the "describe it and search" input appears on every page.
</p>
<div role="radiogroup" aria-labelledby="entry-point-label" class="flex gap-3">
<button
v-for="(opt, idx) in entryPointOptions"
:key="opt.value"
:ref="(el) => collectEntryRef(el, idx)"
role="radio"
:aria-checked="prefs.entry_point_style === opt.value"
:tabindex="prefs.entry_point_style === opt.value ? 0 : -1"
@click="setEntryPoint(opt.value as 'topbar' | 'fab')"
@keydown="handleEntryPointKey($event, idx)"
:class="[
'flex-1 px-4 py-3 rounded border text-sm transition-colors text-left',
prefs.entry_point_style === opt.value
? 'border-accent bg-accent-muted text-accent'
: 'border-surface-border text-text-muted hover:text-text-primary hover:border-accent'
]"
>
<div class="font-medium">{{ opt.label }}</div>
<div class="text-xs text-text-dim mt-0.5">{{ opt.desc }}</div>
</button>
</div>
</div>
<!-- LLM config -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">LLM Reasoning</h2>
<p class="text-text-dim text-xs mb-3">
LLM endpoint for plain-language diagnoses. Works with local Ollama or a remote
cf-orch coordinator (e.g. <span class="font-mono">https://orch.circuitforge.tech</span>).
Leave blank to disable.
</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-text-dim mb-1">LLM Endpoint URL</label>
<input
v-model="prefs.llm_url"
type="text"
placeholder="http://localhost:11434"
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">Model</label>
<input
v-model="prefs.llm_model"
type="text"
placeholder="llama3.1:8b"
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">
API Key
<span class="text-text-dim font-normal">(optional required for cf-orch remote inference)</span>
</label>
<div class="relative">
<input
v-model="prefs.llm_api_key"
:type="showApiKey ? 'text' : 'password'"
placeholder="Leave blank for local Ollama"
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="showApiKey = !showApiKey"
:aria-label="showApiKey ? 'Hide API key' : 'Show API key'"
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-dim hover:text-text-primary text-xs"
>{{ showApiKey ? 'Hide' : 'Show' }}</button>
</div>
</div>
<button
@click="saveLlm"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity"
>
Save LLM settings
</button>
</div>
</div>
<!-- Diagnosis detail level -->
<div>
<h2 id="tech-level-label" class="text-text-primary text-sm font-semibold mb-1">Diagnosis Detail Level</h2>
<p class="text-text-dim text-xs mb-3">
Controls how the LLM formats its diagnosis affects the level of technical detail and output structure.
</p>
<div role="radiogroup" aria-labelledby="tech-level-label" class="flex flex-col sm:flex-row gap-3">
<button
v-for="(opt, idx) in techLevelOptions"
:key="opt.value"
:ref="(el) => collectTechLevelRef(el, idx)"
role="radio"
:aria-checked="prefs.tech_level === opt.value"
:tabindex="prefs.tech_level === opt.value ? 0 : -1"
@click="setTechLevel(opt.value)"
@keydown="handleTechLevelKey($event, idx)"
:class="[
'flex-1 px-4 py-3 rounded border text-sm transition-colors text-left',
prefs.tech_level === opt.value
? 'border-accent bg-accent-muted text-accent'
: 'border-surface-border text-text-muted hover:text-text-primary hover:border-accent'
]"
>
<div class="font-medium">{{ opt.label }}</div>
<div class="text-xs text-text-dim mt-0.5">{{ opt.desc }}</div>
</button>
</div>
</div>
<!-- Severity overrides -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">Severity Overrides</h2>
<p class="text-text-dim text-xs mb-3">
Regex rules applied at query time entries that match are shown at the overridden severity on the dashboard. DB values are unchanged.
</p>
<div class="space-y-2 mb-3">
<div
v-for="(rule, i) in prefs.severity_overrides"
:key="i"
class="flex items-start gap-3 rounded border border-surface-border bg-surface p-3"
>
<button
role="switch"
:aria-checked="rule.enabled"
:aria-label="`${rule.name || 'Override'} rule enabled`"
@click="toggleOverride(i)"
:class="[
'mt-0.5 w-9 h-5 rounded-full flex-shrink-0 transition-colors relative',
rule.enabled ? 'bg-accent' : 'bg-surface-border'
]"
:title="rule.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'"
>
<span :class="['absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform', rule.enabled ? 'translate-x-4' : 'translate-x-0']"></span>
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm text-text-primary font-medium">{{ rule.name }}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-surface-border text-text-dim"> {{ rule.override_severity }}</span>
</div>
<p class="text-xs text-text-dim font-mono mt-0.5 truncate">{{ rule.pattern }}</p>
</div>
<button
@click="deleteOverride(i)"
:aria-label="`Delete override rule: ${rule.name || 'unnamed'}`"
class="text-text-dim hover:text-sev-error text-xs flex-shrink-0 mt-0.5"
title="Delete rule"
></button>
</div>
<p v-if="!prefs.severity_overrides?.length" class="text-text-dim text-xs">No rules configured.</p>
</div>
<!-- Add rule form -->
<div v-if="!showAddOverride">
<button
@click="showAddOverride = true"
class="text-accent text-xs hover:underline"
>+ Add override rule</button>
</div>
<div v-else class="rounded border border-surface-border bg-surface p-3 space-y-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label class="block text-xs text-text-dim mb-1">Name</label>
<input v-model="newRule.name" type="text" placeholder="e.g. PAM auth noise"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Override severity</label>
<select v-model="newRule.override_severity"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:border-accent">
<option>WARN</option>
<option>INFO</option>
<option>DEBUG</option>
</select>
</div>
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Pattern (regex)</label>
<input v-model="newRule.pattern" type="text" placeholder="e.g. pam_unix.*auth"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div class="flex gap-2">
<button @click="addOverride"
class="px-3 py-1.5 bg-accent text-surface text-xs rounded font-medium hover:opacity-90 transition-opacity">
Add
</button>
<button @click="showAddOverride = false" class="text-text-dim hover:text-text-primary text-xs">Cancel</button>
</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>
<!-- Remote Hosts (SSH targets) -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">Remote Hosts</h2>
<p class="text-text-dim text-xs mb-3">
SSH hosts to pull logs from. Private keys are stored as path references only — key contents are never read or transmitted.
</p>
<!-- Target list -->
<div v-if="sshTargets.length > 0" class="space-y-2 mb-3">
<div
v-for="t in sshTargets"
:key="t.id"
class="rounded border border-surface-border bg-surface p-3"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm text-text-primary font-medium">{{ t.label }}</span>
<span class="font-mono text-xs text-text-dim">{{ t.user }}@{{ t.host }}:{{ t.port }}</span>
<!-- Connection status badge -->
<span
v-if="t.last_ok === true"
class="text-[10px] px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 border border-green-800/40"
>Connected</span>
<span
v-else-if="t.last_ok === false"
class="text-[10px] px-1.5 py-0.5 rounded bg-red-900/30 text-sev-error border border-red-800/40"
:title="t.last_error ?? ''"
>Unreachable</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded bg-surface-raised text-text-dim border border-surface-border"
>Not tested</span>
</div>
<p class="text-xs text-text-dim font-mono mt-0.5 truncate">{{ t.key_path }}</p>
<p v-if="t.key_warning" class="text-xs text-yellow-400 mt-0.5">⚠ {{ t.key_warning }}</p>
<!-- Test result (persistent inline, not a toast) -->
<p
v-if="sshTestResults[t.id]"
class="text-xs mt-1"
:class="sshTestResults[t.id]!.ok ? 'text-green-400' : 'text-sev-error'"
>
{{ sshTestResults[t.id]!.ok ? 'Connection OK' : sshTestResults[t.id]!.error }}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="testSshTarget(t.id)"
:disabled="sshTesting.has(t.id)"
class="text-xs text-text-dim hover:text-accent transition-colors px-2 py-1 rounded hover:bg-surface disabled:opacity-40"
>{{ sshTesting.has(t.id) ? 'Testing' : 'Test' }}</button>
<button
@click="editSshTarget(t)"
class="text-xs text-text-dim hover:text-accent transition-colors px-2 py-1 rounded hover:bg-surface"
>Edit</button>
<button
@click="deleteSshTarget(t.id, t.label)"
class="text-xs text-text-dim hover:text-sev-error transition-colors px-2 py-1 rounded hover:bg-surface"
>Delete</button>
</div>
</div>
</div>
</div>
<p v-else class="text-text-dim text-xs mb-3">
No remote hosts configured. Add an SSH host to pull logs from remote machines without manual file exports.
</p>
<!-- Add / Edit form -->
<div v-if="sshForm.open" class="rounded border border-surface-border bg-surface p-3 space-y-3 mb-3">
<h3 class="text-text-primary text-xs font-medium">{{ sshForm.editId ? 'Edit host' : 'Add remote host' }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-text-dim mb-1">Display name</label>
<input v-model="sshForm.label" type="text" placeholder="e.g. rack-server-01"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Host</label>
<input v-model="sshForm.host" type="text" placeholder="192.168.1.10 or server.example.com"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Port</label>
<input v-model.number="sshForm.port" type="number" min="1" max="65535" placeholder="22"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Username</label>
<input v-model="sshForm.user" type="text" placeholder="root or alan"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">SSH key path</label>
<input v-model="sshForm.key_path" type="text" placeholder="~/.ssh/id_ed25519"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
<p v-if="sshFormError" class="text-sev-error text-xs">{{ sshFormError }}</p>
<div class="flex gap-2">
<button @click="saveSshTarget" :disabled="sshFormSaving"
class="px-3 py-1.5 bg-accent text-surface text-xs rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
{{ sshFormSaving ? 'Saving' : (sshForm.editId ? 'Save changes' : 'Add host') }}
</button>
<button @click="closeSshForm" class="text-text-dim hover:text-text-primary text-xs">Cancel</button>
</div>
</div>
<button v-if="!sshForm.open" @click="sshForm.open = true" class="text-accent text-xs hover:underline">
+ Add remote host
</button>
</div>
<p
v-if="saveStatus"
role="status"
aria-live="polite"
class="text-xs"
:class="saveStatus.ok ? 'text-green-400' : 'text-sev-error'"
>
{{ saveStatus.msg }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import type { ComponentPublicInstance } from 'vue'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
interface SeverityOverride {
name: string
pattern: string
override_severity: string
enabled: boolean
}
interface Prefs {
entry_point_style: 'topbar' | 'fab'
llm_url: string
llm_model: string
llm_api_key: string
tech_level: 'homelab' | 'sysadmin' | 'executive'
severity_overrides: SeverityOverride[]
pihole_url: string
pihole_version: string
pihole_api_key: string
router_source_ids: string
device_names: string
}
interface SshTarget {
id: string
label: string
host: string
port: number
user: string
key_path: string
last_tested: string | null
last_ok: boolean | null
last_error: string | null
key_warning?: string | null
}
const techLevelOptions: { value: 'homelab' | 'sysadmin' | 'executive'; label: string; desc: string }[] = [
{ value: 'homelab', label: 'Homelab', desc: 'Clear explanations spells out service names and why each action helps' },
{ value: 'sysadmin', label: 'Sysadmin', desc: 'Technical, structured 5-section diagnosis with commands and confidence scores' },
{ value: 'executive', label: 'Executive', desc: 'Plain English: what broke, who was affected, and what action is needed' },
]
const techLevelBtnRefs = ref<HTMLButtonElement[]>([])
function collectTechLevelRef(el: any, idx: number) {
if (el instanceof HTMLButtonElement) techLevelBtnRefs.value[idx] = el
}
function handleTechLevelKey(e: KeyboardEvent, idx: number) {
let next = idx
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = idx + 1
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = idx - 1
else return
e.preventDefault()
const clamped = Math.max(0, Math.min(techLevelOptions.length - 1, next))
setTechLevel(techLevelOptions[clamped]!.value)
const nextBtn = techLevelBtnRefs.value[clamped]
if (nextBtn) nextBtn.focus()
}
async function setTechLevel(level: 'homelab' | 'sysadmin' | 'executive') {
saveStatus.value = null
try {
await patch({ tech_level: level })
saveStatus.value = { ok: true, msg: 'Saved' }
setTimeout(() => { saveStatus.value = null }, 2000)
} catch {
saveStatus.value = { ok: false, msg: 'Save failed check server connection' }
}
}
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', 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 showAddOverride = 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 })
// SSH targets
const sshTargets = ref<SshTarget[]>([])
const sshTestResults = ref<Record<string, { ok: boolean; error: string | null }>>({})
const sshTesting = ref<Set<string>>(new Set())
const sshFormSaving = ref(false)
const sshFormError = ref<string | null>(null)
const sshForm = ref({
open: false,
editId: null as string | null,
label: '',
host: '',
port: 22,
user: '',
key_path: '',
})
const entryPointBtnRefs = ref<HTMLButtonElement[]>([])
const entryPointOptions = [
{ value: 'topbar', label: 'Top bar', desc: 'Persistent input bar below the nav on every page' },
{ value: 'fab', label: 'FAB', desc: 'Floating action button in the bottom-right corner' },
]
function collectEntryRef(el: any, idx: number) {
if (el instanceof HTMLButtonElement) entryPointBtnRefs.value[idx] = el
}
function handleEntryPointKey(e: KeyboardEvent, idx: number) {
let next = idx
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = idx + 1
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = idx - 1
else return
e.preventDefault()
const clamped = Math.max(0, Math.min(entryPointOptions.length - 1, next))
setEntryPoint(entryPointOptions[clamped]!.value as 'topbar' | 'fab')
const nextBtn = entryPointBtnRefs.value[clamped]
if (nextBtn) nextBtn.focus()
}
onMounted(async () => {
try {
const res = await fetch(`${BASE}/api/settings`)
if (res.ok) prefs.value = await res.json()
} catch { /* non-critical — defaults stay */ }
await loadSshTargets()
})
async function patch(body: Partial<Prefs>) {
const res = await fetch(`${BASE}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await res.text())
prefs.value = await res.json()
}
async function setEntryPoint(style: 'topbar' | 'fab') {
saveStatus.value = null
try {
await patch({ entry_point_style: style })
saveStatus.value = { ok: true, msg: 'Saved' }
setTimeout(() => { saveStatus.value = null }, 2000)
} catch {
saveStatus.value = { ok: false, msg: 'Save failed check server connection' }
}
}
async function saveLlm() {
saveStatus.value = null
try {
await patch({ llm_url: prefs.value.llm_url, llm_model: prefs.value.llm_model, llm_api_key: prefs.value.llm_api_key })
saveStatus.value = { ok: true, msg: 'LLM settings saved' }
setTimeout(() => { saveStatus.value = null }, 2000)
} catch {
saveStatus.value = { ok: false, msg: 'Save failed check server connection' }
}
}
async function saveOverrides() {
try {
await patch({ severity_overrides: prefs.value.severity_overrides })
saveStatus.value = { ok: true, msg: 'Overrides saved' }
setTimeout(() => { saveStatus.value = null }, 2000)
} catch {
saveStatus.value = { ok: false, msg: 'Save failed check server connection' }
}
}
async function toggleOverride(i: number) {
prefs.value = {
...prefs.value,
severity_overrides: prefs.value.severity_overrides.map((r, idx) =>
idx === i ? { ...r, enabled: !r.enabled } : r
),
}
await saveOverrides()
}
async function deleteOverride(i: number) {
prefs.value = {
...prefs.value,
severity_overrides: prefs.value.severity_overrides.filter((_, idx) => idx !== i),
}
await saveOverrides()
}
async function addOverride() {
if (!newRule.value.name.trim() || !newRule.value.pattern.trim()) return
prefs.value.severity_overrides.push({ ...newRule.value })
newRule.value = { name: '', pattern: '', override_severity: 'WARN', enabled: true }
showAddOverride.value = false
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' }
}
}
// --- SSH target management ---
async function loadSshTargets() {
try {
const res = await fetch(`${BASE}/api/ssh-targets`)
if (res.ok) sshTargets.value = await res.json()
} catch { /* non-critical */ }
}
async function testSshTarget(id: string) {
sshTesting.value = new Set([...sshTesting.value, id])
try {
const res = await fetch(`${BASE}/api/ssh-targets/${id}/test`, { method: 'POST' })
const data = await res.json()
sshTestResults.value = { ...sshTestResults.value, [id]: { ok: data.ok, error: data.error ?? null } }
// Refresh list so last_ok badge updates
await loadSshTargets()
} catch {
sshTestResults.value = { ...sshTestResults.value, [id]: { ok: false, error: 'Network error' } }
} finally {
const next = new Set(sshTesting.value)
next.delete(id)
sshTesting.value = next
}
}
function editSshTarget(t: SshTarget) {
sshFormError.value = null
sshForm.value = { open: true, editId: t.id, label: t.label, host: t.host, port: t.port, user: t.user, key_path: t.key_path }
}
async function deleteSshTarget(id: string, label: string) {
if (!confirm(`Delete remote host "${label}"?`)) return
try {
await fetch(`${BASE}/api/ssh-targets/${id}`, { method: 'DELETE' })
await loadSshTargets()
} catch { /* ignore */ }
}
async function saveSshTarget() {
const f = sshForm.value
if (!f.label.trim() || !f.host.trim() || !f.user.trim() || !f.key_path.trim()) {
sshFormError.value = 'All fields are required'
return
}
sshFormSaving.value = true
sshFormError.value = null
try {
const url = f.editId ? `${BASE}/api/ssh-targets/${f.editId}` : `${BASE}/api/ssh-targets`
const method = f.editId ? 'PATCH' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: f.label, host: f.host, port: f.port, user: f.user, key_path: f.key_path }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Save failed' }))
sshFormError.value = err.detail ?? 'Save failed'
return
}
closeSshForm()
await loadSshTargets()
} catch {
sshFormError.value = 'Network error'
} finally {
sshFormSaving.value = false
}
}
function closeSshForm() {
sshForm.value = { open: false, editId: null, label: '', host: '', port: 22, user: '', key_path: '' }
sshFormError.value = null
}
</script>