fc3i(ui): SystemActivityTab.vue — queues + failures + all-activity panes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
<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 class="fc-err" :title="r.error_message">
|
||||
{{ r.error_type }}
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
import QueuesTable from './QueuesTable.vue'
|
||||
|
||||
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) {
|
||||
if (!iso) return '—'
|
||||
const then = new Date(iso).getTime()
|
||||
const now = Date.now()
|
||||
const diff = Math.max(0, (now - then) / 1000)
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
</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-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user