1056 lines
44 KiB
HTML
1056 lines
44 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block content %}
|
|
<!-- Job Status Banner -->
|
|
|
|
|
|
<div class="row mb-4">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Last Sync Status</h5>
|
|
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
|
|
<table class="table table-sm" id="metrics-status-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Source</th>
|
|
<th>Found</th>
|
|
<th>Synced</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="4">No sync data available.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="mt-2 text-muted small">
|
|
<span id="db-stats"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Sync Controls</h5>
|
|
<div class="d-grid gap-2">
|
|
<button class="btn btn-primary" type="button" id="sync-activities-btn">Sync Latest
|
|
Activities (30d)</button>
|
|
<button class="btn btn-outline-primary" type="button" id="sync-all-activities-btn">Sync All
|
|
Historical Activities</button>
|
|
<hr>
|
|
<h6 class="text-muted">Health Metrics</h6>
|
|
<button class="btn btn-info text-white" type="button" id="scan-health-btn">Scan Health Gaps
|
|
(30d)</button>
|
|
<button class="btn btn-outline-info" type="button" id="sync-pending-health-btn">Sync Pending
|
|
Health Metrics</button>
|
|
<hr>
|
|
<h6 class="text-muted">Fitbit Sync</h6>
|
|
<button class="btn btn-success" type="button" id="sync-fitbit-btn">Sync Latest Weight
|
|
(Fitbit) (30d)</button>
|
|
<button class="btn btn-outline-success" type="button" id="sync-all-fitbit-btn">Sync All
|
|
Historical Weight (Fitbit)</button>
|
|
<button class="btn btn-warning mt-2" type="button" id="compare-fitbit-btn">Compare Fitbit vs
|
|
Garmin</button>
|
|
<hr>
|
|
<h6 class="text-muted">Debug</h6>
|
|
<button class="btn btn-outline-secondary" type="button" id="test-queue-btn"
|
|
title="Simulate 5s Job"><i class="bi bi-bug"></i> Test Queue (5s)</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scheduled Jobs Row -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-12">
|
|
<div class="card">
|
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0"><i class="bi bi-calendar-check"></i> Scheduled Jobs</h5>
|
|
<div>
|
|
<button class="btn btn-sm btn-success me-2" onclick="createModal.show()"><i
|
|
class="bi bi-plus-lg"></i> Add Job</button>
|
|
<button class="btn btn-sm btn-light" onclick="loadJobs()"><i class="bi bi-arrow-clockwise"></i>
|
|
Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" id="scheduler-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Job Type</th>
|
|
<th>Status</th>
|
|
<th>Interval</th>
|
|
<th>Last Run</th>
|
|
<th>Next Run</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="7" class="text-center text-muted">Loading schedules...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Job Queue Row -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-12">
|
|
<div class="card border-info">
|
|
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0"><i class="bi bi-list-task"></i> Active Job Queue</h5>
|
|
<span class="badge bg-light text-dark" id="queue-count">0</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" id="job-queue-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Operation</th>
|
|
<th>Job ID</th>
|
|
<th>Status</th>
|
|
<th>Progress</th>
|
|
<th>Message</th>
|
|
<th>Started</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="6" class="text-center text-muted">No active jobs.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Recent Jobs History -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-12">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0"><i class="bi bi-clock-history"></i> Recent Jobs History</h5>
|
|
<div>
|
|
<span class="me-2" id="history-page-info">Page 1</span>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="changeHistoryPage(-1)" id="hist-prev-btn"
|
|
disabled><i class="bi bi-chevron-left"></i></button>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="changeHistoryPage(1)" id="hist-next-btn"
|
|
disabled><i class="bi bi-chevron-right"></i></button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover" id="job-history-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Operation</th>
|
|
<th>Status</th>
|
|
<th>Duration</th>
|
|
<th>Completed At</th>
|
|
<th>Message</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="6" class="text-center text-muted">No recent jobs.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Job Details Modal -->
|
|
<div class="modal fade" id="jobDetailsModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Job Details</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<dl class="row">
|
|
<dt class="col-sm-4">Job ID:</dt>
|
|
<dd class="col-sm-8" id="modal-job-id"></dd>
|
|
|
|
<dt class="col-sm-4">Operation:</dt>
|
|
<dd class="col-sm-8" id="modal-job-op"></dd>
|
|
|
|
<dt class="col-sm-4">Status:</dt>
|
|
<dd class="col-sm-8" id="modal-job-status"></dd>
|
|
|
|
<dt class="col-sm-4">Result:</dt>
|
|
<dd class="col-sm-8">
|
|
<pre id="modal-job-result" class="bg-light p-2 text-wrap"
|
|
style="max-height: 200px; overflow-y: auto;"></pre>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Job Modal -->
|
|
<div class="modal fade" id="editJobModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Edit Schedule</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="edit-job-form">
|
|
<input type="hidden" id="edit-job-id">
|
|
<div class="mb-3">
|
|
<label class="form-label">Job Name</label>
|
|
<input type="text" class="form-control" id="edit-job-name" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Interval (Minutes)</label>
|
|
<input type="number" class="form-control" id="edit-job-interval" min="0" required>
|
|
<div class="form-text">How often this job should run. Set to 0 for Manual Only (Adhoc).</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Parameters (JSON)</label>
|
|
<textarea class="form-control text-monospace" id="edit-job-params" rows="3"></textarea>
|
|
</div>
|
|
<div class="mb-3 form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="edit-job-enabled">
|
|
<label class="form-check-label" for="edit-job-enabled">Enabled</label>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-danger"
|
|
onclick="deleteJob(document.getElementById('edit-job-id').value)">Delete Job</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveJob()">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Job Modal -->
|
|
<div class="modal fade" id="createJobModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Create New Schedule</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="create-job-form">
|
|
<div class="mb-3">
|
|
<label class="form-label">Job Type</label>
|
|
<select class="form-select" id="create-job-type" onchange="updateParamsHelp()">
|
|
<option value="" disabled selected>Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" class="form-control" id="create-job-name" required
|
|
placeholder="e.g. Daily Sync">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Interval (Minutes)</label>
|
|
<input type="number" class="form-control" id="create-job-interval" min="0" value="60" required>
|
|
<div class="form-text">Set to 0 for Manual Only.</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Parameters (JSON)</label>
|
|
<div id="params-helper" class="form-text mb-2"></div>
|
|
<textarea class="form-control text-monospace" id="create-job-params"
|
|
rows="3">{"days_back": 30}</textarea>
|
|
</div>
|
|
|
|
<div class="mb-3 form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="create-job-enabled" checked>
|
|
<label class="form-check-label" for="create-job-enabled">Enabled immediately</label>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-success" onclick="createJob()">Create Schedule</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-12">
|
|
<h3>Recent Sync Logs</h3>
|
|
<div class="table-responsive">
|
|
<table class="table table-striped" id="sync-logs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Operation</th>
|
|
<th>Status</th>
|
|
<th>Start Time</th>
|
|
<th>End Time</th>
|
|
<th>Processed</th>
|
|
<th>Failed</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="7">Loading logs...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mt-5">
|
|
<div class="col-md-12">
|
|
<h3>Actions</h3>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let toastInstance = null;
|
|
let jobDetailsModal = null;
|
|
let editModal = null;
|
|
let createModal = null;
|
|
|
|
// History Pagination State
|
|
let historyPage = 1;
|
|
const historyLimit = 10;
|
|
let historyTotal = 0;
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const toastEl = document.getElementById('appToast');
|
|
if (toastEl) {
|
|
toastInstance = new bootstrap.Toast(toastEl);
|
|
}
|
|
|
|
const modalEl = document.getElementById('jobDetailsModal');
|
|
if (modalEl) {
|
|
jobDetailsModal = new bootstrap.Modal(modalEl);
|
|
}
|
|
|
|
editModal = new bootstrap.Modal(document.getElementById('editJobModal'));
|
|
createModal = new bootstrap.Modal(document.getElementById('createJobModal'));
|
|
|
|
loadJobs();
|
|
if (typeof updateParamsHelp === 'function') updateParamsHelp(); // Init helper text
|
|
loadDashboardData();
|
|
populateJobTypes();
|
|
|
|
// Auto-refresh dashboard data every 3 seconds
|
|
setInterval(loadDashboardData, 3000);
|
|
|
|
// Listen for global job events to toggle buttons
|
|
document.addEventListener('sync-job-active', () => toggleSyncButtons(true));
|
|
document.addEventListener('sync-job-finished', () => {
|
|
toggleSyncButtons(false);
|
|
loadDashboardData(); // Refresh data when job finishes
|
|
});
|
|
|
|
document.getElementById('sync-activities-btn').addEventListener('click', () => syncActivities(30));
|
|
document.getElementById('sync-all-activities-btn').addEventListener('click', () => syncActivities(3650));
|
|
|
|
document.getElementById('scan-health-btn').addEventListener('click', scanHealth);
|
|
document.getElementById('sync-pending-health-btn').addEventListener('click', syncPendingHealth);
|
|
|
|
document.getElementById('sync-fitbit-btn').addEventListener('click', () => syncFitbitWeight('30d'));
|
|
document.getElementById('sync-all-fitbit-btn').addEventListener('click', () => syncFitbitWeight('all'));
|
|
document.getElementById('compare-fitbit-btn').addEventListener('click', compareWeight);
|
|
|
|
const testBtn = document.getElementById('test-queue-btn');
|
|
if (testBtn) {
|
|
testBtn.addEventListener('click', async () => {
|
|
try {
|
|
const res = await fetch('/api/status/test-job', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (res.ok) showToast('Test Job', 'Test job started (5s)', 'success');
|
|
else showToast('Error', data.detail, 'error');
|
|
} catch (e) { showToast('Error', e.message, 'error'); }
|
|
});
|
|
}
|
|
});
|
|
|
|
function showToast(title, body, level = 'info') {
|
|
const toastEl = document.getElementById('appToast');
|
|
if (!toastEl) return;
|
|
|
|
// Ensure instance
|
|
if (!toastInstance) toastInstance = new bootstrap.Toast(toastEl);
|
|
|
|
const toastTitle = document.getElementById('toast-title');
|
|
const toastBody = document.getElementById('toast-body');
|
|
const toastHeader = toastEl.querySelector('.toast-header');
|
|
|
|
if (toastTitle) toastTitle.textContent = title;
|
|
if (toastBody) toastBody.textContent = body;
|
|
|
|
// Reset header color
|
|
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'text-white');
|
|
|
|
if (level === 'success') {
|
|
toastHeader.classList.add('bg-success', 'text-white');
|
|
} else if (level === 'error' || level === 'danger') {
|
|
toastHeader.classList.add('bg-danger', 'text-white');
|
|
} else if (level === 'warning') {
|
|
toastHeader.classList.add('bg-warning');
|
|
} else {
|
|
toastHeader.classList.add('bg-info', 'text-white');
|
|
}
|
|
|
|
toastInstance.show();
|
|
}
|
|
|
|
async function loadDashboardData() {
|
|
try {
|
|
// Fetch Status
|
|
const response = await fetch('/api/status');
|
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
const data = await response.json();
|
|
|
|
// Fetch History in parallel
|
|
try {
|
|
const historyRes = await fetch(`/api/jobs/history?page=${historyPage}&limit=${historyLimit}`);
|
|
if (historyRes.ok) {
|
|
const historyData = await historyRes.json();
|
|
updateJobHistoryTable(historyData);
|
|
}
|
|
} catch (e) { console.error("History fetch error:", e); }
|
|
|
|
// Safe update for db-stats if element exists (it should)
|
|
const dbStats = document.getElementById('db-stats');
|
|
if (dbStats) {
|
|
dbStats.innerHTML = `<strong>DB Total Activities:</strong> ${data.total_activities} | <strong>Downloaded:</strong> ${data.downloaded_activities}`;
|
|
}
|
|
|
|
const metricsBody = document.querySelector('#metrics-status-table tbody');
|
|
if (metricsBody) {
|
|
metricsBody.innerHTML = '';
|
|
|
|
if (data.last_sync_stats && data.last_sync_stats.length > 0) {
|
|
data.last_sync_stats.forEach(stat => {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${stat.type}</td>
|
|
<td>${stat.source}</td>
|
|
<td>${stat.total}</td>
|
|
<td class="${stat.synced > 0 ? 'text-success' : ''}">${stat.synced}</td>
|
|
`;
|
|
metricsBody.appendChild(row);
|
|
});
|
|
} else {
|
|
metricsBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No detailed sync stats available. Run a sync to populate.</td></tr>';
|
|
}
|
|
}
|
|
|
|
const logsBody = document.querySelector('#sync-logs-table tbody');
|
|
if (logsBody) {
|
|
logsBody.innerHTML = '';
|
|
|
|
if (data.recent_logs.length === 0) {
|
|
logsBody.innerHTML = '<tr><td colspan="7">No recent sync logs.</td></tr>';
|
|
} else {
|
|
data.recent_logs.forEach(log => {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${log.operation}</td>
|
|
<td><span class="badge bg-${log.status === 'completed' ? 'success' : (log.status === 'failed' || log.status === 'cancelled' ? 'danger' : 'warning')}">${log.status}</span></td>
|
|
<td>${new Date(log.start_time).toLocaleString()}</td>
|
|
<td>${log.end_time ? new Date(log.end_time).toLocaleString() : 'N/A'}</td>
|
|
<td>${log.records_processed}</td>
|
|
<td>${log.records_failed}</td>
|
|
<td>${log.message || ''}</td>
|
|
`;
|
|
logsBody.appendChild(row);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update Job Queue
|
|
const queueBody = document.querySelector('#job-queue-table tbody');
|
|
const queueCount = document.getElementById('queue-count');
|
|
|
|
if (queueBody && data.active_jobs) {
|
|
if (queueCount) queueCount.textContent = data.active_jobs.length;
|
|
|
|
queueBody.innerHTML = '';
|
|
if (data.active_jobs.length === 0) {
|
|
queueBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">No active jobs.</td></tr>';
|
|
} else {
|
|
data.active_jobs.forEach(job => {
|
|
const row = document.createElement('tr');
|
|
// Highlight running vs undefined
|
|
const statusClass = job.status === 'running' ? 'text-primary fw-bold' : (job.cancel_requested ? 'text-danger' : '');
|
|
|
|
// Action Buttons Logic
|
|
let actionsHtml = '';
|
|
if (job.status === 'running' || job.status === 'queued') {
|
|
actionsHtml += `<button class="btn btn-sm btn-outline-warning me-1" onclick="pauseJob('${job.id}')" title="Pause"><i class="bi bi-pause-fill"></i></button>`;
|
|
} else if (job.status === 'paused') {
|
|
actionsHtml += `<button class="btn btn-sm btn-outline-success me-1" onclick="resumeJob('${job.id}')" title="Resume"><i class="bi bi-play-fill"></i></button>`;
|
|
}
|
|
|
|
if (!job.cancel_requested && job.status !== 'completed' && job.status !== 'failed') {
|
|
actionsHtml += `<button class="btn btn-sm btn-outline-danger" onclick="cancelJob('${job.id}')" title="Cancel"><i class="bi bi-x-circle"></i></button>`;
|
|
}
|
|
|
|
// force kill button: show if running/queued/paused regardless of cancel_requested
|
|
// Use a trash icon or skulls
|
|
if (['running', 'queued', 'paused'].includes(job.status)) {
|
|
actionsHtml += `<button class="btn btn-sm btn-danger ms-1" onclick="forceKillJob('${job.id}')" title="Force Kill (Mark Failed)"><i class="bi bi-trash-fill"></i></button>`;
|
|
}
|
|
|
|
row.innerHTML = `
|
|
<td><span class="${statusClass}">${job.operation}</span></td>
|
|
<td><small class="text-muted">${job.id.substring(0, 8)}...</small></td>
|
|
<td>
|
|
<span class="badge bg-${getBadgeClass(job.status)}">
|
|
${job.status} ${job.cancel_requested ? '(Cancelling)' : ''}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div class="progress" style="height: 20px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"
|
|
style="width: ${job.progress}%" aria-valuenow="${job.progress}" aria-valuemin="0" aria-valuemax="100">
|
|
${job.progress}%
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td><small>${job.message || '-'}</small></td>
|
|
<td><small>${new Date(job.start_time).toLocaleTimeString()}</small></td>
|
|
<td>${actionsHtml}</td>
|
|
`;
|
|
queueBody.appendChild(row);
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading dashboard data:', error);
|
|
// showToast('Error', 'Could not load dashboard data.', 'error');
|
|
}
|
|
}
|
|
|
|
function updateJobHistoryTable(data) {
|
|
const tbody = document.querySelector('#job-history-table tbody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
|
|
// Handle {total, items} vs direct list (backwards compact)
|
|
const jobs = data.items || data;
|
|
historyTotal = data.total || jobs.length;
|
|
|
|
if (!jobs || jobs.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">No history available.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
jobs.forEach(job => {
|
|
const row = document.createElement('tr');
|
|
// Safe JSON stringify for onclick
|
|
const jobStr = encodeURIComponent(JSON.stringify(job));
|
|
|
|
row.innerHTML = `
|
|
<td>${job.operation}</td>
|
|
<td><span class="badge bg-${getBadgeClass(job.status)}">${job.status}</span></td>
|
|
<td>${job.duration_s ? job.duration_s + 's' : '-'}</td>
|
|
<td>${job.completed_at ? new Date(job.completed_at).toLocaleTimeString() : '-'}</td>
|
|
<td><small class="text-truncate d-inline-block" style="max-width: 150px;">${job.message || ''}</small></td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-info" onclick="showJobDetails('${jobStr}')">
|
|
<i class="bi bi-info-circle"></i> Details
|
|
</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
|
|
// Update pagination UI
|
|
document.getElementById('history-page-info').textContent = `Page ${historyPage} of ${Math.ceil(historyTotal / historyLimit) || 1}`;
|
|
document.getElementById('hist-prev-btn').disabled = historyPage <= 1;
|
|
document.getElementById('hist-next-btn').disabled = historyPage * historyLimit >= historyTotal;
|
|
}
|
|
|
|
function changeHistoryPage(delta) {
|
|
historyPage += delta;
|
|
if (historyPage < 1) historyPage = 1;
|
|
loadDashboardData();
|
|
}
|
|
|
|
function getBadgeClass(status) {
|
|
if (status === 'completed') return 'success';
|
|
if (status === 'failed' || status === 'cancelled') return 'danger';
|
|
if (status === 'running') return 'primary';
|
|
if (status === 'paused') return 'warning';
|
|
return 'secondary';
|
|
}
|
|
|
|
function showJobDetails(encodedJob) {
|
|
try {
|
|
const job = JSON.parse(decodeURIComponent(encodedJob));
|
|
document.getElementById('modal-job-id').textContent = job.id;
|
|
document.getElementById('modal-job-op').textContent = job.operation;
|
|
document.getElementById('modal-job-status').textContent = job.status;
|
|
|
|
let resultText = JSON.stringify(job, null, 2);
|
|
if (job.result) resultText = JSON.stringify(job.result, null, 2);
|
|
|
|
document.getElementById('modal-job-result').textContent = resultText;
|
|
|
|
if (jobDetailsModal) jobDetailsModal.show();
|
|
} catch (e) {
|
|
console.error("Error showing details", e);
|
|
}
|
|
}
|
|
|
|
async function pauseJob(id) {
|
|
try {
|
|
await fetch(`/api/jobs/${id}/pause`, { method: 'POST' });
|
|
loadDashboardData();
|
|
} catch (e) { showToast("Error", "Failed to pause job", "error"); }
|
|
}
|
|
|
|
async function resumeJob(id) {
|
|
try {
|
|
await fetch(`/api/jobs/${id}/resume`, { method: 'POST' });
|
|
loadDashboardData();
|
|
} catch (e) { showToast("Error", "Failed to resume job", "error"); }
|
|
}
|
|
|
|
async function cancelJob(id) {
|
|
if (!confirm("Are you sure you want to cancel this job?")) return;
|
|
try {
|
|
await fetch(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
|
loadDashboardData();
|
|
} catch (e) { showToast("Error", "Failed to cancel job", "error"); }
|
|
}
|
|
|
|
async function forceKillJob(id) {
|
|
if (!confirm("WARNING: Force Kill should only be used if a job is stuck!\n\nIt will mark the job as failed immediately but may not stop the background process if it is truly frozen.\n\nAre you sure?")) return;
|
|
try {
|
|
const res = await fetch(`/api/jobs/${id}/force-kill`, { method: 'POST' });
|
|
if (!res.ok) throw new Error("Failed to force kill");
|
|
showToast("Force Kill", "Job marked as failed.", "warning");
|
|
loadDashboardData();
|
|
} catch (e) { showToast("Error", "Failed to force kill job", "error"); }
|
|
}
|
|
|
|
function toggleSyncButtons(disabled) {
|
|
const ids = [
|
|
'sync-activities-btn', 'sync-all-activities-btn',
|
|
'scan-health-btn', 'sync-pending-health-btn',
|
|
'sync-fitbit-btn', 'sync-all-fitbit-btn'
|
|
];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.disabled = disabled;
|
|
});
|
|
}
|
|
|
|
async function syncActivities(daysBack = 30) {
|
|
const typeLabel = daysBack > 1000 ? 'Historical' : 'Latest';
|
|
showToast(`${typeLabel} Syncing...`, `Starting ${typeLabel} Activity sync (Scan + Download)...`, 'info');
|
|
|
|
try {
|
|
// Step 1: Scan
|
|
const scanRes = await fetch(`/api/activities/sync/scan?days_back=${daysBack}`, { method: 'POST' });
|
|
if (!scanRes.ok) {
|
|
const err = await scanRes.json();
|
|
throw new Error(err.detail || "Scan failed");
|
|
}
|
|
const scanData = await scanRes.json();
|
|
showToast('Scan Started', `Job ID: ${scanData.job_id} (Scanning)`, 'success');
|
|
|
|
// Helper to poll
|
|
const poll = async (jid) => {
|
|
return new Promise((resolve, reject) => {
|
|
const check = async () => {
|
|
try {
|
|
const r = await fetch(`/api/jobs/${jid}`);
|
|
if (r.status === 404) return resolve('done');
|
|
const d = await r.json();
|
|
if (d.status === 'completed') resolve('done');
|
|
else if (d.status === 'failed') reject(d.message);
|
|
else setTimeout(check, 2000);
|
|
} catch (e) { reject(e); }
|
|
};
|
|
check();
|
|
});
|
|
};
|
|
|
|
await poll(scanData.job_id);
|
|
|
|
// Step 2: Sync Pending
|
|
const syncRes = await fetch('/api/activities/sync/pending', { method: 'POST' });
|
|
if (!syncRes.ok) {
|
|
const err = await syncRes.json();
|
|
throw new Error(err.detail || "Download failed");
|
|
}
|
|
const syncData = await syncRes.json();
|
|
showToast('Download Started', `Job ID: ${syncData.job_id} (Downloading)`, 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error syncing activities:', error);
|
|
showToast('Sync Error', `Activity sync failed: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function scanHealth() {
|
|
showToast('Scan Started', 'Scanning for health data gaps...', 'info');
|
|
try {
|
|
const response = await fetch('/api/metrics/sync/scan', { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast('Scan Started', `Job ID: ${data.job_id}`, 'success');
|
|
} else {
|
|
showToast('Error', 'Failed to start scan', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error scanning health:', error);
|
|
showToast('Error', error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function syncPendingHealth() {
|
|
showToast('Sync Started', 'Syncing pending health metrics...', 'info');
|
|
try {
|
|
const response = await fetch('/api/metrics/sync/pending', { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast('Sync Started', `Job ID: ${data.job_id}`, 'success');
|
|
} else {
|
|
showToast('Error', 'Failed to start sync', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error syncing health:', error);
|
|
showToast('Error', error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function syncFitbitWeight(scope) {
|
|
const typeLabel = scope === 'all' ? 'All History' : 'Latest (30d)';
|
|
showToast(`Fitbit Syncing...`, `Fitbit Weight sync initiated (${typeLabel}).`, 'info');
|
|
|
|
try {
|
|
const response = await fetch('/api/sync/fitbit/weight', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ scope: scope })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.detail || errorData.message || `HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
showToast('Fitbit Sync Complete', data.message, 'success');
|
|
loadDashboardData();
|
|
} catch (error) {
|
|
console.error('Error syncing Fitbit weight:', error);
|
|
showToast('Fitbit Sync Error', `Sync failed: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function compareWeight() {
|
|
showToast('Comparing...', 'Comparing Fitbit and Garmin weight records...', 'info');
|
|
try {
|
|
const response = await fetch('/api/sync/compare-weight', { method: 'POST' });
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
|
|
showToast('Comparison Results',
|
|
`Fitbit Total: ${data.fitbit_total}\n` +
|
|
`Garmin Total: ${data.garmin_total}\n` +
|
|
`Missing in Garmin: ${data.missing_in_garmin}\n` +
|
|
`${data.message}`,
|
|
data.missing_in_garmin > 0 ? 'warning' : 'success'
|
|
);
|
|
|
|
console.log("Comparison Data:", data);
|
|
|
|
} catch (error) {
|
|
console.error('Error comparing weight:', error);
|
|
showToast('Comparison Error', `Comparison failed: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// --- Scheduler Functions ---
|
|
|
|
function updateParamsHelp() {
|
|
const type = document.getElementById('create-job-type').value;
|
|
const helper = document.getElementById('params-helper');
|
|
const textArea = document.getElementById('create-job-params');
|
|
|
|
let helpText = "";
|
|
let defaultJson = {};
|
|
|
|
switch (type) {
|
|
case 'fitbit_weight_sync':
|
|
case 'activity_sync':
|
|
case 'metrics_sync':
|
|
case 'health_scan':
|
|
helpText = "Requires 'days_back' (integer).";
|
|
defaultJson = { "days_back": 10 };
|
|
break;
|
|
case 'health_sync_pending':
|
|
case 'garmin_weight_upload':
|
|
helpText = "Requires 'limit' (integer).";
|
|
defaultJson = { "limit": 50 };
|
|
break;
|
|
case 'activity_backfill_full':
|
|
helpText = "Requires 'days_back' (integer, default 3650).";
|
|
defaultJson = { "days_back": 3650 };
|
|
break;
|
|
}
|
|
|
|
helper.textContent = helpText;
|
|
textArea.value = JSON.stringify(defaultJson, null, 2);
|
|
}
|
|
|
|
async function createJob() {
|
|
const type = document.getElementById('create-job-type').value;
|
|
const name = document.getElementById('create-job-name').value;
|
|
const interval = parseInt(document.getElementById('create-job-interval').value);
|
|
const enabled = document.getElementById('create-job-enabled').checked;
|
|
const paramsStr = document.getElementById('create-job-params').value;
|
|
|
|
if (!name) { alert("Name is required"); return; }
|
|
if (interval < 0) { alert("Interval must be >= 0"); return; }
|
|
|
|
let params = {};
|
|
try {
|
|
params = JSON.parse(paramsStr);
|
|
} catch (e) {
|
|
alert("Invalid JSON parameters");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/scheduling/jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
job_type: type,
|
|
name: name,
|
|
interval_minutes: interval,
|
|
params: params,
|
|
enabled: enabled
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.detail || "Failed to create");
|
|
}
|
|
|
|
createModal.hide();
|
|
showToast("Job Created!", "success");
|
|
loadJobs();
|
|
|
|
} catch (e) {
|
|
alert("Error: " + e.message);
|
|
}
|
|
}
|
|
|
|
async function loadJobs() {
|
|
try {
|
|
const response = await fetch('/api/scheduling/jobs');
|
|
if (!response.ok) throw new Error("Failed to fetch jobs");
|
|
const jobs = await response.json();
|
|
renderTable(jobs);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast("Error loading schedules", "error");
|
|
// Render error state table?
|
|
}
|
|
}
|
|
|
|
function renderTable(jobs) {
|
|
const tbody = document.querySelector('#scheduler-table tbody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
|
|
if (jobs.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">No scheduled jobs configured.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
jobs.forEach(job => {
|
|
const row = document.createElement('tr');
|
|
|
|
// Format Next Run color
|
|
let nextRunClass = '';
|
|
if (!job.enabled) nextRunClass = 'text-muted text-decoration-line-through';
|
|
|
|
row.innerHTML = `
|
|
<td><strong>${job.name}</strong></td>
|
|
<td><small class="text-muted">${job.job_type}</small></td>
|
|
<td>
|
|
<span class="badge bg-${job.enabled ? 'success' : 'secondary'}">
|
|
${job.enabled ? 'Active' : 'Disabled'}
|
|
</span>
|
|
</td>
|
|
<td>${job.interval_minutes} mins</td>
|
|
<td>${job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}</td>
|
|
<td class="${nextRunClass}">${job.next_run ? new Date(job.next_run).toLocaleString() : '-'}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-success me-1" onclick="runJob(${job.id})" title="Run Now">
|
|
<i class="bi bi-play-fill"></i> Run
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openEditModal(${job.id}, '${job.name}', ${job.interval_minutes}, ${job.enabled}, '${encodeURIComponent(JSON.stringify(JSON.parse(job.params || '{}')))}')">
|
|
<i class="bi bi-pencil"></i> Edit
|
|
</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function openEditModal(id, name, interval, enabled, params) {
|
|
document.getElementById('edit-job-id').value = id;
|
|
document.getElementById('edit-job-name').value = name;
|
|
document.getElementById('edit-job-interval').value = interval;
|
|
document.getElementById('edit-job-enabled').checked = enabled;
|
|
try {
|
|
document.getElementById('edit-job-params').value = JSON.stringify(JSON.parse(decodeURIComponent(params)), null, 2);
|
|
} catch (e) {
|
|
document.getElementById('edit-job-params').value = "{}";
|
|
}
|
|
editModal.show();
|
|
}
|
|
|
|
async function deleteJob(id) {
|
|
if (!confirm("Are you sure you want to delete this scheduled job?")) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/scheduling/jobs/${id}`, { method: 'DELETE' });
|
|
if (!response.ok) throw new Error("Failed to delete");
|
|
|
|
editModal.hide();
|
|
showToast("Job deleted", "warning");
|
|
loadJobs();
|
|
} catch (e) {
|
|
alert("Error: " + e.message);
|
|
}
|
|
}
|
|
|
|
async function runJob(id) {
|
|
if (!confirm("Run this scheduled job immediately?")) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/scheduling/jobs/${id}/run`, { method: 'POST' });
|
|
if (!response.ok) throw new Error("Failed to trigger job");
|
|
|
|
showToast("Job Triggered", "The scheduled job has been started.", "success");
|
|
loadJobs();
|
|
// Start polling or refresh dashboard active queue
|
|
loadDashboardData();
|
|
} catch (e) {
|
|
showToast("Error", e.message, "error");
|
|
}
|
|
}
|
|
|
|
async function saveJob() {
|
|
const id = document.getElementById('edit-job-id').value;
|
|
const interval = parseInt(document.getElementById('edit-job-interval').value);
|
|
const enabled = document.getElementById('edit-job-enabled').checked;
|
|
const paramsStr = document.getElementById('edit-job-params').value;
|
|
|
|
if (interval < 0) {
|
|
alert("Interval must be at least 0 minutes");
|
|
return;
|
|
}
|
|
|
|
let params = {};
|
|
try {
|
|
params = JSON.parse(paramsStr);
|
|
} catch (e) {
|
|
alert("Invalid JSON parameters");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/scheduling/jobs/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
interval_minutes: interval,
|
|
enabled: enabled,
|
|
params: params
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.detail || "Failed to update");
|
|
}
|
|
|
|
editModal.hide();
|
|
showToast("Schedule updated successfully", "success");
|
|
loadJobs();
|
|
|
|
} catch (e) {
|
|
alert("Error: " + e.message);
|
|
}
|
|
}
|
|
async function populateJobTypes() {
|
|
const select = document.getElementById('create-job-type');
|
|
try {
|
|
const response = await fetch('/api/scheduling/available-types');
|
|
if (!response.ok) throw new Error('Failed to fetch types');
|
|
const types = await response.json();
|
|
|
|
select.innerHTML = '';
|
|
types.forEach(type => {
|
|
const option = document.createElement('option');
|
|
option.value = type;
|
|
// Simple label transformation: underscores to spaces, Title Case
|
|
option.text = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Trigger help update for first item
|
|
if (types.length > 0) {
|
|
select.selectedIndex = 0;
|
|
updateParamsHelp();
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error("Error loading job types", e);
|
|
select.innerHTML = '<option disabled>Error loading types</option>';
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %} |