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:
2026-05-26 11:16:37 -04:00
parent 4e1f208a9f
commit 6de84d0d60
2 changed files with 104 additions and 22 deletions
@@ -4,14 +4,20 @@
<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>
<span>{{ displayTitle }}</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>
<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-actions>
<v-btn
@@ -27,12 +33,21 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { copyText } from '../../utils/clipboard.js'
const props = defineProps({
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' },
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'])
@@ -40,6 +55,47 @@ const emit = defineEmits(['update:modelValue'])
const copied = ref(false)
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) => {
if (!open) {
copied.value = false
@@ -47,13 +103,18 @@ watch(() => props.modelValue, (open) => {
}
})
function close() {
function close () {
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 {
await navigator.clipboard.writeText(props.message || '')
await copyText(text)
copied.value = true
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
@@ -64,19 +125,43 @@ async function onCopy() {
</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. */
/* Context panel: muted labels in vellum, crisp values in parchment. The
2-column key/value grid keeps rows visually scannable when there are
many fields (Task, Queue, Target, Duration, Started, Finished, Retries,
Worker, Celery ID, Args). */
.fc-err-context {
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 {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-family: 'JetBrains Mono', 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));
background: rgb(var(--v-theme-background));
color: rgb(var(--v-theme-on-surface));
padding: 12px 14px;
border-radius: 6px;
@@ -62,7 +62,7 @@
<td>
<button
type="button" class="fc-err-link"
@click="openError(r.error_type, r.error_message)"
@click="openError(r)"
:title="'Click for full error'"
>{{ r.error_type }}</button>
</td>
@@ -145,8 +145,7 @@
<ErrorDetailModal
v-model="showErrorModal"
:title="errorModalTitle"
:message="errorModalMessage"
:row="errorModalRow"
/>
</div>
</template>
@@ -163,12 +162,10 @@ import QueuesTable from './QueuesTable.vue'
// 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('')
const errorModalRow = ref(null)
function openError(title, message) {
errorModalTitle.value = title || 'Error details'
errorModalMessage.value = message || ''
function openError(row) {
errorModalRow.value = row
showErrorModal.value = true
}