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

@@ -0,0 +1,74 @@
/**
* Migration: Add qso_changes table for sync job rollback
*
* This script adds the qso_changes table which tracks all QSO modifications
* made by sync jobs, enabling rollback functionality for failed or stale jobs.
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add qso_changes table...');
try {
// Check if table already exists
const tableExists = sqlite.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='qso_changes'
`).get();
if (tableExists) {
console.log('Table qso_changes already exists. Migration complete.');
sqlite.close();
return;
}
// Create qso_changes table
sqlite.exec(`
CREATE TABLE qso_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
qso_id INTEGER,
change_type TEXT NOT NULL,
before_data TEXT,
after_data TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
FOREIGN KEY (job_id) REFERENCES sync_jobs(id) ON DELETE CASCADE,
FOREIGN KEY (qso_id) REFERENCES qsos(id) ON DELETE CASCADE
)
`);
// Create index for faster lookups during rollback
sqlite.exec(`
CREATE INDEX idx_qso_changes_job_id ON qso_changes(job_id)
`);
// Create index for change_type lookups
sqlite.exec(`
CREATE INDEX idx_qso_changes_change_type ON qso_changes(change_type)
`);
console.log('Migration complete! Created qso_changes table with indexes.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});