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:
parent
a047555031
commit
251109ae96
4 changed files with 78 additions and 24 deletions
|
|
@ -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"):
|
||||||
port = int(answers.get("syslog_port") or 514)
|
try:
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,30 +239,36 @@ async function loadFacts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doDelete(id: string) {
|
async function doDelete(id: string) {
|
||||||
const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' })
|
try {
|
||||||
if (r.ok) { confirmDelete.value = null; await loadDocs() }
|
const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' })
|
||||||
else error.value = 'Failed to delete document'
|
if (r.ok) { confirmDelete.value = null; await loadDocs() }
|
||||||
|
else error.value = 'Failed to delete document'
|
||||||
|
} catch { error.value = 'Could not reach server' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doDeleteFact(id: string) {
|
async function doDeleteFact(id: string) {
|
||||||
const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' })
|
try {
|
||||||
if (r.ok) { confirmDeleteFact.value = null; await loadFacts() }
|
const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' })
|
||||||
else error.value = 'Failed to delete fact'
|
if (r.ok) { confirmDeleteFact.value = null; await loadFacts() }
|
||||||
|
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
|
||||||
const r = await fetch(`${BASE}/api/context/facts`, {
|
try {
|
||||||
method: 'POST',
|
const r = await fetch(`${BASE}/api/context/facts`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ ...newFact.value, source: 'manual' }),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
body: JSON.stringify({ ...newFact.value, source: 'manual' }),
|
||||||
if (r.ok) {
|
})
|
||||||
newFact.value = { category: 'service', key: '', value: '' }
|
if (r.ok) {
|
||||||
await loadFacts()
|
newFact.value = { category: 'service', key: '', value: '' }
|
||||||
} else {
|
await loadFacts()
|
||||||
error.value = 'Failed to add fact'
|
} else {
|
||||||
}
|
error.value = 'Failed to add fact'
|
||||||
|
}
|
||||||
|
} catch { error.value = 'Could not reach server' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWizardComplete() {
|
function onWizardComplete() {
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue