feat(ui): ErrorDetailModal — context panel (task/queue/target/duration/started/retries/worker/celery-id/args) + contrast fix (background-token bg vs surface-variant pale-on-pale) + copyText helper for Copy button
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,20 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="d-flex align-center" style="gap: 12px;">
|
<v-card-title class="d-flex align-center" style="gap: 12px;">
|
||||||
<v-icon icon="mdi-alert-circle-outline" color="error" />
|
<v-icon icon="mdi-alert-circle-outline" color="error" />
|
||||||
<span>{{ title }}</span>
|
<span>{{ displayTitle }}</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn icon variant="text" size="small" @click="close">
|
<v-btn icon variant="text" size="small" @click="close">
|
||||||
<v-icon>mdi-close</v-icon>
|
<v-icon>mdi-close</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<pre class="fc-err-pre">{{ message || '(no error message)' }}</pre>
|
<dl v-if="contextRows.length" class="fc-err-context">
|
||||||
|
<template v-for="(row, idx) in contextRows" :key="idx">
|
||||||
|
<dt>{{ row[0] }}</dt>
|
||||||
|
<dd>{{ row[1] }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
<pre class="fc-err-pre">{{ displayMessage || '(no error message)' }}</pre>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -27,12 +33,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { copyText } from '../../utils/clipboard.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
|
// Legacy mode: pass title + message strings. Used by callers whose row
|
||||||
|
// shape lacks structured context (e.g. ImportTaskList where `error` is
|
||||||
|
// a plain string on the import_task row, not a TaskRun).
|
||||||
title: { type: String, default: 'Error details' },
|
title: { type: String, default: 'Error details' },
|
||||||
message: { type: String, default: '' },
|
message: { type: String, default: '' },
|
||||||
|
// Row mode: pass the full TaskRun-shaped dict from /api/system_activity.
|
||||||
|
// When set, displayTitle/displayMessage derive from the row and a context
|
||||||
|
// panel of task_name/queue/target/duration/etc. renders above the error.
|
||||||
|
row: { type: Object, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -40,6 +55,47 @@ const emit = defineEmits(['update:modelValue'])
|
|||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
let copiedTimer = null
|
let copiedTimer = null
|
||||||
|
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
if (props.row) return props.row.error_type || 'Error details'
|
||||||
|
return props.title
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayMessage = computed(() => {
|
||||||
|
if (props.row) return props.row.error_message || ''
|
||||||
|
return props.message
|
||||||
|
})
|
||||||
|
|
||||||
|
function _shortTaskName (name) {
|
||||||
|
if (!name) return ''
|
||||||
|
const parts = String(name).split('.')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatDuration (ms) {
|
||||||
|
if (ms == null) return null
|
||||||
|
if (ms < 1000) return `${ms} ms`
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
|
||||||
|
return `${(ms / 60_000).toFixed(1)} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextRows = computed(() => {
|
||||||
|
const r = props.row
|
||||||
|
if (!r) return []
|
||||||
|
const rows = []
|
||||||
|
if (r.task_name) rows.push(['Task', _shortTaskName(r.task_name)])
|
||||||
|
if (r.queue) rows.push(['Queue', r.queue])
|
||||||
|
if (r.target_id != null) rows.push(['Target', r.target_id])
|
||||||
|
const dur = _formatDuration(r.duration_ms)
|
||||||
|
if (dur != null) rows.push(['Duration', dur])
|
||||||
|
if (r.started_at) rows.push(['Started', r.started_at])
|
||||||
|
if (r.finished_at) rows.push(['Finished', r.finished_at])
|
||||||
|
if (r.retry_count) rows.push(['Retries', r.retry_count])
|
||||||
|
if (r.worker_hostname) rows.push(['Worker', r.worker_hostname])
|
||||||
|
if (r.celery_task_id) rows.push(['Celery ID', r.celery_task_id])
|
||||||
|
if (r.args_summary) rows.push(['Args', r.args_summary])
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
watch(() => props.modelValue, (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
copied.value = false
|
copied.value = false
|
||||||
@@ -47,13 +103,18 @@ watch(() => props.modelValue, (open) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
function close () {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCopy() {
|
async function onCopy () {
|
||||||
|
let text = displayMessage.value || ''
|
||||||
|
if (contextRows.value.length) {
|
||||||
|
const header = contextRows.value.map(([k, v]) => `${k}: ${v}`).join('\n')
|
||||||
|
text = `${header}\n\nError: ${displayTitle.value}\n${text}`
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(props.message || '')
|
await copyText(text)
|
||||||
copied.value = true
|
copied.value = true
|
||||||
if (copiedTimer) clearTimeout(copiedTimer)
|
if (copiedTimer) clearTimeout(copiedTimer)
|
||||||
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||||
@@ -64,19 +125,43 @@ async function onCopy() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* The full error often contains a SQLAlchemy statement + parameters
|
/* Context panel: muted labels in vellum, crisp values in parchment. The
|
||||||
block + multi-line traceback. Pre-wrap keeps long lines readable;
|
2-column key/value grid keeps rows visually scannable when there are
|
||||||
monospace + tabular layout keeps the structure scannable. The
|
many fields (Task, Queue, Target, Duration, Started, Finished, Retries,
|
||||||
max-height + overflow-auto prevents a 50-line traceback from
|
Worker, Celery ID, Args). */
|
||||||
pushing the Close button off-screen. Operator-flagged 2026-05-26:
|
.fc-err-context {
|
||||||
the prior :title="..." tooltip was unusable for content this long. */
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 4px;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fc-err-context dt {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fc-err-context dd {
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error pre block: high-contrast pairing. The page's `background` token
|
||||||
|
(obsidian #14171A) is darker than the modal card's `surface` (iron
|
||||||
|
#1E2228), so parchment text reads crisply against it. The prior pairing
|
||||||
|
used `surface-variant` which Vuetify auto-derives to a near-parchment
|
||||||
|
light value in this theme — pale-on-pale and unreadable. Operator-
|
||||||
|
flagged 2026-05-26 ("ui contrast is poor"). */
|
||||||
.fc-err-pre {
|
.fc-err-pre {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: rgb(var(--v-theme-surface-variant, 38 36 41));
|
background: rgb(var(--v-theme-background));
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button" class="fc-err-link"
|
type="button" class="fc-err-link"
|
||||||
@click="openError(r.error_type, r.error_message)"
|
@click="openError(r)"
|
||||||
:title="'Click for full error'"
|
:title="'Click for full error'"
|
||||||
>{{ r.error_type }}</button>
|
>{{ r.error_type }}</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -145,8 +145,7 @@
|
|||||||
|
|
||||||
<ErrorDetailModal
|
<ErrorDetailModal
|
||||||
v-model="showErrorModal"
|
v-model="showErrorModal"
|
||||||
:title="errorModalTitle"
|
:row="errorModalRow"
|
||||||
:message="errorModalMessage"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -163,12 +162,10 @@ import QueuesTable from './QueuesTable.vue'
|
|||||||
// rollback + traceback content rendered as a cramped browser tooltip
|
// rollback + traceback content rendered as a cramped browser tooltip
|
||||||
// you couldn't copy from or scroll within).
|
// you couldn't copy from or scroll within).
|
||||||
const showErrorModal = ref(false)
|
const showErrorModal = ref(false)
|
||||||
const errorModalTitle = ref('')
|
const errorModalRow = ref(null)
|
||||||
const errorModalMessage = ref('')
|
|
||||||
|
|
||||||
function openError(title, message) {
|
function openError(row) {
|
||||||
errorModalTitle.value = title || 'Error details'
|
errorModalRow.value = row
|
||||||
errorModalMessage.value = message || ''
|
|
||||||
showErrorModal.value = true
|
showErrorModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user