eebc8e2413
- utils/date.js: add formatRelative(iso, {future,nullText}); migrate 6 sites
(SourceRow, SubscriptionsTab, SourceHealthDot, SchedulerStatusBar +
thin adapters in BackupRunsTable/SystemActivityTab for their '—' null text).
PostCard (30d->absolute) and CredentialCard (mo/y buckets) intentionally
keep bespoke formatters.
- utils/toast.js: toast(opts) wraps the globalThis.window?.__fcToast?.(...)
incantation; migrate 63 call sites across 24 files.
- utils/downloadStatus.js: single source for the download-event status enum
-> label/color/icon; collapse the 3 duplicate maps (DownloadStatChips,
DownloadsFilterPopover, DownloadEventRow).
Net -33 lines. Platform metadata was already centralized in platformColor.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
310 lines
9.5 KiB
Vue
310 lines
9.5 KiB
Vue
<template>
|
||
<div class="fc-activity">
|
||
<!-- Queues + workers pane -->
|
||
<v-card class="mb-4">
|
||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||
<v-icon icon="mdi-pulse" size="small" />
|
||
<span>Queues + workers</span>
|
||
<v-spacer />
|
||
<span class="text-caption fc-muted">
|
||
updated {{ formatRelative(store.queues?.fetched_at) }}
|
||
</span>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<QueuesTable
|
||
:queues="store.queues"
|
||
:workers="store.workers"
|
||
:recent-minute="store.recentMinute"
|
||
/>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Recent failures pane -->
|
||
<v-card class="mb-4">
|
||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||
<v-icon icon="mdi-alert-circle-outline" size="small" />
|
||
<span>Recent failures (last 24h)</span>
|
||
<v-spacer />
|
||
<span class="text-caption fc-muted">
|
||
{{ failureCount }} failures
|
||
</span>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<div v-if="errorTypes.length" class="mb-3 fc-pills">
|
||
<v-chip
|
||
v-for="t in errorTypes" :key="t.name"
|
||
:color="t.name === filterErrorType ? 'accent' : undefined"
|
||
size="small" variant="tonal" closable
|
||
@click="toggleErrorTypeFilter(t.name)"
|
||
@click:close="toggleErrorTypeFilter(t.name)"
|
||
>
|
||
{{ t.name }} × {{ t.count }}
|
||
</v-chip>
|
||
</div>
|
||
<v-table density="compact">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Queue</th>
|
||
<th>Task</th>
|
||
<th>Target</th>
|
||
<th>Error</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in filteredFailures" :key="r.id">
|
||
<td class="fc-tabular" :title="r.finished_at">
|
||
{{ formatRelative(r.finished_at) }}
|
||
</td>
|
||
<td>{{ r.queue }}</td>
|
||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||
<td>
|
||
<button
|
||
type="button" class="fc-err-link"
|
||
@click="openError(r)"
|
||
:title="'Click for full error'"
|
||
>{{ r.error_type }}</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!filteredFailures.length">
|
||
<td colspan="5" class="text-center fc-muted py-4">
|
||
No failures in the last 24h.
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- All activity pane -->
|
||
<v-card>
|
||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||
<v-icon icon="mdi-format-list-bulleted" size="small" />
|
||
<span>All recent activity</span>
|
||
<v-spacer />
|
||
<v-select
|
||
v-model="filterQueue"
|
||
:items="queueOptions" density="compact" hide-details
|
||
style="max-width: 180px;"
|
||
@update:model-value="onFilterChange"
|
||
/>
|
||
<v-select
|
||
v-model="filterStatus"
|
||
:items="statusOptions" density="compact" hide-details
|
||
style="max-width: 180px;"
|
||
@update:model-value="onFilterChange"
|
||
/>
|
||
<v-btn
|
||
variant="text" size="small" rounded="pill"
|
||
@click="onRefresh"
|
||
>
|
||
<v-icon start size="small">mdi-refresh</v-icon>
|
||
Refresh
|
||
</v-btn>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<v-table density="compact">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Time</th>
|
||
<th>Queue</th>
|
||
<th>Task</th>
|
||
<th>Target</th>
|
||
<th class="text-right">Duration</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in store.runs" :key="r.id">
|
||
<td><v-icon size="small" :color="statusColor(r.status)">{{ statusIcon(r.status) }}</v-icon></td>
|
||
<td class="fc-tabular" :title="r.started_at">{{ formatRelative(r.started_at) }}</td>
|
||
<td>{{ r.queue }}</td>
|
||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||
<td class="text-right fc-tabular">{{ formatDuration(r.duration_ms) }}</td>
|
||
<td>{{ r.status }}</td>
|
||
</tr>
|
||
<tr v-if="!store.runs.length && !store.loading.runs">
|
||
<td colspan="7" class="text-center fc-muted py-4">
|
||
No activity yet.
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
<div v-if="store.runsHasMore" class="d-flex justify-center py-3">
|
||
<v-btn
|
||
variant="text" size="small"
|
||
:loading="store.loading.runs"
|
||
@click="onLoadMore"
|
||
>Load more</v-btn>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<ErrorDetailModal
|
||
v-model="showErrorModal"
|
||
:row="errorModalRow"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
|
||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||
import { formatRelative as fmtRelative } from '../../utils/date.js'
|
||
import ErrorDetailModal from '../common/ErrorDetailModal.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 errorModalRow = ref(null)
|
||
|
||
function openError(row) {
|
||
errorModalRow.value = row
|
||
showErrorModal.value = true
|
||
}
|
||
|
||
const store = useSystemActivityStore()
|
||
|
||
const filterQueue = ref(null)
|
||
const filterStatus = ref(null)
|
||
const filterErrorType = ref(null)
|
||
|
||
const queueOptions = [
|
||
{ title: 'All queues', value: null },
|
||
{ title: 'import', value: 'import' },
|
||
{ title: 'thumbnail', value: 'thumbnail' },
|
||
{ title: 'ml', value: 'ml' },
|
||
{ title: 'download', value: 'download' },
|
||
{ title: 'scan', value: 'scan' },
|
||
{ title: 'maintenance', value: 'maintenance' },
|
||
{ title: 'default', value: 'default' },
|
||
]
|
||
const statusOptions = [
|
||
{ title: 'All statuses', value: null },
|
||
{ title: 'Running', value: 'running' },
|
||
{ title: 'OK', value: 'ok' },
|
||
{ title: 'Error', value: 'error' },
|
||
{ title: 'Timeout', value: 'timeout' },
|
||
{ title: 'Retry', value: 'retry' },
|
||
]
|
||
|
||
let queuesPollId = null
|
||
let failuresPollId = null
|
||
|
||
function pollQueues() {
|
||
if (document.hidden) return
|
||
store.loadQueues()
|
||
store.loadWorkers()
|
||
store.loadRecentMinute()
|
||
}
|
||
function pollFailures() {
|
||
if (document.hidden) return
|
||
store.loadFailures()
|
||
}
|
||
|
||
onMounted(() => {
|
||
pollQueues()
|
||
pollFailures()
|
||
store.setFilter({ queue: null, status: null })
|
||
store.loadRuns({ reset: true })
|
||
queuesPollId = setInterval(pollQueues, 3000)
|
||
failuresPollId = setInterval(pollFailures, 15000)
|
||
})
|
||
onUnmounted(() => {
|
||
if (queuesPollId) clearInterval(queuesPollId)
|
||
if (failuresPollId) clearInterval(failuresPollId)
|
||
})
|
||
|
||
const errorTypes = computed(() => {
|
||
const counts = store.failures?.count_by_type || {}
|
||
return Object.entries(counts).map(([name, count]) => ({ name, count }))
|
||
})
|
||
|
||
const failureCount = computed(() => (store.failures?.recent || []).length)
|
||
|
||
const filteredFailures = computed(() => {
|
||
const all = store.failures?.recent || []
|
||
if (!filterErrorType.value) return all
|
||
return all.filter(r => r.error_type === filterErrorType.value)
|
||
})
|
||
|
||
function toggleErrorTypeFilter(name) {
|
||
filterErrorType.value = filterErrorType.value === name ? null : name
|
||
if (filterErrorType.value) {
|
||
// Mirror into the All-activity table too.
|
||
filterStatus.value = 'error'
|
||
onFilterChange()
|
||
}
|
||
}
|
||
|
||
function onFilterChange() {
|
||
store.setFilter({ queue: filterQueue.value, status: filterStatus.value })
|
||
store.loadRuns({ reset: true })
|
||
}
|
||
function onLoadMore() { store.loadRuns({ reset: false }) }
|
||
function onRefresh() { store.loadRuns({ reset: true }) }
|
||
|
||
function statusIcon(status) {
|
||
return {
|
||
ok: 'mdi-check-circle',
|
||
error: 'mdi-close-circle',
|
||
timeout: 'mdi-clock-alert',
|
||
retry: 'mdi-refresh',
|
||
running: 'mdi-timer-sand',
|
||
}[status] || 'mdi-help-circle'
|
||
}
|
||
function statusColor(status) {
|
||
return {
|
||
ok: 'success', error: 'error', timeout: 'warning',
|
||
retry: 'info', running: 'accent',
|
||
}[status] || 'on-surface-variant'
|
||
}
|
||
function shortTaskName(name) {
|
||
if (!name) return ''
|
||
const parts = name.split('.')
|
||
return parts.slice(-1)[0]
|
||
}
|
||
function formatDuration(ms) {
|
||
if (ms == null) return '—'
|
||
if (ms < 1000) return `${ms} ms`
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
|
||
return `${(ms / 60_000).toFixed(1)} min`
|
||
}
|
||
function formatRelative(iso) {
|
||
return fmtRelative(iso, { nullText: '—' })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fc-activity { padding: 0; }
|
||
.fc-pills {
|
||
display: flex; gap: 6px; flex-wrap: wrap;
|
||
}
|
||
.fc-tabular {
|
||
font-variant-numeric: tabular-nums;
|
||
font-feature-settings: 'tnum';
|
||
}
|
||
.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)); }
|
||
</style>
|