feat: add sync job history to dashboard

Add a "Recent Sync Jobs" section to the dashboard that displays the last 5 sync jobs with:

- Job type (LoTW/DCL) with icon
- Status badge (pending/running/completed/failed)
- Relative timestamp (e.g., "5m ago", "2h ago")
- Duration for completed jobs
- Sync statistics (total, added, updated, skipped)
- Error messages for failed jobs
- Empty state with helpful CTAs
- Loading state while fetching

Uses existing backend API (GET /api/jobs?limit=5).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 11:17:04 +01:00
parent 6b195d3014
commit 56be3c0702

View File

@@ -1,14 +1,79 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { jobsAPI } from '$lib/api.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
onMount(() => { let jobs = [];
let loading = true;
onMount(async () => {
// Load user profile on mount if we have a token // Load user profile on mount if we have a token
if (browser) { if (browser) {
auth.loadProfile(); auth.loadProfile();
} }
// Load recent jobs if authenticated
if ($auth.user) {
try {
const response = await jobsAPI.getRecent(5);
jobs = response.jobs || [];
} catch (error) {
console.error('Failed to load jobs:', error);
} finally {
loading = false;
}
}
}); });
function getJobIcon(type) {
return type === 'lotw_sync' ? '📡' : '🛰️';
}
function getJobLabel(type) {
return type === 'lotw_sync' ? 'LoTW Sync' : 'DCL Sync';
}
function getStatusBadge(status) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
return styles[status] || 'bg-gray-100 text-gray-800';
}
function formatTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDate(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function getDuration(job) {
if (!job.startedAt || !job.completedAt) return null;
const diff = new Date(job.completedAt) - new Date(job.startedAt);
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
</script> </script>
<div class="container"> <div class="container">
@@ -40,6 +105,86 @@
</div> </div>
</div> </div>
<!-- Recent Sync Jobs -->
<div class="jobs-section">
<h2 class="section-title">🔄 Recent Sync Jobs</h2>
{#if loading}
<div class="loading-state">Loading jobs...</div>
{:else if jobs.length === 0}
<div class="empty-state">
<p>No sync jobs yet. Sync your QSOs from LoTW or DCL to get started!</p>
<div class="empty-actions">
<a href="/settings" class="btn btn-secondary">Configure Credentials</a>
<a href="/qsos" class="btn btn-primary">Sync QSOs</a>
</div>
</div>
{:else}
<div class="jobs-list">
{#each jobs as job (job.id)}
<div class="job-card" class:failed={job.status === 'failed'}>
<div class="job-header">
<div class="job-title">
<span class="job-icon">{getJobIcon(job.type)}</span>
<span class="job-name">{getJobLabel(job.type)}</span>
<span class="job-id">#{job.id}</span>
</div>
<span class="status-badge {getStatusBadge(job.status)}">
{job.status}
</span>
</div>
<div class="job-meta">
<span class="job-date" title={new Date(job.createdAt).toLocaleString()}>
{formatDate(job.createdAt)}
</span>
{#if job.startedAt}
<span class="job-time">{formatTime(job.startedAt)}</span>
{/if}
{#if getDuration(job)}
<span class="job-duration">({getDuration(job)})</span>
{/if}
</div>
{#if job.status === 'failed' && job.error}
<div class="job-error">
{job.error}
</div>
{:else if job.result}
<div class="job-stats">
{#if job.result.total !== undefined}
<span class="stat-item">
<strong>{job.result.total}</strong> total
</span>
{/if}
{#if job.result.added !== undefined && job.result.added > 0}
<span class="stat-item stat-added">
+{job.result.added} added
</span>
{/if}
{#if job.result.updated !== undefined && job.result.updated > 0}
<span class="stat-item stat-updated">
~{job.result.updated} updated
</span>
{/if}
{#if job.result.skipped !== undefined && job.result.skipped > 0}
<span class="stat-item stat-skipped">
{job.result.skipped} skipped
</span>
{/if}
</div>
{:else if job.status === 'running' || job.status === 'pending'}
<div class="job-progress">
<span class="progress-text">
{job.status === 'pending' ? 'Waiting to start...' : 'Processing...'}
</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="info-box"> <div class="info-box">
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
@@ -191,4 +336,196 @@
color: #666; color: #666;
line-height: 1.8; line-height: 1.8;
} }
/* Jobs Section */
.jobs-section {
margin-bottom: 2rem;
}
.section-title {
font-size: 1.5rem;
color: #333;
margin-bottom: 1rem;
}
.loading-state,
.empty-state {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 2rem;
text-align: center;
color: #666;
}
.empty-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.jobs-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.job-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.job-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.job-card.failed {
border-left: 4px solid #dc3545;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.job-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.job-icon {
font-size: 1.5rem;
}
.job-name {
font-weight: 600;
color: #333;
font-size: 1.1rem;
}
.job-id {
font-size: 0.85rem;
color: #999;
font-family: monospace;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
text-transform: capitalize;
}
.bg-yellow-100 {
background-color: #fef3c7;
}
.bg-blue-100 {
background-color: #dbeafe;
}
.bg-green-100 {
background-color: #d1fae5;
}
.bg-red-100 {
background-color: #fee2e2;
}
.text-yellow-800 {
color: #92400e;
}
.text-blue-800 {
color: #1e40af;
}
.text-green-800 {
color: #065f46;
}
.text-red-800 {
color: #991b1b;
}
.job-meta {
display: flex;
gap: 0.75rem;
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.job-date {
font-weight: 500;
}
.job-time,
.job-duration {
color: #999;
}
.job-error {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
font-size: 0.95rem;
margin-top: 0.5rem;
}
.job-stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.stat-item {
font-size: 0.9rem;
color: #666;
padding: 0.25rem 0.5rem;
background: #f8f9fa;
border-radius: 4px;
}
.stat-item strong {
color: #333;
}
.stat-added {
color: #065f46;
background: #d1fae5;
}
.stat-updated {
color: #1e40af;
background: #dbeafe;
}
.stat-skipped {
color: #92400e;
background: #fef3c7;
}
.job-progress {
margin-top: 0.5rem;
}
.progress-text {
color: #1e40af;
font-size: 0.9rem;
font-style: italic;
}
</style> </style>