Files
FitTrack2/FitnessSync/backend/templates/index.html
2026-01-13 09:42:16 -08:00

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 %}