fix: final review fixes — port guard, network error handling, wizard back nav, tablist arrow keys, dialog focus trap

- wizard.py: wrap syslog_port int() in try/except to default 514 on non-numeric input
- ContextView: add try/catch to doDelete, doDeleteFact, addFact for network errors
- ContextView: arrow-key navigation for tablist (ArrowLeft/ArrowRight)
- DiagnoseView: arrow-key navigation for tablist (ArrowLeft/ArrowRight)
- WizardOverlay: reset current_step to last schema step when clicking 'Go back and edit'
- WizardOverlay: focus trap on Tab/Shift+Tab within dialog element
This commit is contained in:
pyr0ball 2026-05-13 17:40:40 -07:00
parent a047555031
commit 251109ae96
4 changed files with 78 additions and 24 deletions

View file

@ -115,7 +115,10 @@ def apply_session(db_path: Path, session: dict[str, Any]) -> dict[str, Any]:
syslog_answer = str(answers.get("syslog") or "No") syslog_answer = str(answers.get("syslog") or "No")
if syslog_answer.startswith("Yes"): if syslog_answer.startswith("Yes"):
try:
port = int(answers.get("syslog_port") or 514) port = int(answers.get("syslog_port") or 514)
except (ValueError, TypeError):
port = 514
sources.append({"type": "syslog", "id": f"syslog:{hostname}", "port": port}) sources.append({"type": "syslog", "id": f"syslog:{hostname}", "port": port})
return { return {

View file

@ -4,6 +4,7 @@
aria-modal="true" aria-modal="true"
:aria-labelledby="`wiz-heading-${currentStep?.step ?? 1}`" :aria-labelledby="`wiz-heading-${currentStep?.step ?? 1}`"
class="fixed inset-0 z-50 bg-surface overflow-y-auto" class="fixed inset-0 z-50 bg-surface overflow-y-auto"
:ref="(el) => { dialogRef = el as HTMLElement | null }"
> >
<div class="max-w-lg mx-auto py-12 px-6"> <div class="max-w-lg mx-auto py-12 px-6">
<!-- Progress heading --> <!-- Progress heading -->
@ -94,7 +95,7 @@
> >
{{ applying ? 'Saving…' : `Save ${factCount} facts and apply source config` }} {{ applying ? 'Saving…' : `Save ${factCount} facts and apply source config` }}
</button> </button>
<button @click="showSummary = false" class="text-sm text-text-dim hover:text-text-primary"> <button @click="showSummary = false; session = { ...session, current_step: schema.length }" class="text-sm text-text-dim hover:text-text-primary">
Go back and edit Go back and edit
</button> </button>
</div> </div>
@ -133,6 +134,7 @@ const applying = ref(false)
const applyError = ref<string | null>(null) const applyError = ref<string | null>(null)
const headingRef = ref<HTMLElement | null>(null) const headingRef = ref<HTMLElement | null>(null)
const summaryRef = ref<HTMLElement | null>(null) const summaryRef = ref<HTMLElement | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const totalSteps = computed(() => schema.value.length) const totalSteps = computed(() => schema.value.length)
const currentStep = computed(() => const currentStep = computed(() =>
@ -207,6 +209,19 @@ async function applyWizard() {
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (e.key === 'Tab') {
const focusable = dialogRef.value?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (!focusable || focusable.length === 0) return
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
if (e.key === 'Escape') emit('close') if (e.key === 'Escape') emit('close')
} }

View file

@ -21,7 +21,7 @@
<!-- Tabs --> <!-- Tabs -->
<div role="tablist" aria-label="Context sections" class="flex gap-1 mb-4 border-b border-surface-border"> <div role="tablist" aria-label="Context sections" class="flex gap-1 mb-4 border-b border-surface-border">
<button <button
v-for="tab in ['Documents', 'Facts']" v-for="(tab, idx) in ctxTabs"
:key="tab" :key="tab"
role="tab" role="tab"
:aria-selected="activeTab === tab" :aria-selected="activeTab === tab"
@ -29,6 +29,8 @@
:aria-controls="`ctx-panel-${tab}`" :aria-controls="`ctx-panel-${tab}`"
:tabindex="activeTab === tab ? 0 : -1" :tabindex="activeTab === tab ? 0 : -1"
@click="activeTab = tab" @click="activeTab = tab"
@keydown="handleTabKey($event, tab)"
:ref="(el) => { if (el) tabRefs[idx] = el as HTMLButtonElement }"
:class="[ :class="[
'px-4 py-2 text-sm transition-colors', 'px-4 py-2 text-sm transition-colors',
activeTab === tab activeTab === tab
@ -189,7 +191,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import DocUploadZone from '@/components/DocUploadZone.vue' import DocUploadZone from '@/components/DocUploadZone.vue'
import WizardOverlay from '@/components/WizardOverlay.vue' import WizardOverlay from '@/components/WizardOverlay.vue'
@ -199,6 +201,19 @@ interface Doc { id: string; filename: string; doc_type: string; file_size: numbe
interface Fact { id: string; category: string; key: string; value: string; source: string | null; created_at: string } interface Fact { id: string; category: string; key: string; value: string; source: string | null; created_at: string }
const activeTab = ref('Documents') const activeTab = ref('Documents')
const tabRefs = ref<HTMLButtonElement[]>([])
const ctxTabs = ['Documents', 'Facts']
function handleTabKey(e: KeyboardEvent, currentTab: string) {
const idx = ctxTabs.indexOf(currentTab)
let next = idx
if (e.key === 'ArrowRight') next = (idx + 1) % ctxTabs.length
else if (e.key === 'ArrowLeft') next = (idx - 1 + ctxTabs.length) % ctxTabs.length
else return
e.preventDefault()
activeTab.value = ctxTabs[next]!
nextTick(() => tabRefs.value[next]?.focus())
}
const showWizard = ref(false) const showWizard = ref(false)
const docs = ref<Doc[]>([]) const docs = ref<Doc[]>([])
const facts = ref<Fact[]>([]) const facts = ref<Fact[]>([])
@ -224,19 +239,24 @@ async function loadFacts() {
} }
async function doDelete(id: string) { async function doDelete(id: string) {
try {
const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' }) const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' })
if (r.ok) { confirmDelete.value = null; await loadDocs() } if (r.ok) { confirmDelete.value = null; await loadDocs() }
else error.value = 'Failed to delete document' else error.value = 'Failed to delete document'
} catch { error.value = 'Could not reach server' }
} }
async function doDeleteFact(id: string) { async function doDeleteFact(id: string) {
try {
const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' }) const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' })
if (r.ok) { confirmDeleteFact.value = null; await loadFacts() } if (r.ok) { confirmDeleteFact.value = null; await loadFacts() }
else error.value = 'Failed to delete fact' else error.value = 'Failed to delete fact'
} catch { error.value = 'Could not reach server' }
} }
async function addFact() { async function addFact() {
if (!newFact.value.key.trim() || !newFact.value.value.trim()) return if (!newFact.value.key.trim() || !newFact.value.value.trim()) return
try {
const r = await fetch(`${BASE}/api/context/facts`, { const r = await fetch(`${BASE}/api/context/facts`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -248,6 +268,7 @@ async function addFact() {
} else { } else {
error.value = 'Failed to add fact' error.value = 'Failed to add fact'
} }
} catch { error.value = 'Could not reach server' }
} }
function onWizardComplete() { function onWizardComplete() {

View file

@ -11,14 +11,16 @@
<!-- Tab toggle --> <!-- Tab toggle -->
<div role="tablist" aria-label="Diagnose mode" class="flex gap-1 mb-6 border-b border-surface-border"> <div role="tablist" aria-label="Diagnose mode" class="flex gap-1 mb-6 border-b border-surface-border">
<button <button
v-for="t in tabs" v-for="(t, idx) in tabs"
:key="t.key" :key="t.key"
role="tab" role="tab"
:aria-selected="activeTab === t.key" :aria-selected="activeTab === t.key"
:id="`tab-${t.key}`" :id="`tab-${t.key}`"
:aria-controls="`tabpanel-${t.key}`" :aria-controls="`tabpanel-${t.key}`"
:tabindex="activeTab === t.key ? 0 : -1" :tabindex="activeTab === t.key ? 0 : -1"
@click="activeTab = t.key" @click="activeTab = t.key as 'quick' | 'structured'"
@keydown="handleTabKey($event, t.key)"
:ref="(el) => { if (el) tabRefs[idx] = el as HTMLButtonElement }"
:class="[ :class="[
'px-4 py-2 text-sm transition-colors border-b-2 -mb-px', 'px-4 py-2 text-sm transition-colors border-b-2 -mb-px',
activeTab === t.key activeTab === t.key
@ -60,7 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { useRoute, RouterLink } from 'vue-router' import { useRoute, RouterLink } from 'vue-router'
import QuickCapture from '@/components/QuickCapture.vue' import QuickCapture from '@/components/QuickCapture.vue'
import IncidentForm from '@/components/IncidentForm.vue' import IncidentForm from '@/components/IncidentForm.vue'
@ -72,6 +74,19 @@ const tabs: { key: 'quick' | 'structured'; label: string }[] = [
] ]
const activeTab = ref<'quick' | 'structured'>('quick') const activeTab = ref<'quick' | 'structured'>('quick')
const createdLabel = ref('') const createdLabel = ref('')
const tabRefs = ref<HTMLButtonElement[]>([])
function handleTabKey(e: KeyboardEvent, currentKey: 'quick' | 'structured') {
const keys = tabs.map(t => t.key)
const idx = keys.indexOf(currentKey)
let next = idx
if (e.key === 'ArrowRight') next = (idx + 1) % keys.length
else if (e.key === 'ArrowLeft') next = (idx - 1 + keys.length) % keys.length
else return
e.preventDefault()
activeTab.value = keys[next] as 'quick' | 'structured'
nextTick(() => tabRefs.value[next]?.focus())
}
onMounted(() => { onMounted(() => {
if (route.query.tab === 'structured') activeTab.value = 'structured' if (route.query.tab === 'structured') activeTab.value = 'structured'