dfa1680a19
ci / backend (push) Failing after 0s
ci / static (push) Failing after 11s
ci / backend (pull_request) Failing after 1s
ci / static (pull_request) Failing after 12s
Build & Test with Secrets / build (pull_request) Failing after 2s
ci / publish (push) Has been cancelled
ci / frontend (push) Has been cancelled
Build & Test with Secrets / security-scan (pull_request) Has been cancelled
Build & Test with Secrets / notification (pull_request) Has been cancelled
Build & Test with Secrets / frontend (pull_request) Has been cancelled
ci / publish (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
317 lines
6.9 KiB
Vue
317 lines
6.9 KiB
Vue
<template>
|
|
<div class="ingestion-status">
|
|
<div class="header">
|
|
<h1>Market Data Ingestion</h1>
|
|
<p class="subtitle">Monitor data collection status</p>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- Status summary -->
|
|
<div v-if="job" class="status-card">
|
|
<div class="status-header">
|
|
<h2>Job {{ job.jobId.substring(0, 8) }}</h2>
|
|
<span :class="['status-badge', `status-${job.status.toLowerCase()}`]">
|
|
{{ job.status }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="status-grid">
|
|
<div class="stat">
|
|
<span class="label">Rows Processed</span>
|
|
<span class="value">{{ job.rowsProcessed.toLocaleString() }}</span>
|
|
</div>
|
|
|
|
<div class="stat">
|
|
<span class="label">Rows Failed</span>
|
|
<span class="value error">{{ job.rowsFailed }}</span>
|
|
</div>
|
|
|
|
<div class="stat">
|
|
<span class="label">Quality Score</span>
|
|
<span class="value">{{ calculateQualityScore(job) }}%</span>
|
|
</div>
|
|
|
|
<div class="stat" v-if="job.durationSeconds">
|
|
<span class="label">Duration</span>
|
|
<span class="value">{{ job.durationSeconds }}s</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="job.errorMessage" class="error-section">
|
|
<strong>Error:</strong> {{ job.errorMessage }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div v-else class="loading">
|
|
<p>Fetching ingestion status...</p>
|
|
</div>
|
|
|
|
<!-- Historical jobs -->
|
|
<div class="history-section">
|
|
<h3>Recent Ingestions</h3>
|
|
<table class="history-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Job ID</th>
|
|
<th>Status</th>
|
|
<th>Rows</th>
|
|
<th>Duration</th>
|
|
<th>Completed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(item, idx) in recentJobs" :key="idx" :class="`status-${item.status.toLowerCase()}`">
|
|
<td>{{ item.jobId.substring(0, 8) }}</td>
|
|
<td><span :class="['status-badge', `status-${item.status.toLowerCase()}`]">{{ item.status }}</span></td>
|
|
<td>{{ item.rowsProcessed }}</td>
|
|
<td>{{ item.durationSeconds ? `${item.durationSeconds}s` : '—' }}</td>
|
|
<td>{{ item.completedAt ? new Date(item.completedAt).toLocaleDateString() : '—' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
|
|
interface IngestionJob {
|
|
jobId: string
|
|
status: string
|
|
rowsProcessed: number
|
|
rowsFailed: number
|
|
rowsSkipped?: number
|
|
durationSeconds?: number
|
|
completedAt?: string
|
|
errorMessage?: string
|
|
}
|
|
|
|
const job = ref<IngestionJob | null>(null)
|
|
const recentJobs = ref<IngestionJob[]>([])
|
|
const isLoading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
|
|
// Fetch latest job status from API
|
|
const fetchLatestJob = async () => {
|
|
try {
|
|
// In a real app, this would fetch from /api/market/ingest/latest
|
|
// For now, we'll show a loading state
|
|
const response = await fetch('/api/market/ingest/latest', {
|
|
headers: {
|
|
'X-KArtSell-User': 'ingestion-user',
|
|
'X-KArtSell-Role': 'DataAdmin',
|
|
},
|
|
})
|
|
|
|
if (response.ok) {
|
|
job.value = await response.json()
|
|
} else if (response.status === 404) {
|
|
// No jobs yet - that's fine
|
|
job.value = null
|
|
} else {
|
|
throw new Error(`API error: ${response.status}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch latest job:', err)
|
|
// Don't fail the page, just show no data
|
|
job.value = null
|
|
}
|
|
}
|
|
|
|
// Fetch recent jobs history
|
|
const fetchRecentJobs = async () => {
|
|
try {
|
|
const response = await fetch('/api/market/ingest/history?limit=10', {
|
|
headers: {
|
|
'X-KArtSell-User': 'ingestion-user',
|
|
'X-KArtSell-Role': 'DataAdmin',
|
|
},
|
|
})
|
|
|
|
if (response.ok) {
|
|
recentJobs.value = await response.json()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch recent jobs:', err)
|
|
error.value = 'Failed to load job history'
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchLatestJob()
|
|
fetchRecentJobs()
|
|
|
|
// Auto-refresh every 10 seconds if there's an active job
|
|
const interval = setInterval(() => {
|
|
if (job.value?.status === 'Running' || job.value?.status === 'Queued') {
|
|
fetchLatestJob()
|
|
}
|
|
}, 10000)
|
|
|
|
return () => clearInterval(interval)
|
|
})
|
|
|
|
const calculateQualityScore = (job: IngestionJob): number => {
|
|
const total = job.rowsProcessed + job.rowsFailed + (job.rowsSkipped || 0)
|
|
if (total === 0) return 0
|
|
return Math.round((job.rowsProcessed / total) * 100)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ingestion-status {
|
|
padding: 2rem;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 2rem;
|
|
margin: 0 0 0.5rem 0;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
}
|
|
|
|
.content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2rem;
|
|
}
|
|
|
|
.status-card {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
background: var(--surface-elevated);
|
|
}
|
|
|
|
.status-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.status-header h2 {
|
|
margin: 0;
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-badge.status-completed {
|
|
background-color: #10b981;
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.status-running {
|
|
background-color: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.status-failed {
|
|
background-color: #ef4444;
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.status-queued {
|
|
background-color: #f59e0b;
|
|
color: white;
|
|
}
|
|
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.stat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.stat .label {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.stat .value {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.stat .value.error {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.error-section {
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background-color: #fee2e2;
|
|
border-left: 4px solid #ef4444;
|
|
color: #7f1d1d;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.history-section h3 {
|
|
margin-top: 2rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.history-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.history-table thead {
|
|
background-color: var(--surface-secondary);
|
|
}
|
|
|
|
.history-table th {
|
|
padding: 1rem;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.history-table td {
|
|
padding: 1rem;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.history-table tbody tr.status-completed {
|
|
background-color: #f0fdf4;
|
|
}
|
|
|
|
.history-table tbody tr.status-failed {
|
|
background-color: #fef2f2;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
</style>
|