Feat: Scheduler implemented, WIP
This commit is contained in:
@@ -132,23 +132,55 @@
|
||||
|
||||
<!-- Schedules Tab -->
|
||||
<div id="schedules-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<!-- Recurring Schedules Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scheduled Actions</h2>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recurring Schedules</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automated patterns that generate scheduled actions
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="openScheduleModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Schedule Action
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="project-schedules"
|
||||
hx-get="/api/projects/{{ project_id }}/schedules"
|
||||
hx-trigger="load, every 30s"
|
||||
<div id="recurring-schedule-list"
|
||||
hx-get="/api/projects/{{ project_id }}/recurring-schedules/partials/list"
|
||||
hx-trigger="load, refresh from:#recurring-schedule-list"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading schedules...</div>
|
||||
<div class="text-center py-8 text-gray-500">Loading recurring schedules...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Actions Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Individual scheduled start/stop/download actions
|
||||
</p>
|
||||
</div>
|
||||
<select id="schedules-filter" onchange="filterScheduledActions()"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<option value="pending">Pending</option>
|
||||
<option value="all">All</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="project-schedules"
|
||||
hx-get="/api/projects/{{ project_id }}/schedules?status=pending"
|
||||
hx-trigger="load, every 30s, refresh from:#project-schedules"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading scheduled actions...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,6 +410,122 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<div id="schedule-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create Recurring Schedule</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Set up automated monitoring schedules</p>
|
||||
</div>
|
||||
<button onclick="closeScheduleModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="schedule-form" class="p-6 space-y-6">
|
||||
<!-- Schedule Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Schedule Name</label>
|
||||
<input type="text" name="schedule_name" id="schedule-name"
|
||||
placeholder="e.g., Weeknight Monitoring"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||
</div>
|
||||
|
||||
<!-- Location Selection (Multiple) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Locations
|
||||
<span class="text-xs font-normal text-gray-500 ml-2">(select one or more)</span>
|
||||
</label>
|
||||
<div id="schedule-locations-container"
|
||||
class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2 space-y-1">
|
||||
<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>
|
||||
</div>
|
||||
<p id="schedule-location-empty" class="hidden text-xs text-gray-500 mt-2">
|
||||
No locations available. Create a location first.
|
||||
</p>
|
||||
<p id="schedule-location-error" class="hidden text-xs text-red-500 mt-2">
|
||||
Please select at least one location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Type Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="relative cursor-pointer">
|
||||
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
|
||||
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Weekly Calendar</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Select specific days with start/end times. Ideal for weeknight monitoring (Mon-Fri 7pm-7am).
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="relative cursor-pointer">
|
||||
<input type="radio" name="schedule_type" value="simple_interval" class="peer sr-only" onchange="toggleScheduleType('simple_interval')">
|
||||
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">24/7 Continuous</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Continuous monitoring with daily stop/download/restart cycle at a set time.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Calendar Editor -->
|
||||
<div id="schedule-weekly-wrapper">
|
||||
{% include "partials/projects/schedule_calendar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Simple Interval Editor -->
|
||||
<div id="schedule-interval-wrapper" class="hidden">
|
||||
{% include "partials/projects/schedule_interval.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Timezone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
|
||||
<select name="timezone" id="schedule-timezone"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="America/New_York">Eastern (America/New_York)</option>
|
||||
<option value="America/Chicago">Central (America/Chicago)</option>
|
||||
<option value="America/Denver">Mountain (America/Denver)</option>
|
||||
<option value="America/Los_Angeles">Pacific (America/Los_Angeles)</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="schedule-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeScheduleModal()"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Unit Modal -->
|
||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
@@ -809,11 +957,19 @@ function filterFiles() {
|
||||
});
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function openScheduleModal() {
|
||||
alert('Schedule modal coming soon');
|
||||
function filterScheduledActions() {
|
||||
const filter = document.getElementById('schedules-filter').value;
|
||||
const url = filter === 'all'
|
||||
? `/api/projects/${projectId}/schedules`
|
||||
: `/api/projects/${projectId}/schedules?status=${filter}`;
|
||||
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#project-schedules',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function exportProjectData() {
|
||||
window.location.href = `/api/projects/${projectId}/export`;
|
||||
}
|
||||
@@ -825,11 +981,239 @@ function archiveProject() {
|
||||
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schedule Modal Functions
|
||||
// ============================================================================
|
||||
|
||||
async function openScheduleModal() {
|
||||
// Reset form
|
||||
document.getElementById('schedule-name').value = '';
|
||||
document.getElementById('schedule-locations-container').innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>';
|
||||
document.getElementById('schedule-location-empty').classList.add('hidden');
|
||||
document.getElementById('schedule-location-error').classList.add('hidden');
|
||||
document.getElementById('schedule-error').classList.add('hidden');
|
||||
|
||||
// Reset to weekly calendar type
|
||||
document.querySelector('input[name="schedule_type"][value="weekly_calendar"]').checked = true;
|
||||
toggleScheduleType('weekly_calendar');
|
||||
|
||||
// Reset calendar checkboxes
|
||||
if (typeof clearAllDays === 'function') {
|
||||
clearAllDays();
|
||||
}
|
||||
|
||||
// Show modal
|
||||
document.getElementById('schedule-modal').classList.remove('hidden');
|
||||
|
||||
// Load locations
|
||||
await loadScheduleLocations();
|
||||
}
|
||||
|
||||
function closeScheduleModal() {
|
||||
document.getElementById('schedule-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadScheduleLocations() {
|
||||
const container = document.getElementById('schedule-locations-container');
|
||||
const emptyMsg = document.getElementById('schedule-location-empty');
|
||||
const errorMsg = document.getElementById('schedule-location-error');
|
||||
|
||||
// Reset state
|
||||
emptyMsg.classList.add('hidden');
|
||||
errorMsg.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/locations-json`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load locations');
|
||||
}
|
||||
const locations = await response.json();
|
||||
|
||||
if (!locations.length) {
|
||||
container.innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">No locations available</div>';
|
||||
emptyMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build checkboxes for each location
|
||||
container.innerHTML = locations.map(loc => `
|
||||
<label class="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
name="schedule_locations"
|
||||
value="${loc.id}"
|
||||
data-name="${loc.name}"
|
||||
data-type="${loc.location_type}"
|
||||
class="rounded text-seismo-orange focus:ring-seismo-orange">
|
||||
<span class="text-sm text-gray-900 dark:text-white">${loc.name}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">(${loc.location_type})</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add select all / clear all buttons if more than one location
|
||||
if (locations.length > 1) {
|
||||
container.insertAdjacentHTML('afterbegin', `
|
||||
<div class="flex gap-2 pb-2 mb-2 border-b border-gray-200 dark:border-gray-600">
|
||||
<button type="button" onclick="selectAllLocations()" class="text-xs text-seismo-orange hover:underline">Select All</button>
|
||||
<span class="text-gray-400">|</span>
|
||||
<button type="button" onclick="clearAllLocations()" class="text-xs text-gray-500 hover:underline">Clear All</button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load locations:', err);
|
||||
container.innerHTML = '<div class="text-red-500 text-sm py-2 text-center">Error loading locations</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllLocations() {
|
||||
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = true);
|
||||
}
|
||||
|
||||
function clearAllLocations() {
|
||||
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = false);
|
||||
}
|
||||
|
||||
function getSelectedLocationIds() {
|
||||
const checkboxes = document.querySelectorAll('input[name="schedule_locations"]:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function toggleScheduleType(type) {
|
||||
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
|
||||
const intervalEditor = document.getElementById('schedule-interval-wrapper');
|
||||
|
||||
if (type === 'weekly_calendar') {
|
||||
weeklyEditor.classList.remove('hidden');
|
||||
intervalEditor.classList.add('hidden');
|
||||
} else {
|
||||
weeklyEditor.classList.add('hidden');
|
||||
intervalEditor.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule form submission
|
||||
document.getElementById('schedule-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = document.getElementById('schedule-name').value.trim();
|
||||
const locationIds = getSelectedLocationIds();
|
||||
const scheduleType = document.querySelector('input[name="schedule_type"]:checked').value;
|
||||
const timezone = document.getElementById('schedule-timezone').value;
|
||||
|
||||
// Hide previous errors
|
||||
document.getElementById('schedule-location-error').classList.add('hidden');
|
||||
|
||||
if (!name) {
|
||||
showScheduleError('Please enter a schedule name.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!locationIds.length) {
|
||||
document.getElementById('schedule-location-error').classList.remove('hidden');
|
||||
showScheduleError('Please select at least one location.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build payload based on schedule type
|
||||
const payload = {
|
||||
name: name,
|
||||
location_ids: locationIds, // Array of location IDs
|
||||
schedule_type: scheduleType,
|
||||
timezone: timezone,
|
||||
};
|
||||
|
||||
if (scheduleType === 'weekly_calendar') {
|
||||
// Get weekly pattern from the calendar editor
|
||||
if (typeof getWeeklyPatternData === 'function') {
|
||||
payload.weekly_pattern = getWeeklyPatternData();
|
||||
} else {
|
||||
showScheduleError('Calendar editor not loaded properly.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate at least one day is selected
|
||||
const hasEnabledDay = Object.values(payload.weekly_pattern).some(day => day.enabled);
|
||||
if (!hasEnabledDay) {
|
||||
showScheduleError('Please select at least one day for monitoring.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get auto-increment setting for calendar mode
|
||||
if (typeof getCalendarAutoIncrement === 'function') {
|
||||
payload.auto_increment_index = getCalendarAutoIncrement();
|
||||
} else {
|
||||
payload.auto_increment_index = true;
|
||||
}
|
||||
|
||||
// Get include_download setting for calendar mode
|
||||
if (typeof getCalendarIncludeDownload === 'function') {
|
||||
payload.include_download = getCalendarIncludeDownload();
|
||||
} else {
|
||||
payload.include_download = true;
|
||||
}
|
||||
} else {
|
||||
// Get interval data
|
||||
if (typeof getIntervalData === 'function') {
|
||||
const intervalData = getIntervalData();
|
||||
payload.interval_type = intervalData.interval_type;
|
||||
payload.cycle_time = intervalData.cycle_time;
|
||||
payload.include_download = intervalData.include_download;
|
||||
payload.auto_increment_index = intervalData.auto_increment_index;
|
||||
} else {
|
||||
showScheduleError('Interval editor not loaded properly.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/recurring-schedules/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to create schedule');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Close modal and refresh schedules list
|
||||
closeScheduleModal();
|
||||
|
||||
// Refresh both the recurring schedules list and scheduled actions
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/recurring-schedules/partials/list`, {
|
||||
target: '#recurring-schedule-list',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/schedules?status=pending`, {
|
||||
target: '#project-schedules',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
|
||||
// Show success message
|
||||
console.log('Schedule(s) created:', result.message);
|
||||
|
||||
} catch (err) {
|
||||
showScheduleError(err.message || 'Failed to create schedule.');
|
||||
}
|
||||
});
|
||||
|
||||
function showScheduleError(message) {
|
||||
const errorEl = document.getElementById('schedule-error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Keyboard shortcuts
|
||||
// ============================================================================
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeLocationModal();
|
||||
closeAssignModal();
|
||||
closeScheduleModal();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -846,6 +1230,12 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('schedule-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeScheduleModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Load project details on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjectDetails();
|
||||
|
||||
Reference in New Issue
Block a user