9deebfa133
The icon+title v-card-title heading (d-flex align-center + gap + <v-icon size=small> + <span>) was hand-rolled identically in 13 cards/dialogs (15 heading instances). Consolidate to <CardHeading icon title> (components/common) with an iconColor prop (error headings) and a default slot for trailing content (spacer+actions, inline status chip). Adopted everywhere the pattern appears — all-or-nothing per the hardened DRY process. Over-DRY guard: plain text-only <v-card-title> one-liners are NOT this pattern and stay; DownloadDetailModal leads with a status CHIP (not an icon), a different concept, left alone. §8b: the only remaining d-flex align-center v-card-title is that intentional variant. Catalog updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
5.5 KiB
Vue
176 lines
5.5 KiB
Vue
<template>
|
|
<v-dialog :model-value="modelValue" max-width="900"
|
|
@update:model-value="$emit('update:modelValue', $event)">
|
|
<v-card>
|
|
<CardHeading
|
|
icon="mdi-alert-circle-outline" icon-color="error"
|
|
:title="displayTitle"
|
|
>
|
|
<v-spacer />
|
|
<v-btn icon variant="text" size="small" @click="close">
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
</CardHeading>
|
|
<v-card-text>
|
|
<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
|
|
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 { toast } from '../../utils/toast.js'
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
import { copyText } from '../../utils/clipboard.js'
|
|
import CardHeading from './CardHeading.vue'
|
|
|
|
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'])
|
|
|
|
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
|
|
if (copiedTimer) { clearTimeout(copiedTimer); copiedTimer = null }
|
|
}
|
|
})
|
|
|
|
function close () {
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
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 copyText(text)
|
|
copied.value = true
|
|
if (copiedTimer) clearTimeout(copiedTimer)
|
|
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
|
} catch (e) {
|
|
toast({ text: `Copy failed: ${e.message}`, type: 'error' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 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: '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-background));
|
|
color: rgb(var(--v-theme-on-surface));
|
|
padding: 12px 14px;
|
|
border-radius: 6px;
|
|
max-height: 60vh;
|
|
overflow: auto;
|
|
margin: 0;
|
|
}
|
|
</style>
|