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