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:
@@ -1,14 +1,79 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores.js';
|
||||
import { jobsAPI } from '$lib/api.js';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
onMount(() => {
|
||||
let jobs = [];
|
||||
let loading = true;
|
||||
|
||||
onMount(async () => {
|
||||
// Load user profile on mount if we have a token
|
||||
if (browser) {
|
||||
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>
|
||||
|
||||
<div class="container">
|
||||
@@ -40,6 +105,86 @@
|
||||
</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">
|
||||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
@@ -191,4 +336,196 @@
|
||||
color: #666;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user