feat: add sync job cancel and rollback with real-time updates

Implement comprehensive sync job management with rollback capabilities
and real-time status updates on the dashboard.

## Features

### Cancel & Rollback
- Users can cancel failed or stale (>1h) sync jobs
- Rollback deletes added QSOs and restores updated QSOs to previous state
- Uses qso_changes table to track all modifications with before/after snapshots
- Server-side validation prevents cancelling completed or active jobs

### Database Changes
- Add qso_changes table to track QSO modifications per job
- Stores change type (added/updated), before/after data snapshots
- Enables precise rollback of sync operations
- Migration script included

### Real-time Updates
- Dashboard now polls for job updates every 2 seconds
- Smart polling: starts when jobs active, stops when complete
- Job status badges update in real-time (pending → running → completed)
- Cancel button appears/disappears based on job state

### Backend
- Fixed job ordering to show newest first (desc createdAt)
- Track all QSO changes during LoTW/DCL sync operations
- cancelJob() function handles rollback logic
- DELETE /api/jobs/:jobId endpoint for cancelling jobs

### Frontend
- jobsAPI.cancel() method for cancelling jobs
- Dashboard shows last 5 sync jobs with status, stats, duration
- Real-time job status updates via polling
- Cancel button with confirmation dialog
- Loading state and error handling

### Logging Fix
- Changed from Bun.write() to fs.appendFile() for reliable log appending
- Logs now persist across server restarts instead of being truncated

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 11:46:19 +01:00
parent 56be3c0702
commit a50b4ae724
9 changed files with 502 additions and 26 deletions

View File

@@ -2,7 +2,7 @@ import Database from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import * as schema from './db/schema/index.js';
import { join, dirname } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, appendFile } from 'fs';
import { fileURLToPath } from 'url';
// ===================================================================
@@ -50,9 +50,9 @@ function log(level, message, data) {
const logMessage = formatLogMessage(level, message, data);
// Write to file asynchronously (fire and forget for performance)
Bun.write(backendLogFile, logMessage, { createPath: true }).catch(err => {
console.error('Failed to write to log file:', err);
// Append to file asynchronously (fire and forget for performance)
appendFile(backendLogFile, logMessage, (err) => {
if (err) console.error('Failed to write to log file:', err);
});
// Also log to console in development
@@ -90,9 +90,9 @@ export function logToFrontend(level, message, data = null, context = {}) {
logMessage += '\n';
// Write to frontend log file
Bun.write(frontendLogFile, logMessage, { createPath: true }).catch(err => {
console.error('Failed to write to frontend log file:', err);
// Append to frontend log file
appendFile(frontendLogFile, logMessage, (err) => {
if (err) console.error('Failed to write to frontend log file:', err);
});
}