feat(ui): ErrorDetailModal — click error → flat-text modal with copy button (replaces unusable :title tooltip for multi-line SQLAlchemy tracebacks) — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="900"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 12px;">
|
||||||
|
<v-icon icon="mdi-alert-circle-outline" color="error" />
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn icon variant="text" size="small" @click="close">
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<pre class="fc-err-pre">{{ message || '(no error message)' }}</pre>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-btn
|
||||||
|
variant="text" rounded="pill" size="small"
|
||||||
|
:prepend-icon="copied ? 'mdi-check' : 'mdi-content-copy'"
|
||||||
|
@click="onCopy"
|
||||||
|
>{{ copied ? 'Copied' : 'Copy' }}</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" rounded="pill" @click="close">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: 'Error details' },
|
||||||
|
message: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
let copiedTimer = null
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
copied.value = false
|
||||||
|
if (copiedTimer) { clearTimeout(copiedTimer); copiedTimer = null }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.message || '')
|
||||||
|
copied.value = true
|
||||||
|
if (copiedTimer) clearTimeout(copiedTimer)
|
||||||
|
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* The full error often contains a SQLAlchemy statement + parameters
|
||||||
|
block + multi-line traceback. Pre-wrap keeps long lines readable;
|
||||||
|
monospace + tabular layout keeps the structure scannable. The
|
||||||
|
max-height + overflow-auto prevents a 50-line traceback from
|
||||||
|
pushing the Close button off-screen. Operator-flagged 2026-05-26:
|
||||||
|
the prior :title="..." tooltip was unusable for content this long. */
|
||||||
|
.fc-err-pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: rgb(var(--v-theme-surface-variant, 38 36 41));
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -45,9 +45,11 @@
|
|||||||
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
||||||
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
||||||
<template #item.error="{ item }">
|
<template #item.error="{ item }">
|
||||||
<span v-if="item.error" :title="item.error" class="text-caption">
|
<button
|
||||||
{{ shorten(item.error, 60) }}
|
v-if="item.error" type="button" class="fc-err-link text-caption"
|
||||||
</span>
|
@click="openError(`Task ${item.id} failed`, item.error)"
|
||||||
|
title="Click for full error"
|
||||||
|
>{{ shorten(item.error, 60) }}</button>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table-virtual>
|
</v-data-table-virtual>
|
||||||
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
||||||
@@ -100,16 +102,35 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<ErrorDetailModal
|
||||||
|
v-model="showErrorModal"
|
||||||
|
:title="errorModalTitle"
|
||||||
|
:message="errorModalMessage"
|
||||||
|
/>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||||
|
|
||||||
const store = useImportStore()
|
const store = useImportStore()
|
||||||
const statusFilter = ref(null)
|
const statusFilter = ref(null)
|
||||||
const clearDialog = ref(false)
|
const clearDialog = ref(false)
|
||||||
|
// Click-to-open modal for full error text (operator-flagged 2026-05-26
|
||||||
|
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
|
||||||
|
// tracebacks into an unusable popup with no copy-paste affordance).
|
||||||
|
const showErrorModal = ref(false)
|
||||||
|
const errorModalTitle = ref('')
|
||||||
|
const errorModalMessage = ref('')
|
||||||
|
|
||||||
|
function openError(title, message) {
|
||||||
|
errorModalTitle.value = title
|
||||||
|
errorModalMessage.value = message || ''
|
||||||
|
showErrorModal.value = true
|
||||||
|
}
|
||||||
const clearAgeDays = ref(7)
|
const clearAgeDays = ref(7)
|
||||||
const clearStuckDialog = ref(false)
|
const clearStuckDialog = ref(false)
|
||||||
|
|
||||||
@@ -179,3 +200,21 @@ async function onClearStuckConfirm() {
|
|||||||
clearStuckDialog.value = false
|
clearStuckDialog.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-err-link {
|
||||||
|
/* Truncated error preview as a clickable button — opens
|
||||||
|
ErrorDetailModal with the full text. Inherits the row's font
|
||||||
|
sizing so it doesn't visually drift from the prior tooltip-bearing
|
||||||
|
span. */
|
||||||
|
color: rgb(var(--v-theme-error, 220 80 80));
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-err-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -59,8 +59,12 @@
|
|||||||
<td>{{ r.queue }}</td>
|
<td>{{ r.queue }}</td>
|
||||||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||||||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||||||
<td class="fc-err" :title="r.error_message">
|
<td>
|
||||||
{{ r.error_type }}
|
<button
|
||||||
|
type="button" class="fc-err-link"
|
||||||
|
@click="openError(r.error_type, r.error_message)"
|
||||||
|
:title="'Click for full error'"
|
||||||
|
>{{ r.error_type }}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!filteredFailures.length">
|
<tr v-if="!filteredFailures.length">
|
||||||
@@ -138,6 +142,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<ErrorDetailModal
|
||||||
|
v-model="showErrorModal"
|
||||||
|
:title="errorModalTitle"
|
||||||
|
:message="errorModalMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -145,8 +155,23 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||||
|
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||||
import QueuesTable from './QueuesTable.vue'
|
import QueuesTable from './QueuesTable.vue'
|
||||||
|
|
||||||
|
// Click-to-open modal for full error text. Replaces the unusable
|
||||||
|
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
|
||||||
|
// rollback + traceback content rendered as a cramped browser tooltip
|
||||||
|
// you couldn't copy from or scroll within).
|
||||||
|
const showErrorModal = ref(false)
|
||||||
|
const errorModalTitle = ref('')
|
||||||
|
const errorModalMessage = ref('')
|
||||||
|
|
||||||
|
function openError(title, message) {
|
||||||
|
errorModalTitle.value = title || 'Error details'
|
||||||
|
errorModalMessage.value = message || ''
|
||||||
|
showErrorModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const store = useSystemActivityStore()
|
const store = useSystemActivityStore()
|
||||||
|
|
||||||
const filterQueue = ref(null)
|
const filterQueue = ref(null)
|
||||||
@@ -276,5 +301,18 @@ function formatRelative(iso) {
|
|||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
}
|
}
|
||||||
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
|
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
|
||||||
|
.fc-err-link {
|
||||||
|
/* Styled as a text-only button so the error_type cell stays
|
||||||
|
visually identical to the prior tooltip-bearing row, but is
|
||||||
|
now a real clickable target with hover affordance. */
|
||||||
|
color: rgb(var(--v-theme-error, 220 80 80));
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-err-link:hover { text-decoration: underline; }
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user